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
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:
co-authored by
Claude Opus 5
parent
796b6e5297
commit
0cd9306068
+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)
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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?;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user