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>
This commit is contained in:
hugocasa
2026-09-16 10:29:21 +02:00
co-authored by Claude Opus 5
parent 796b6e5297
commit 0cd9306068
12 changed files with 359 additions and 120 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)
+25
View File
@@ -62,6 +62,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,
@@ -285,6 +286,13 @@ pub async fn initial_load(
);
if let Some(db) = conn.as_sql() {
// Outside the `server_mode` block below: `MODE=mcp` serves the MCP routes with
// `server_mode` false, and that deployment is the one most likely to set this.
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)
});
@@ -1614,6 +1622,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,83 @@
//! 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 router. Reordering
//! that stack, or adding a third MCP mount without it, leaves the switch inert while every
//! other MCP test still passes, so the two mounts are pinned here together.
#![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(())
}
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"
);
}
// 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(())
}
+8 -4
View File
@@ -61,10 +61,10 @@ 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,
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,
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,
@@ -1343,6 +1343,10 @@ pub async fn get_global_setting(
&& key != HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING
&& key != WS_BASE_URL_SETTING
&& key != INSTANCE_BANNER_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
@@ -558,7 +558,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(),
@@ -571,15 +571,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,
};
@@ -97,6 +97,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";
pub const AUTO_LOGIN_PROVIDER_SETTING: &str = "auto_login_provider";
/// Name of the SAML attribute or OIDC userinfo claim carrying the user's IdP groups. Unset or
/// empty leaves instance-group membership entirely to SCIM.
@@ -363,6 +367,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,13 @@
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'
import { onMount } from 'svelte'
type ConnectTab = 'cli' | 'mcp'
let drawer: Drawer | undefined = $state()
let tokenUrlDisabled = $state(false)
let selectedTab: ConnectTab = $state('cli')
let openVersion = $state(0)
@@ -19,6 +22,10 @@ wmill workspace add ${workspaceId} ${workspaceId} ${origin}
wmill init
wmill sync pull`)
onMount(async () => {
tokenUrlDisabled = await mcpTokenUrlDisabled()
})
function noop() {}
export function openDrawer(tab: ConnectTab = 'cli') {
@@ -96,8 +103,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>
@@ -686,6 +686,14 @@ export const settings: Record<string, Setting[]> = {
fieldType: 'boolean',
storage: 'setting'
},
{
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.',
key: 'mcp_disable_token_query_param',
fieldType: 'boolean',
storage: 'setting'
},
{
label: 'Auto-login SSO provider',
description:
@@ -1,12 +1,15 @@
<script lang="ts">
import { untrack } from 'svelte'
import { onMount, untrack } from 'svelte'
import { userWorkspaces, workspaceStore, type UserWorkspace } from '$lib/stores'
import { Button } from '../common'
import { Alert, Button } from '../common'
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
import Toggle from '../Toggle.svelte'
import { 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 TextInput from '../text_input/TextInput.svelte'
import Select from '../select/Select.svelte'
@@ -52,6 +55,9 @@
let pickedScopes = $state<string[] | null>(null)
let readOnly = $state(false)
// Instance refuses `?token=` on the MCP endpoints, so a generated token would not get a
// client in and the URL is handed over bare for the client's OAuth flow to complete.
let tokenUrlDisabled = $state(false)
function ensureCurrentWorkspaceIncluded(
workspacesList: UserWorkspace[],
@@ -144,11 +150,18 @@
const scopeWorkspaceId = $derived(
isAllWorkspaces ? $workspaceStore || '' : newTokenWorkspace || $workspaceStore || ''
)
const mcpBaseUrl = $derived(
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`
: `${window.location.origin}/api/mcp/w/${newTokenWorkspace}/mcp`
)
const mcpBaseUrl = $derived(`${mcpUrl}?token=`)
onMount(async () => {
if (showMcpMode || mcpOnly || openWithMcpMode) {
tokenUrlDisabled = await mcpTokenUrlDisabled()
}
})
$effect(() => {
const requestedMcpMode = mcpOnly || openWithMcpMode
@@ -176,7 +189,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 && tokenUrlDisabled ? 'MCP URL' : title}
</h3>
{#if showMcpMode && !mcpOnly}
<div
@@ -206,115 +221,149 @@
</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"
/>
</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}
<div>
<span class="block mb-1 text-xs text-emphasis font-semibold"
>Expires In <span class="text-xs text-primary">(optional)</span></span
>
{#if mcpCreationMode && tokenUrlDisabled}
{#if !lockWorkspace}
<div class="mb-4 max-w-md">
<span class="block mb-1 text-emphasis text-xs font-semibold">Workspace</span>
<Select
bind:value={newTokenExpiration}
placeholder="No expiration"
inputClass="w-full"
bind:value={newTokenWorkspace}
items={[
{ label: 'No expiration', value: undefined },
{ label: '15 minutes', value: 15 * 60 },
{ label: '30 minutes', value: 30 * 60 },
{ label: '1 hour', value: 1 * 60 * 60 },
{ label: '1 day', value: 1 * 24 * 60 * 60 },
{ label: '7 days', value: 7 * 24 * 60 * 60 },
{ label: '30 days', value: 30 * 24 * 60 * 60 },
{ label: '90 days', value: 90 * 24 * 60 * 60 },
{ label: '180 days', value: 180 * 24 * 60 * 60 },
{ label: '365 days', value: 365 * 24 * 60 * 60 }
{
label: 'All workspaces',
value: ALL_WORKSPACES,
subtitle: 'Multi-workspace'
},
...workspaces.map((w) => ({ label: w.name, value: w.id, subtitle: w.id }))
]}
/>
</div>
{/if}
</div>
<div class="mt-4 flex justify-end gap-2 flex-row">
<CopyableCodeBlock code={mcpUrl} language={shell} wrap />
<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>
{#if !mcpOnly}
<Button
on:click={() => {
exitMcpMode()
}}
variant="default"
>
Cancel
</Button>
<div class="mt-4 flex justify-end gap-2 flex-row">
<Button onClick={exitMcpMode} variant="default">Cancel</Button>
</div>
{/if}
<Button
on:click={() => createToken(mcpCreationMode)}
disabled={mcpCreationMode && (newTokenWorkspace == undefined || !pickedScopes)}
variant="accent"
>
{mcpCreationMode ? 'Generate MCP URL' : 'New token'}
</Button>
</div>
{: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"
/>
</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}
<div>
<span class="block mb-1 text-xs text-emphasis font-semibold"
>Expires In <span class="text-xs text-primary">(optional)</span></span
>
<Select
bind:value={newTokenExpiration}
placeholder="No expiration"
inputClass="w-full"
items={[
{ label: 'No expiration', value: undefined },
{ label: '15 minutes', value: 15 * 60 },
{ label: '30 minutes', value: 30 * 60 },
{ label: '1 hour', value: 1 * 60 * 60 },
{ label: '1 day', value: 1 * 24 * 60 * 60 },
{ label: '7 days', value: 7 * 24 * 60 * 60 },
{ label: '30 days', value: 30 * 24 * 60 * 60 },
{ label: '90 days', value: 90 * 24 * 60 * 60 },
{ label: '180 days', value: 180 * 24 * 60 * 60 },
{ label: '365 days', value: 365 * 24 * 60 * 60 }
]}
/>
</div>
{/if}
</div>
<div class="mt-4 flex justify-end gap-2 flex-row">
{#if !mcpOnly}
<Button
on:click={() => {
exitMcpMode()
}}
variant="default"
>
Cancel
</Button>
{/if}
<Button
on:click={() => createToken(mcpCreationMode)}
disabled={mcpCreationMode && (newTokenWorkspace == undefined || !pickedScopes)}
variant="accent"
>
{mcpCreationMode ? 'Generate MCP URL' : 'New token'}
</Button>
</div>
{/if}
</div>
{#if newToken && displayCreateToken}
+20
View File
@@ -0,0 +1,20 @@
import { SettingService } from '$lib/gen'
let cached: Promise<boolean> | undefined
/**
* 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.
*/
export function mcpTokenUrlDisabled(): Promise<boolean> {
cached ??= SettingService.getGlobal({ key: 'mcp_disable_token_query_param' })
.then((v) => (v as boolean | null) ?? false)
.catch((err) => {
console.error('Failed to load the MCP token setting:', err)
// Retry on the next caller rather than pinning the fallback for the session.
cached = undefined
return false
})
return cached
}