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:
hugocasa
2026-09-18 15:24:00 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent f0d66a42eb
commit 37e493ae66
12 changed files with 385 additions and 110 deletions
+7 -2
View File
@@ -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)
+27
View File
@@ -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(())
}
+9 -4
View File
@@ -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?;
}
+5 -3
View File
@@ -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,
+28 -2
View File
@@ -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> {
+1 -1
View File
@@ -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}
+20
View File
@@ -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
)
}