mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
feat: add an instance setting to refuse a token in MCP URLs (#11162)
* feat: add an instance setting to refuse a token in MCP URLs
MCP clients are commonly configured with the token in the URL
(`/api/mcp/w/{workspace}/mcp?token=...`). A URL-borne credential ends up in
browser history, proxy logs and referrers, so an instance can now turn that
channel off with the `mcp_disable_token_query_param` global setting and leave
the Authorization header as the only way in, which sends MCP clients through
the OAuth flow the endpoints already advertise.
The rejection is a middleware on both the workspaced and the gateway MCP
mounts, layered outside everything that reads a token and inside the
WWW-Authenticate layer, so the 401 carries the resource pointer a client needs
to start OAuth discovery.
Off by default. With it on, the token drawer and the home connect drawer stop
offering to mint a token for an MCP URL and hand over the bare URL instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: read the MCP URL policy when a URL is asked for, and drop the all-workspaces option
Two review findings on the token drawer:
The policy was read once per page load and cached for the browser session, so a
superadmin turning the setting on left every open tab handing out `?token=` URLs
the server now refuses. Both entry points now read it when the user actually asks
for an MCP URL: when MCP mode is entered, and when the connect drawer opens.
The workspace picker offered "All workspaces / Multi-workspace", but the gateway's
consent screen binds the token it issues to the one workspace picked there, so
OAuth has no multi-workspace grant to hand out. That entry is now token-only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: don't guess the MCP URL policy, and say where the switch lands on restart
Review findings:
The comment on the settings load claimed `MODE=mcp` as the target deployment, but
that mode joins no monitor loop, so the startup pass is its only read and a change
lands on restart. That is true of every global setting there, `base_url` included;
the comment now says so, and the setting description tells an operator running
dedicated MCP servers what to expect.
A failed settings probe resolved to "tokens allowed", so with the switch on the
drawer would mint a non-expiring token and hand over a URL the server refuses for
as long as it exists. The probe now propagates its error and the panel reports it
with a retry, creating nothing until the answer is known.
The test passed a valid token, so it could not tell a rejection before
authentication from one after it. It now also sends a token that was never valid
and asserts the middleware's own message, which fails if the layer moves inward.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: withhold the MCP URL until a workspace is picked
With no persisted workspace the store starts undefined, so opening the drawer from
/user/workspaces before choosing one rendered a copyable
`/api/mcp/w/undefined/mcp`. It reads like a real URL and a client pointed at it
would never connect. The panel now asks for a workspace instead, matching the guard
the token branch already has on its generate button.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: drop the coverage-status note from the MCP switch test
It documented what the test does not reach rather than a constraint the next
reader could break; that belongs in the PR, not the module doc. The layer-order
rationale, which is what a future edit would break, stays.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor: fall back to the bare MCP URL instead of alerting on a failed read
When the setting read fails, show the bare URL rather than an error with a retry.
It works whichever way the setting is, so no alert is needed, and it still never
mints a token for a URL the server may refuse. The connect drawer's wording falls
back the same way so the blurb matches the panel.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor: move the MCP URL token setting to Core
It sat in the Auth/OAuth/SAML list, which the settings sidebar shows under SSO,
suggesting a dependency on SSO that does not exist: MCP OAuth has Windmill act as
the authorization server, and any login method, password included, completes it.
It is an instance-wide credential policy, so it now lives with the other ones in
Core, kept out of quick setup like its neighbours.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f0d66a42eb
commit
37e493ae66
+7
-2
@@ -52,7 +52,8 @@ use windmill_common::{
|
||||
INSTANCE_EVENTS_WEBHOOK_SETTING, INSTANCE_PYTHON_VERSION_SETTING,
|
||||
JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING,
|
||||
KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING, MAVEN_SETTINGS_XML_SETTING,
|
||||
MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NO_DEFAULT_MAVEN_SETTING,
|
||||
MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING,
|
||||
NO_DEFAULT_MAVEN_SETTING,
|
||||
NPM_CONFIG_REGISTRY_SETTING, NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING,
|
||||
NUGET_CONFIG_SETTING, OAUTH_SETTING, OTEL_SETTING, OTEL_TRACES_RETENTION_SECS_SETTING,
|
||||
OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING,
|
||||
@@ -126,7 +127,8 @@ use windmill_worker::{
|
||||
|
||||
use crate::monitor::{
|
||||
initial_load, load_concurrency_key_max_queued, load_disable_password_login,
|
||||
load_fork_workspace_tag_append_fork_suffix, load_keep_job_dir, load_metrics_debug_enabled,
|
||||
load_fork_workspace_tag_append_fork_suffix, load_keep_job_dir,
|
||||
load_mcp_disable_token_query_param, load_metrics_debug_enabled,
|
||||
load_preview_tags_override, load_require_preexisting_user, load_retention_period_overrides,
|
||||
load_tag_per_workspace_enabled, load_tag_per_workspace_workspaces,
|
||||
load_workspace_fairness_duration_secs, load_workspace_fairness_enabled,
|
||||
@@ -2164,6 +2166,9 @@ async fn process_notify_event(
|
||||
DISABLE_PASSWORD_LOGIN_SETTING => {
|
||||
load_disable_password_login(db).await;
|
||||
}
|
||||
MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING => {
|
||||
load_mcp_disable_token_query_param(db).await;
|
||||
}
|
||||
EXPOSE_METRICS_SETTING => {
|
||||
tracing::info!("Metrics setting changed, restarting");
|
||||
spawn_graceful_killpill(tx, db, 30, "metrics setting change", server_mode)
|
||||
|
||||
@@ -65,6 +65,7 @@ use windmill_common::{
|
||||
FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING, HUB_API_SECRET_SETTING,
|
||||
HUB_BASE_URL_SETTING, INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING,
|
||||
JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING,
|
||||
MCP_DISABLE_TOKEN_QUERY_PARAM, MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING,
|
||||
MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING,
|
||||
NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING, NUGET_CONFIG_SETTING,
|
||||
OTEL_SETTING, OTEL_TRACES_RETENTION_SECS_SETTING, OTEL_TRACING_PROXY_SETTING,
|
||||
@@ -288,6 +289,15 @@ pub async fn initial_load(
|
||||
);
|
||||
|
||||
if let Some(db) = conn.as_sql() {
|
||||
// Outside the `server_mode` block below: a `MODE=mcp` process serves the MCP routes
|
||||
// with `server_mode` false and would otherwise never read this at all. That mode
|
||||
// joins no monitor loop, so there — as for every global setting, `base_url`
|
||||
// included — this pass is the only read, and a change lands on restart.
|
||||
pass.setting(
|
||||
MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING,
|
||||
false,
|
||||
|v| async move { apply_mcp_disable_token_query_param(v) },
|
||||
);
|
||||
pass.setting(DEFAULT_TAGS_PER_WORKSPACE_SETTING, false, |v| async move {
|
||||
apply_tag_per_workspace_enabled(v)
|
||||
});
|
||||
@@ -1617,6 +1627,23 @@ pub fn apply_disable_password_login(value: Option<serde_json::Value>) {
|
||||
};
|
||||
}
|
||||
|
||||
pub async fn load_mcp_disable_token_query_param(db: &DB) {
|
||||
match load_value_from_global_settings(db, MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING).await {
|
||||
Ok(v) => apply_mcp_disable_token_query_param(v),
|
||||
Err(e) => tracing::error!("Error loading mcp_disable_token_query_param setting: {e:#}"),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn apply_mcp_disable_token_query_param(value: Option<serde_json::Value>) {
|
||||
match value {
|
||||
Some(serde_json::Value::Bool(t)) => {
|
||||
MCP_DISABLE_TOKEN_QUERY_PARAM.store(t, Ordering::Relaxed)
|
||||
}
|
||||
None => MCP_DISABLE_TOKEN_QUERY_PARAM.store(false, Ordering::Relaxed),
|
||||
_ => (),
|
||||
};
|
||||
}
|
||||
|
||||
struct LogFile {
|
||||
file_path: String,
|
||||
hostname: String,
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! The `mcp_disable_token_query_param` switch closes the URL-borne credential path.
|
||||
//!
|
||||
//! The rejection is a middleware layered between the `WWW-Authenticate` decorator and
|
||||
//! everything that reads a token, on both the workspaced and the gateway mount. Each half of
|
||||
//! that sandwich is pinned: the `WWW-Authenticate` header on the refusal catches the layer
|
||||
//! being moved outward (a client would lose the pointer that starts OAuth discovery), and
|
||||
//! refusing a token that was never valid catches it being moved inward past authentication
|
||||
//! (the URL-borne token would be hashed and looked up before anything refused it).
|
||||
#![cfg(feature = "mcp")]
|
||||
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
use windmill_common::global_settings::MCP_DISABLE_TOKEN_QUERY_PARAM;
|
||||
use windmill_test_utils::*;
|
||||
|
||||
/// Workspace-less with an `mcp:` scope, which is what the gateway mount requires; the
|
||||
/// workspaced mount takes its workspace from the path, so one token reaches both.
|
||||
async fn insert_mcp_token(db: &Pool<Postgres>) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes)
|
||||
VALUES (encode(sha256('MCP_TOKEN'::bytea), 'hex'), 'MCP_TOK', 'MCP_TOKEN', 'test@windmill.dev', 'mcp token', true, ARRAY['mcp:all'])",
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A token that is not in `token` at all. Authentication would refuse it on its own, so a
|
||||
/// refusal carrying the middleware's own wording is evidence nothing looked it up first.
|
||||
const BOGUS_TOKEN: &str = "NOT_A_REAL_TOKEN";
|
||||
|
||||
async fn tools_list(url: &str) -> anyhow::Result<reqwest::Response> {
|
||||
Ok(reqwest::Client::new()
|
||||
.post(url)
|
||||
.header("Accept", "application/json, text/event-stream")
|
||||
.json(&json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }))
|
||||
.send()
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_mcp_token_query_param_switch(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
set_jwt_secret().await;
|
||||
insert_mcp_token(&db).await?;
|
||||
let server = ApiServer::start_mcp(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let workspaced =
|
||||
format!("http://localhost:{port}/api/mcp/w/test-workspace/mcp?token=MCP_TOKEN");
|
||||
let gateway = format!("http://localhost:{port}/api/mcp/gateway?token=MCP_TOKEN");
|
||||
|
||||
assert_eq!(
|
||||
tools_list(&workspaced).await?.status(),
|
||||
200,
|
||||
"a URL-borne token is the documented default and must keep working while the switch is off"
|
||||
);
|
||||
assert_eq!(tools_list(&gateway).await?.status(), 200);
|
||||
|
||||
MCP_DISABLE_TOKEN_QUERY_PARAM.store(true, Ordering::Relaxed);
|
||||
|
||||
for url in [&workspaced, &gateway] {
|
||||
let resp = tools_list(url).await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
401,
|
||||
"{url} still admitted a token in the URL"
|
||||
);
|
||||
// What sends the client into the OAuth flow rather than leaving it stuck on a 401.
|
||||
assert!(
|
||||
resp.headers().contains_key("www-authenticate"),
|
||||
"{url} rejected without pointing at the authorization server"
|
||||
);
|
||||
}
|
||||
|
||||
// Refused before authentication, not after: an invalid token gets the middleware's own
|
||||
// message rather than the generic 401 that looking it up would produce.
|
||||
let resp = tools_list(&format!(
|
||||
"http://localhost:{port}/api/mcp/w/test-workspace/mcp?token={BOGUS_TOKEN}"
|
||||
))
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 401);
|
||||
assert!(
|
||||
resp.text().await?.contains("does not accept a token in the MCP URL"),
|
||||
"an invalid URL token was answered by authentication, so the token was read before \
|
||||
the switch refused it"
|
||||
);
|
||||
|
||||
// The header stays open: it is the channel the OAuth flow itself hands tokens over on.
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!("http://localhost:{port}/api/mcp/gateway"))
|
||||
.header("Accept", "application/json, text/event-stream")
|
||||
.header("Authorization", "Bearer MCP_TOKEN")
|
||||
.json(&json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -64,10 +64,11 @@ use windmill_common::{
|
||||
GITHUB_APP_WEBHOOK_BASE_URL_SETTING, HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING,
|
||||
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING,
|
||||
INSTANCE_BANNER_SETTING, MAX_RETENTION_OVERRIDE_WORKSPACES,
|
||||
MAX_TOKEN_EXPIRATION_DAYS_SETTING, RETENTION_PERIOD_SECS_OVERRIDES_SETTING,
|
||||
RUFF_CONFIG_SETTING, UNIQUE_ID_SETTING, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING,
|
||||
WORKSPACE_FAIRNESS_ENABLED_SETTING, WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING,
|
||||
WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, WS_BASE_URL_SETTING,
|
||||
MAX_TOKEN_EXPIRATION_DAYS_SETTING, MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING,
|
||||
RETENTION_PERIOD_SECS_OVERRIDES_SETTING, RUFF_CONFIG_SETTING, UNIQUE_ID_SETTING,
|
||||
WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING,
|
||||
WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING,
|
||||
WS_BASE_URL_SETTING,
|
||||
},
|
||||
instance_config::{self, ApplyMode, InstanceConfig},
|
||||
server::Smtp,
|
||||
@@ -1364,6 +1365,10 @@ pub async fn get_global_setting(
|
||||
&& key != INSTANCE_BANNER_SETTING
|
||||
// The token form reads it to stop offering expirations the server would shorten.
|
||||
&& key != MAX_TOKEN_EXPIRATION_DAYS_SETTING
|
||||
// Whoever is wiring up an MCP client reads it to know whether a URL-borne token
|
||||
// would be refused, and they are usually not a superadmin. Not a secret: pointing
|
||||
// any MCP client at the instance discovers the same answer.
|
||||
&& key != MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING
|
||||
{
|
||||
require_super_admin(&db, &authed).await?;
|
||||
}
|
||||
|
||||
@@ -560,7 +560,7 @@ pub async fn run_server(
|
||||
if server_mode || mcp_mode {
|
||||
use mcp::{
|
||||
add_www_authenticate_header, add_www_authenticate_header_gateway,
|
||||
extract_workspace_from_token,
|
||||
extract_workspace_from_token, reject_token_query_param,
|
||||
};
|
||||
let (mcp_router, mcp_cancellation_token) = setup_mcp_server(
|
||||
db.clone(),
|
||||
@@ -573,15 +573,17 @@ pub async fn run_server(
|
||||
let workspaced_mcp_router = mcp_router
|
||||
.clone()
|
||||
.route_layer(from_extractor::<ApiAuthed>())
|
||||
.layer(axum::middleware::from_fn(reject_token_query_param))
|
||||
.layer(axum::middleware::from_fn(add_www_authenticate_header))
|
||||
.layer(axum::middleware::from_fn(extract_and_store_workspace_id));
|
||||
// Gateway MCP router — resolves workspace from token
|
||||
let gateway_mcp_router = mcp_router
|
||||
.route_layer(from_extractor::<ApiAuthed>())
|
||||
.layer(axum::middleware::from_fn(extract_workspace_from_token))
|
||||
.layer(axum::middleware::from_fn(reject_token_query_param))
|
||||
.layer(axum::middleware::from_fn(
|
||||
add_www_authenticate_header_gateway,
|
||||
))
|
||||
.layer(axum::middleware::from_fn(extract_workspace_from_token));
|
||||
));
|
||||
(
|
||||
workspaced_mcp_router,
|
||||
gateway_mcp_router,
|
||||
|
||||
@@ -43,11 +43,14 @@ use axum::{
|
||||
extract::{Extension, Path},
|
||||
http::Request,
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use windmill_common::{auth::hash_token, db::GatewayWorkspaceId, error::JsonResult};
|
||||
use windmill_common::{
|
||||
auth::hash_token, db::GatewayWorkspaceId, error::JsonResult,
|
||||
global_settings::MCP_DISABLE_TOKEN_QUERY_PARAM,
|
||||
};
|
||||
|
||||
// McpAuth impl for ApiAuthed is in windmill-api-auth (same crate as the type)
|
||||
|
||||
@@ -446,6 +449,29 @@ pub async fn add_www_authenticate_header(
|
||||
}
|
||||
}
|
||||
|
||||
/// Middleware refusing a credential carried in the MCP URL once the instance sets
|
||||
/// `mcp_disable_token_query_param`. Sits outside everything that reads the token, so neither
|
||||
/// the gateway lookup nor `ApiAuthed` ever sees it, and inside the `WWW-Authenticate` layer,
|
||||
/// whose header is what sends the client into the OAuth flow instead. Refused rather than
|
||||
/// ignored: the URL leaked the token whether or not the request used it.
|
||||
pub async fn reject_token_query_param(request: Request<axum::body::Body>, next: Next) -> Response {
|
||||
let carries_token = MCP_DISABLE_TOKEN_QUERY_PARAM.load(std::sync::atomic::Ordering::Relaxed)
|
||||
&& request
|
||||
.uri()
|
||||
.query()
|
||||
.is_some_and(|q| url::form_urlencoded::parse(q.as_bytes()).any(|(k, _)| k == "token"));
|
||||
if carries_token {
|
||||
return (
|
||||
axum::http::StatusCode::UNAUTHORIZED,
|
||||
"This instance does not accept a token in the MCP URL. Remove the token query \
|
||||
parameter and let your client sign in through OAuth, or send the token in an \
|
||||
Authorization header.",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
/// Extract the bearer token from either the `Authorization` header or the
|
||||
/// `?token=` query parameter (MCP clients commonly pass it in the URL).
|
||||
fn extract_gateway_token(request: &Request<axum::body::Body>) -> Option<String> {
|
||||
|
||||
@@ -12,5 +12,5 @@ pub mod oauth_server;
|
||||
pub use core::{
|
||||
add_www_authenticate_header, add_www_authenticate_header_gateway,
|
||||
extract_and_store_workspace_id, extract_workspace_from_token, list_tools_service,
|
||||
setup_mcp_server,
|
||||
reject_token_query_param, setup_mcp_server,
|
||||
};
|
||||
|
||||
@@ -102,6 +102,10 @@ pub const HUB_API_SECRET_SETTING: &str = "hub_api_secret";
|
||||
pub const AUTOMATE_USERNAME_CREATION_SETTING: &str = "automate_username_creation";
|
||||
pub const DISABLE_WORKSPACE_INVITE_EMAILS_SETTING: &str = "disable_workspace_invite_emails";
|
||||
pub const DISABLE_PASSWORD_LOGIN_SETTING: &str = "disable_password_login";
|
||||
/// Refuse `?token=` on the MCP endpoints, leaving the `Authorization` header as the only way
|
||||
/// in. A URL-borne credential ends up in browser history, proxy logs and referrers, so an
|
||||
/// instance that cares sends MCP clients through the OAuth flow instead.
|
||||
pub const MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING: &str = "mcp_disable_token_query_param";
|
||||
/// Ceiling, in days, on how far ahead a token minted through `POST /users/tokens/create` or
|
||||
/// `POST /users/tokens/impersonate` may expire; a request asking for more, or for no
|
||||
/// expiration at all, is shortened to it rather than refused. On those routes only: server-side
|
||||
@@ -407,6 +411,7 @@ use std::sync::atomic::AtomicBool;
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref HTTP_ROUTE_WORKSPACED_ROUTE: AtomicBool = AtomicBool::new(false);
|
||||
pub static ref DISABLE_PASSWORD_LOGIN: AtomicBool = AtomicBool::new(false);
|
||||
pub static ref MCP_DISABLE_TOKEN_QUERY_PARAM: AtomicBool = AtomicBool::new(false);
|
||||
/// Origins HTTP routes allow cross-origin when they configure none of their
|
||||
/// own. Empty means unset, which keeps the historical `*`.
|
||||
pub static ref HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS: arc_swap::ArcSwap<Vec<String>> =
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
import CopyableCodeBlock from '$lib/components/details/CopyableCodeBlock.svelte'
|
||||
import { Bot, ExternalLink, Terminal } from 'lucide-svelte'
|
||||
import { shell } from 'svelte-highlight/languages'
|
||||
import { mcpTokenUrlDisabled } from '$lib/mcpAuth'
|
||||
|
||||
type ConnectTab = 'cli' | 'mcp'
|
||||
|
||||
let drawer: Drawer | undefined = $state()
|
||||
let tokenUrlDisabled = $state(false)
|
||||
let selectedTab: ConnectTab = $state('cli')
|
||||
let openVersion = $state(0)
|
||||
|
||||
@@ -24,6 +26,10 @@ wmill sync pull`)
|
||||
export function openDrawer(tab: ConnectTab = 'cli') {
|
||||
selectedTab = tab
|
||||
openVersion += 1
|
||||
// Falls back like CreateToken below, which shows the bare URL when the read fails.
|
||||
void mcpTokenUrlDisabled()
|
||||
.then((v) => (tokenUrlDisabled = v))
|
||||
.catch(() => (tokenUrlDisabled = true))
|
||||
drawer?.openDrawer()
|
||||
}
|
||||
|
||||
@@ -96,8 +102,13 @@ wmill sync pull`)
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-sm font-semibold text-emphasis">MCP URL</h3>
|
||||
<p class="text-xs text-secondary max-w-xl">
|
||||
Generate an MCP server URL for the current workspace and choose which
|
||||
scripts, flows, and endpoints the client can access.
|
||||
{#if tokenUrlDisabled}
|
||||
The MCP server URL for the current workspace. Your client signs in to
|
||||
Windmill to use it.
|
||||
{:else}
|
||||
Generate an MCP server URL for the current workspace and choose which
|
||||
scripts, flows, and endpoints the client can access.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -325,6 +325,15 @@ export const settings: Record<string, Setting[]> = {
|
||||
value === null ||
|
||||
value === '' ||
|
||||
parseMaxTokenExpirationDays(value) !== undefined
|
||||
},
|
||||
{
|
||||
label: 'Disable token in MCP URLs',
|
||||
description:
|
||||
'Reject the ?token= query parameter on the MCP endpoints, so MCP clients authenticate with an Authorization header or through the OAuth flow. A token in a URL is a credential that ends up in browser history, proxy logs and referrers. Existing MCP URLs carrying a token stop working. Servers and workers pick this up within a minute; dedicated MCP servers (MODE=mcp) apply it when they next restart.',
|
||||
key: 'mcp_disable_token_query_param',
|
||||
fieldType: 'boolean',
|
||||
storage: 'setting',
|
||||
hideInQuickSetup: true
|
||||
}
|
||||
],
|
||||
Jobs: [
|
||||
|
||||
@@ -6,12 +6,15 @@
|
||||
workspaceStore,
|
||||
type UserWorkspace
|
||||
} from '$lib/stores'
|
||||
import { Button } from '../common'
|
||||
import { Alert, Button, Skeleton } from '../common'
|
||||
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
|
||||
import Toggle from '../Toggle.svelte'
|
||||
import { SettingService, UserService, type NewToken } from '$lib/gen'
|
||||
import { mcpTokenUrlDisabled } from '$lib/mcpAuth'
|
||||
import TokenDisplay from './TokenDisplay.svelte'
|
||||
import ScopesPicker from './ScopesPicker.svelte'
|
||||
import CopyableCodeBlock from '../details/CopyableCodeBlock.svelte'
|
||||
import { shell } from 'svelte-highlight/languages'
|
||||
import { parseMaxTokenExpirationDays } from '$lib/tokenExpiration'
|
||||
|
||||
import TextInput from '../text_input/TextInput.svelte'
|
||||
@@ -59,6 +62,22 @@
|
||||
|
||||
let pickedScopes = $state<string[] | null>(null)
|
||||
let readOnly = $state(false)
|
||||
// How this instance lets an MCP client in. `oauth` means it refuses `?token=`, so a
|
||||
// generated token would not get a client in and the URL is handed over bare instead.
|
||||
// A failed read lands on `oauth`: the bare URL works whichever way the setting is,
|
||||
// whereas guessing `token` mints a non-expiring credential the server may refuse.
|
||||
type McpUrlPolicy = 'loading' | 'token' | 'oauth'
|
||||
let mcpUrlPolicy = $state<McpUrlPolicy>('loading')
|
||||
|
||||
async function loadMcpUrlPolicy() {
|
||||
mcpUrlPolicy = 'loading'
|
||||
try {
|
||||
mcpUrlPolicy = (await mcpTokenUrlDisabled()) ? 'oauth' : 'token'
|
||||
} catch (err) {
|
||||
console.error('Failed to load the MCP token setting:', err)
|
||||
mcpUrlPolicy = 'oauth'
|
||||
}
|
||||
}
|
||||
|
||||
const DAY_SECS = 24 * 60 * 60
|
||||
const EXPIRATION_CHOICES = [
|
||||
@@ -117,6 +136,7 @@
|
||||
|
||||
function enterMcpMode() {
|
||||
mcpCreationMode = true
|
||||
void loadMcpUrlPolicy()
|
||||
resetExpirationOnModeChange()
|
||||
newTokenWorkspace = defaultNewTokenWorkspace ?? $workspaceStore
|
||||
newToken = undefined
|
||||
@@ -224,11 +244,17 @@
|
||||
const scopeWorkspaceId = $derived(
|
||||
isAllWorkspaces ? $workspaceStore || '' : newTokenWorkspace || $workspaceStore || ''
|
||||
)
|
||||
const mcpBaseUrl = $derived(
|
||||
// Undefined wherever the workspace is: `/api/mcp/w/undefined/mcp` reads like a real URL
|
||||
// and is copyable, so the OAuth panel withholds it rather than showing a broken one. The
|
||||
// token branch guards the same case by disabling its generate button.
|
||||
const mcpUrl = $derived(
|
||||
isAllWorkspaces
|
||||
? `${window.location.origin}/api/mcp/gateway?token=`
|
||||
: `${window.location.origin}/api/mcp/w/${newTokenWorkspace}/mcp?token=`
|
||||
? `${window.location.origin}/api/mcp/gateway`
|
||||
: newTokenWorkspace
|
||||
? `${window.location.origin}/api/mcp/w/${newTokenWorkspace}/mcp`
|
||||
: undefined
|
||||
)
|
||||
const mcpBaseUrl = $derived(`${mcpUrl ?? ''}?token=`)
|
||||
|
||||
$effect(() => {
|
||||
const requestedMcpMode = mcpOnly || openWithMcpMode
|
||||
@@ -256,7 +282,9 @@
|
||||
<!-- Stays bounded by the panel width: a content-driven width (min-w-min) would let a long
|
||||
scope chip stretch this card and push the rest of the form out of view. -->
|
||||
<div class="p-4 rounded-md mb-6 bg-surface-tertiary">
|
||||
<h3 class="pb-2 font-semibold text-emphasis text-sm">{title}</h3>
|
||||
<h3 class="pb-2 font-semibold text-emphasis text-sm">
|
||||
{mcpCreationMode && mcpUrlPolicy !== 'token' ? 'MCP URL' : title}
|
||||
</h3>
|
||||
|
||||
{#if showMcpMode && !mcpOnly}
|
||||
<div
|
||||
@@ -286,112 +314,147 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if scopes != undefined}
|
||||
<div class="mb-4">
|
||||
<span class="block mb-1 text-emphasis text-xs font-semibold">Scope</span>
|
||||
{#each scopes as scope (scope)}
|
||||
<TextInput inputProps={{ disabled: true }} value={scope} class="mb-2 w-full" />
|
||||
{/each}
|
||||
<div class="text-tertiary">
|
||||
<Toggle
|
||||
bind:checked={readOnly}
|
||||
options={{
|
||||
right: 'Read-only',
|
||||
rightTooltip:
|
||||
'Restricts this token to GET/HEAD endpoints. Any mutating request (POST/PUT/PATCH/DELETE) or job-run action will be rejected with 403, regardless of the scopes listed above.'
|
||||
}}
|
||||
size="2xs"
|
||||
{#if mcpCreationMode && mcpUrlPolicy === 'loading'}
|
||||
<Skeleton layout={[[2], 0.5, [1]]} />
|
||||
{:else if mcpCreationMode && mcpUrlPolicy === 'oauth'}
|
||||
{#if !lockWorkspace}
|
||||
<div class="mb-4 max-w-md">
|
||||
<span class="block mb-1 text-emphasis text-xs font-semibold">Workspace</span>
|
||||
<!-- No all-workspaces entry: the gateway's consent screen binds the token it issues
|
||||
to the one workspace picked there, so OAuth has no multi-workspace grant to offer. -->
|
||||
<Select
|
||||
bind:value={newTokenWorkspace}
|
||||
items={workspaces.map((w) => ({ label: w.name, value: w.id, subtitle: w.id }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if !scopes || scopes.length === 0}
|
||||
<ScopesPicker
|
||||
mode={mcpCreationMode ? 'mcp' : 'standard'}
|
||||
workspaceId={scopeWorkspaceId}
|
||||
bind:value={pickedScopes}
|
||||
bind:readOnly
|
||||
/>
|
||||
{/if}
|
||||
{#if mcpUrl}
|
||||
<CopyableCodeBlock code={mcpUrl} language={shell} wrap />
|
||||
|
||||
<div class="mt-2 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{#if mcpCreationMode}
|
||||
{#if !lockWorkspace}
|
||||
<div>
|
||||
<span class="block mb-1 text-emphasis text-xs font-semibold">Workspace</span>
|
||||
<Select
|
||||
bind:value={newTokenWorkspace}
|
||||
items={[
|
||||
{
|
||||
label: 'All workspaces',
|
||||
value: ALL_WORKSPACES,
|
||||
subtitle: 'Multi-workspace'
|
||||
},
|
||||
...workspaces.map((w) => ({ label: w.name, value: w.id, subtitle: w.id }))
|
||||
]}
|
||||
<div class="mt-2">
|
||||
<Alert type="info" title="This instance requires MCP clients to sign in" size="xs">
|
||||
Paste this URL into your client. It opens a Windmill page where you approve the access
|
||||
it asks for, and no token needs to be copied around.
|
||||
</Alert>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-tertiary">Pick a workspace to get its MCP URL.</p>
|
||||
{/if}
|
||||
|
||||
{#if !mcpOnly}
|
||||
<div class="mt-4 flex justify-end gap-2 flex-row">
|
||||
<Button onClick={exitMcpMode} variant="default">Cancel</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if scopes != undefined}
|
||||
<div class="mb-4">
|
||||
<span class="block mb-1 text-emphasis text-xs font-semibold">Scope</span>
|
||||
{#each scopes as scope (scope)}
|
||||
<TextInput inputProps={{ disabled: true }} value={scope} class="mb-2 w-full" />
|
||||
{/each}
|
||||
<div class="text-tertiary">
|
||||
<Toggle
|
||||
bind:checked={readOnly}
|
||||
options={{
|
||||
right: 'Read-only',
|
||||
rightTooltip:
|
||||
'Restricts this token to GET/HEAD endpoints. Any mutating request (POST/PUT/PATCH/DELETE) or job-run action will be rejected with 403, regardless of the scopes listed above.'
|
||||
}}
|
||||
size="2xs"
|
||||
/>
|
||||
{#if isAllWorkspaces}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !scopes || scopes.length === 0}
|
||||
<ScopesPicker
|
||||
mode={mcpCreationMode ? 'mcp' : 'standard'}
|
||||
workspaceId={scopeWorkspaceId}
|
||||
bind:value={pickedScopes}
|
||||
bind:readOnly
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="mt-2 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{#if mcpCreationMode}
|
||||
{#if !lockWorkspace}
|
||||
<div>
|
||||
<span class="block mb-1 text-emphasis text-xs font-semibold">Workspace</span>
|
||||
<Select
|
||||
bind:value={newTokenWorkspace}
|
||||
items={[
|
||||
{
|
||||
label: 'All workspaces',
|
||||
value: ALL_WORKSPACES,
|
||||
subtitle: 'Multi-workspace'
|
||||
},
|
||||
...workspaces.map((w) => ({ label: w.name, value: w.id, subtitle: w.id }))
|
||||
]}
|
||||
/>
|
||||
{#if isAllWorkspaces}
|
||||
<p class="mt-1 text-xs text-tertiary">
|
||||
This token works across every workspace you can access. Tools take a
|
||||
<code>workspace_id</code> argument; call <code>list_workspaces</code> to discover them.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if !mcpOnly}
|
||||
<div>
|
||||
<span class="block mb-1 text-emphasis text-xs font-semibold"
|
||||
>Label <span class="text-xs text-primary">(optional)</span></span
|
||||
>
|
||||
<TextInput inputProps={{ type: 'text' }} bind:value={newTokenLabel} class="w-full" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !mcpCreationMode || maxExpirationSecs != undefined}
|
||||
<div>
|
||||
<span class="block mb-1 text-xs text-emphasis font-semibold">
|
||||
Expires In
|
||||
{#if maxExpirationSecs == undefined}
|
||||
<span class="text-xs text-primary">(optional)</span>
|
||||
{/if}
|
||||
</span>
|
||||
<Select
|
||||
bind:value={() => newTokenExpiration, (v) => (pickedExpiration = v)}
|
||||
placeholder={maxExpirationSecs == undefined ? 'No expiration' : 'Pick an expiration'}
|
||||
inputClass="w-full"
|
||||
items={expirationItems}
|
||||
/>
|
||||
{#if maxExpirationSecs != undefined}
|
||||
<p class="mt-1 text-xs text-tertiary">
|
||||
This token works across every workspace you can access. Tools take a
|
||||
<code>workspace_id</code> argument; call <code>list_workspaces</code> to discover them.
|
||||
This instance limits tokens to {maxExpirationLabel}.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !mcpOnly}
|
||||
<div>
|
||||
<span class="block mb-1 text-emphasis text-xs font-semibold"
|
||||
>Label <span class="text-xs text-primary">(optional)</span></span
|
||||
<div class="mt-4 flex justify-end gap-2 flex-row">
|
||||
{#if !mcpOnly}
|
||||
<Button
|
||||
on:click={() => {
|
||||
exitMcpMode()
|
||||
}}
|
||||
variant="default"
|
||||
>
|
||||
<TextInput inputProps={{ type: 'text' }} bind:value={newTokenLabel} class="w-full" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !mcpCreationMode || maxExpirationSecs != undefined}
|
||||
<div>
|
||||
<span class="block mb-1 text-xs text-emphasis font-semibold">
|
||||
Expires In
|
||||
{#if maxExpirationSecs == undefined}
|
||||
<span class="text-xs text-primary">(optional)</span>
|
||||
{/if}
|
||||
</span>
|
||||
<Select
|
||||
bind:value={() => newTokenExpiration, (v) => (pickedExpiration = v)}
|
||||
placeholder={maxExpirationSecs == undefined ? 'No expiration' : 'Pick an expiration'}
|
||||
inputClass="w-full"
|
||||
items={expirationItems}
|
||||
/>
|
||||
{#if maxExpirationSecs != undefined}
|
||||
<p class="mt-1 text-xs text-tertiary">
|
||||
This instance limits tokens to {maxExpirationLabel}.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-end gap-2 flex-row">
|
||||
{#if !mcpOnly}
|
||||
Cancel
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
on:click={() => {
|
||||
exitMcpMode()
|
||||
}}
|
||||
variant="default"
|
||||
on:click={() => createToken(mcpCreationMode)}
|
||||
disabled={mcpCreationMode && (newTokenWorkspace == undefined || !pickedScopes)}
|
||||
variant="accent"
|
||||
>
|
||||
Cancel
|
||||
{mcpCreationMode ? 'Generate MCP URL' : 'New token'}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
on:click={() => createToken(mcpCreationMode)}
|
||||
disabled={mcpCreationMode && (newTokenWorkspace == undefined || !pickedScopes)}
|
||||
variant="accent"
|
||||
>
|
||||
{mcpCreationMode ? 'Generate MCP URL' : 'New token'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if newToken && displayCreateToken}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { SettingService } from '$lib/gen'
|
||||
|
||||
/**
|
||||
* Whether the instance refuses `?token=` on the MCP endpoints. When it does, an MCP URL is
|
||||
* handed over bare and the client reaches it by completing the OAuth flow, so nothing in the
|
||||
* UI should offer to mint a token for one.
|
||||
*
|
||||
* Deliberately uncached: callers read it at the moment an MCP URL is asked for, so a superadmin
|
||||
* flipping the setting does not leave open tabs handing out URLs the server now refuses.
|
||||
*
|
||||
* Throws rather than falling back, so the caller picks the safe default. Guessing `false`
|
||||
* here would mint a non-expiring token for a URL the server may refuse.
|
||||
*/
|
||||
export async function mcpTokenUrlDisabled(): Promise<boolean> {
|
||||
return (
|
||||
((await SettingService.getGlobal({
|
||||
key: 'mcp_disable_token_query_param'
|
||||
})) as boolean | null) ?? false
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user