From 8343203ec2cea28a2ffd5b4ac636497e861fa3ce Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 10 Jul 2026 23:32:30 +0200 Subject: [PATCH] feat(mcp): add multi-workspace MCP tokens via the gateway endpoint (#10043) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mcp): add multi-workspace MCP tokens via the gateway endpoint A single MCP token with no bound workspace (workspace_id NULL + mcp scope) now works across every workspace the token owner can access, served through the existing /api/mcp/gateway endpoint. This avoids having to register one MCP server entry per workspace in clients like Claude/Cursor. In multi-workspace mode the runner exposes a synthetic `list_workspaces` tool plus the generic API endpoint tools, each workspace-scoped one gaining a required `workspace_id` argument (mirroring the proxy pattern users built externally). Per-workspace scripts/flows are not enumerated to avoid flooding the tool list — they are run via runScriptByPath/runFlowByPath with an explicit workspace_id. Auth is resolved per tool call: the gateway middleware detects a workspace-less mcp token and marks the request MultiWorkspaceMcp, and the runner resolves a per-workspace ApiAuthed from the raw token via the AuthCache (validating membership; superadmins may act in any workspace). Single-workspace tokens are unchanged. Frontend: the MCP token creation flow gains an "All workspaces" option that produces a workspace-less token and the gateway URL. Fixes WIN-2153 Co-Authored-By: Claude Opus 4.8 (1M context) * test(mcp): cover multi-workspace endpoint tool transformation Unit tests for endpoint_tool_to_mcp_tool_multi and list_workspaces_tool: workspace-scoped tools gain a required workspace_id arg, global tools are left unchanged, workspace_id is not duplicated, and list_workspaces takes no arguments. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(mcp): forward script/flow args for runScriptByPath/runFlowByPath These endpoints have an additionalProperties body (no declared properties), so build_request_body previously returned an empty body and dropped every script/flow argument. This was latent for the per-path run endpoints and became load-bearing in multi-workspace mode, where scripts/flows can only be run via runScriptByPath/runFlowByPath — parameterized runs silently lost their arguments. build_request_body now forwards all arguments not consumed by a path/query parameter for pass-through (additionalProperties) bodies, keeping the strict declared-only behavior for endpoints with explicit properties. The runner strips the synthetic workspace_id argument before dispatch so it can't leak into the forwarded body. Reported by Codex review on #10043. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(mcp): note workspace_id requirement in multi-workspace tool descriptions Workspace-scoped tools already gain a required workspace_id parameter (with its own schema description) in multi-workspace mode, but the tool's prose description was unchanged. Append a note so models/clients that read the description text know to pass workspace_id (and to call list_workspaces first). Global tool descriptions are left untouched. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(mcp): trim multi-workspace tool/arg descriptions The workspace_id note repeats across every workspace-scoped tool in each tools/list, so keep it terse: description suffix "Requires `workspace_id`." and arg description "Target workspace id (from list_workspaces)." to avoid spending tokens on repeated boilerplate. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(mcp): enforce script/flow path scopes for multi-workspace run-by-path In multi-workspace mode runScriptByPath/runFlowByPath are the only way to run scripts/flows, but they were authorized against the endpoint scope only — never the caller's mcp:scripts:/mcp:flows: path scopes. A granular token could run items outside its allowed paths (e.g. mcp:scripts:f/team/* + mcp:endpoints:* running f/other/secret), and a mcp:endpoints:* token could run arbitrary scripts. Now these two endpoints are authorized by the script/flow scope of the requested path (matching single-workspace mode's per-item tools): exposed in list_tools only when the token grants some script/flow (McpScopeConfig::has_any), and at call time the path is checked via is_allowed("script"/"flow", path). Verified e2e: mcp:scripts:f/team/* runs f/team/* but is denied f/other/*; mcp:endpoints:* alone no longer exposes or runs run-by-path. Reported by Codex + Pi review on #10043. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(mcp): deny run-by-path for mcp:favorites multi-workspace tokens mcp:favorites sets granular=false, so the previous run-by-path scope check (gated on `granular`) was skipped entirely — a default "Favorites only" all-workspaces token could run any script/flow by naming its path, bypassing the favorites restriction. Favorites are an enumerated set reachable only through per-item tools, not by arbitrary path, so they grant nothing for run-by-path. has_any() now returns true only for mcp:all (not favorites), and the call-time check drops the `granular` gate and relies on is_allowed() directly (already false for favorites, true for mcp:all, pattern-matched for granular). Verified e2e: mcp:favorites no longer exposes or runs run-by-path; mcp:all still runs; granular script scopes still path-enforced. Reported by Codex review on #10043. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- ...1fde70711b2fb8ec6826411cc7d74687d5bcb.json | 26 ++ ...e0cde0e125f8ed426bd784dd7b92e1d21dfdf.json | 28 ++ ...046cd3af17c5ce0d4426153b3065b2faaa781.json | 28 ++ backend/windmill-api/src/lib.rs | 9 +- backend/windmill-api/src/mcp/core.rs | 142 +++++-- backend/windmill-api/src/mcp/utils.rs | 97 +++++ backend/windmill-mcp/src/common/scope.rs | 43 ++ backend/windmill-mcp/src/common/types.rs | 21 + backend/windmill-mcp/src/server/backend.rs | 23 +- backend/windmill-mcp/src/server/endpoints.rs | 196 +++++++++ backend/windmill-mcp/src/server/mod.rs | 6 +- backend/windmill-mcp/src/server/runner.rs | 402 +++++++++++++++--- .../components/settings/CreateToken.svelte | 43 +- 13 files changed, 963 insertions(+), 101 deletions(-) create mode 100644 backend/.sqlx/query-67c405ff2bfd68119dbd5e2edc91fde70711b2fb8ec6826411cc7d74687d5bcb.json create mode 100644 backend/.sqlx/query-a460f0ca8f23a8eb9d808b5edd6e0cde0e125f8ed426bd784dd7b92e1d21dfdf.json create mode 100644 backend/.sqlx/query-b3b06ec52fde4b8264c6307c24b046cd3af17c5ce0d4426153b3065b2faaa781.json diff --git a/backend/.sqlx/query-67c405ff2bfd68119dbd5e2edc91fde70711b2fb8ec6826411cc7d74687d5bcb.json b/backend/.sqlx/query-67c405ff2bfd68119dbd5e2edc91fde70711b2fb8ec6826411cc7d74687d5bcb.json new file mode 100644 index 0000000000..df8b5cce99 --- /dev/null +++ b/backend/.sqlx/query-67c405ff2bfd68119dbd5e2edc91fde70711b2fb8ec6826411cc7d74687d5bcb.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, name FROM workspace WHERE deleted = false ORDER BY name", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false + ] + }, + "hash": "67c405ff2bfd68119dbd5e2edc91fde70711b2fb8ec6826411cc7d74687d5bcb" +} diff --git a/backend/.sqlx/query-a460f0ca8f23a8eb9d808b5edd6e0cde0e125f8ed426bd784dd7b92e1d21dfdf.json b/backend/.sqlx/query-a460f0ca8f23a8eb9d808b5edd6e0cde0e125f8ed426bd784dd7b92e1d21dfdf.json new file mode 100644 index 0000000000..43679e5287 --- /dev/null +++ b/backend/.sqlx/query-a460f0ca8f23a8eb9d808b5edd6e0cde0e125f8ed426bd784dd7b92e1d21dfdf.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT workspace_id, scopes FROM token WHERE token_hash = $1 AND (expiration > NOW() OR expiration IS NULL)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "scopes", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true, + true + ] + }, + "hash": "a460f0ca8f23a8eb9d808b5edd6e0cde0e125f8ed426bd784dd7b92e1d21dfdf" +} diff --git a/backend/.sqlx/query-b3b06ec52fde4b8264c6307c24b046cd3af17c5ce0d4426153b3065b2faaa781.json b/backend/.sqlx/query-b3b06ec52fde4b8264c6307c24b046cd3af17c5ce0d4426153b3065b2faaa781.json new file mode 100644 index 0000000000..ce93a35191 --- /dev/null +++ b/backend/.sqlx/query-b3b06ec52fde4b8264c6307c24b046cd3af17c5ce0d4426153b3065b2faaa781.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT workspace.id, workspace.name\n FROM workspace\n JOIN usr ON usr.workspace_id = workspace.id\n WHERE usr.email = $1 AND usr.disabled = false AND workspace.deleted = false\n ORDER BY workspace.name", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "b3b06ec52fde4b8264c6307c24b046cd3af17c5ce0d4426153b3065b2faaa781" +} diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index cbbb406db4..b4f3482ac1 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -483,8 +483,13 @@ pub async fn run_server( add_www_authenticate_header, add_www_authenticate_header_gateway, extract_workspace_from_token, }; - let (mcp_router, mcp_cancellation_token) = - setup_mcp_server(db.clone(), user_db, _base_internal_url.clone()).await?; + let (mcp_router, mcp_cancellation_token) = setup_mcp_server( + db.clone(), + user_db, + _base_internal_url.clone(), + auth_cache.clone(), + ) + .await?; // Workspace-scoped MCP router let workspaced_mcp_router = mcp_router .clone() diff --git a/backend/windmill-api/src/mcp/core.rs b/backend/windmill-api/src/mcp/core.rs index 52fec66096..db1644379d 100644 --- a/backend/windmill-api/src/mcp/core.rs +++ b/backend/windmill-api/src/mcp/core.rs @@ -10,10 +10,11 @@ use windmill_common::{db::UserDB, utils::StripPath, DB}; use windmill_mcp::common::schema::enrich_resource_schemas; use windmill_mcp::common::transform::apply_key_transformation; use windmill_mcp::common::types::{ - FlowInfo, HubScriptInfo, ResourceInfo, ResourceType, SchemaType, ScriptInfo, + FlowInfo, HubScriptInfo, ResourceInfo, ResourceType, SchemaType, ScriptInfo, WorkspaceInfo, }; use windmill_mcp::server::{BackendResult, EndpointTool, ErrorData, McpBackend}; +use crate::auth::AuthCache; use crate::db::ApiAuthed; use crate::jobs::{ run_wait_result_flow_by_path_internal, run_wait_result_script_by_path_internal, RunJobQuery, @@ -31,7 +32,8 @@ use std::time::Duration; use tokio_util::sync::CancellationToken; use windmill_mcp::server::{ - LocalSessionManager, Runner, StreamableHttpServerConfig, StreamableHttpService, + LocalSessionManager, McpToken, MultiWorkspaceMcp, Runner, StreamableHttpServerConfig, + StreamableHttpService, }; use windmill_mcp::WorkspaceId; @@ -53,11 +55,17 @@ pub struct WindmillBackend { pub db: DB, pub user_db: UserDB, pub base_internal_url: String, + pub auth_cache: Arc, } impl WindmillBackend { - pub fn new(db: DB, user_db: UserDB, base_internal_url: String) -> Self { - Self { db, user_db, base_internal_url } + pub fn new( + db: DB, + user_db: UserDB, + base_internal_url: String, + auth_cache: Arc, + ) -> Self { + Self { db, user_db, base_internal_url, auth_cache } } } @@ -305,6 +313,8 @@ impl McpBackend for WindmillBackend { args_map, &endpoint_tool.body_schema, &endpoint_tool.body_field_renames, + &endpoint_tool.path_params_schema, + &endpoint_tool.query_params_schema, ); // Create and execute request @@ -338,6 +348,57 @@ impl McpBackend for WindmillBackend { } } + async fn list_accessible_workspaces( + &self, + auth: &ApiAuthed, + ) -> BackendResult> { + // A superadmin can act in every workspace and often has no explicit `usr` + // membership row (matching resolve_workspace_auth, which authorizes any + // workspace for a superadmin), so list them all. Everyone else is limited + // to the workspaces they are a member of. + let workspaces = if auth.is_admin { + sqlx::query_as!( + WorkspaceInfo, + "SELECT id, name FROM workspace WHERE deleted = false ORDER BY name", + ) + .fetch_all(&self.db) + .await + } else { + sqlx::query_as!( + WorkspaceInfo, + "SELECT workspace.id, workspace.name + FROM workspace + JOIN usr ON usr.workspace_id = workspace.id + WHERE usr.email = $1 AND usr.disabled = false AND workspace.deleted = false + ORDER BY workspace.name", + auth.email, + ) + .fetch_all(&self.db) + .await + }; + + workspaces.map_err(|e| ErrorData::internal_error(e.to_string(), None)) + } + + async fn resolve_workspace_auth( + &self, + token: &str, + workspace_id: &str, + ) -> BackendResult { + self.auth_cache + .get_authed(Some(workspace_id.to_string()), token) + .await + .ok_or_else(|| { + ErrorData::invalid_params( + format!( + "Access denied: token owner is not a member of workspace '{}'", + workspace_id + ), + None, + ) + }) + } + fn all_endpoint_tools(&self) -> Vec { all_tools() } @@ -401,37 +462,67 @@ pub async fn add_www_authenticate_header( } } -/// Middleware for gateway: extract workspace_id from the Bearer token in the DB -/// and inject it as WorkspaceId extension so the MCP runner can use it. +/// 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) -> Option { + if let Some(token) = request + .headers() + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|h| h.strip_prefix("Bearer ")) + { + return Some(token.to_string()); + } + request.uri().query().and_then(|q| { + url::form_urlencoded::parse(q.as_bytes()) + .find(|(k, _)| k == "token") + .map(|(_, v)| v.into_owned()) + }) +} + +/// Middleware for gateway: resolve the MCP session mode from the Bearer token in +/// the DB. A token bound to a workspace injects `WorkspaceId` (single-workspace +/// mode). A workspace-less MCP token (`workspace_id IS NULL` with an `mcp:` scope) +/// injects `MultiWorkspaceMcp` + `McpToken`, putting the runner in +/// multi-workspace mode where tools take an explicit `workspace_id` argument. pub async fn extract_workspace_from_token( Extension(db): Extension, mut request: Request, next: Next, ) -> Response { - if let Some(auth_header) = request - .headers() - .get(axum::http::header::AUTHORIZATION) - .and_then(|v| v.to_str().ok()) - { - if let Some(token) = auth_header.strip_prefix("Bearer ") { - let t_hash = hash_token(token); - match sqlx::query_scalar!( - "SELECT workspace_id FROM token WHERE token_hash = $1 AND workspace_id IS NOT NULL AND (expiration > NOW() OR expiration IS NULL)", - t_hash - ) - .fetch_optional(&db) - .await - { - Ok(Some(Some(workspace_id))) => { + if let Some(token) = extract_gateway_token(&request) { + let t_hash = hash_token(&token); + match sqlx::query!( + "SELECT workspace_id, scopes FROM token WHERE token_hash = $1 AND (expiration > NOW() OR expiration IS NULL)", + t_hash + ) + .fetch_optional(&db) + .await + { + Ok(Some(row)) => match row.workspace_id { + Some(workspace_id) => { request .extensions_mut() .insert(GatewayWorkspaceId(workspace_id.clone())); request.extensions_mut().insert(WorkspaceId(workspace_id)); } - Ok(_) => {} - Err(e) => { - tracing::error!("Gateway token workspace lookup failed: {}", e); + None => { + // Only enter multi-workspace mode for genuine MCP tokens; a + // full-privilege global token without mcp scope is rejected + // by the runner's mcp-scope check anyway. + let is_mcp = row + .scopes + .as_deref() + .is_some_and(|s| s.iter().any(|scope| scope.starts_with("mcp:"))); + if is_mcp { + request.extensions_mut().insert(MultiWorkspaceMcp); + request.extensions_mut().insert(McpToken(token)); + } } + }, + Ok(None) => {} + Err(e) => { + tracing::error!("Gateway token workspace lookup failed: {}", e); } } } @@ -472,11 +563,12 @@ pub async fn setup_mcp_server( db: DB, user_db: UserDB, base_internal_url: String, + auth_cache: Arc, ) -> anyhow::Result<(Router, CancellationToken)> { let cancellation_token = CancellationToken::new(); let session_manager = Arc::new(LocalSessionManager::default()); - let backend = WindmillBackend::new(db, user_db, base_internal_url); + let backend = WindmillBackend::new(db, user_db, base_internal_url, auth_cache); let runner = Runner::new(backend); let service_config = StreamableHttpServerConfig { diff --git a/backend/windmill-api/src/mcp/utils.rs b/backend/windmill-api/src/mcp/utils.rs index 2184c1548f..ba4ea391d5 100644 --- a/backend/windmill-api/src/mcp/utils.rs +++ b/backend/windmill-api/src/mcp/utils.rs @@ -412,12 +412,50 @@ pub fn build_request_body( args_map: &serde_json::Map, body_schema: &Option, body_field_renames: &Option, + path_params_schema: &Option, + query_params_schema: &Option, ) -> Option { if method == "GET" { return None; } let schema = body_schema.as_ref()?; + + let has_declared_props = schema + .get("properties") + .and_then(|p| p.as_object()) + .map(|o| !o.is_empty()) + .unwrap_or(false); + + // Pass-through body: the schema declares no explicit properties (e.g. + // runScriptByPath / runFlowByPath, whose body is `additionalProperties: true` + // and carries the script/flow arguments verbatim). Forward every argument + // that isn't already consumed by a path or query parameter — without this the + // request body would be empty and parameterized runs would lose their args. + if !has_declared_props { + if schema.get("type").and_then(|t| t.as_str()) != Some("object") { + return None; + } + let consumed: std::collections::HashSet<&str> = [path_params_schema, query_params_schema] + .into_iter() + .filter_map(|s| s.as_ref()) + .filter_map(|s| s.get("properties").and_then(|p| p.as_object())) + .flat_map(|props| props.keys().map(|k| k.as_str())) + .collect(); + + let body_map: serde_json::Map = args_map + .iter() + .filter(|(k, v)| !consumed.contains(k.as_str()) && !v.is_null()) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + + return if body_map.is_empty() { + None + } else { + Some(Value::Object(body_map)) + }; + } + let props = schema.get("properties")?.as_object()?; let body_map: serde_json::Map = props @@ -540,6 +578,65 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn build_request_body_passthrough_forwards_script_args_minus_path() { + // runScriptByPath-shaped body: additionalProperties, no declared props. + // `path` is a path param and must be excluded; the rest are the script's + // arguments and must be forwarded verbatim. + let body_schema = Some(json!({ "type": "object", "additionalProperties": true })); + let path_schema = Some(json!({ + "type": "object", + "properties": { "path": { "type": "string" } }, + "required": ["path"] + })); + let args: serde_json::Map = json!({ + "path": "u/admin/my_script", + "name": "alice", + "count": 3 + }) + .as_object() + .unwrap() + .clone(); + + let body = build_request_body("POST", &args, &body_schema, &None, &path_schema, &None) + .expect("passthrough body should be built"); + let obj = body.as_object().unwrap(); + assert_eq!(obj.get("name"), Some(&json!("alice"))); + assert_eq!(obj.get("count"), Some(&json!(3))); + assert!( + !obj.contains_key("path"), + "path param must be excluded from body" + ); + } + + #[test] + fn build_request_body_declared_props_only_forwards_declared() { + // Endpoints with explicit properties keep the strict declared-only behavior. + let body_schema = Some(json!({ + "type": "object", + "properties": { "value": { "type": "string" } }, + "required": ["value"] + })); + let args: serde_json::Map = json!({ "value": "x", "sneaky": "y" }) + .as_object() + .unwrap() + .clone(); + let body = build_request_body("POST", &args, &body_schema, &None, &None, &None).unwrap(); + let obj = body.as_object().unwrap(); + assert_eq!(obj.get("value"), Some(&json!("x"))); + assert!( + !obj.contains_key("sneaky"), + "undeclared args must be dropped" + ); + } + + #[test] + fn build_request_body_get_has_no_body() { + let body_schema = Some(json!({ "type": "object", "additionalProperties": true })); + let args: serde_json::Map = json!({ "a": 1 }).as_object().unwrap().clone(); + assert!(build_request_body("GET", &args, &body_schema, &None, &None, &None).is_none()); + } + #[test] fn validate_path_param_value_accepts_legitimate_windmill_paths() { for ok in [ diff --git a/backend/windmill-mcp/src/common/scope.rs b/backend/windmill-mcp/src/common/scope.rs index f43095d740..418458d4fe 100644 --- a/backend/windmill-mcp/src/common/scope.rs +++ b/backend/windmill-mcp/src/common/scope.rs @@ -23,6 +23,26 @@ pub struct McpScopeConfig { } impl McpScopeConfig { + /// Whether the token grants access to *any* concrete resource of this type by + /// path. Used to decide whether to advertise the run-by-path tools in + /// multi-workspace mode (a `mcp:scripts:*`-only token should see + /// `runScriptByPath` even without an endpoint scope). `mcp:all` grants + /// everything; `mcp:favorites` does NOT — favorites are an enumerated set the + /// caller can only reach through the per-item tools, not by naming an + /// arbitrary path, so it grants nothing here (mirrors `is_allowed`, which + /// returns false for a favorites token). + pub fn has_any(&self, resource_type: &str) -> bool { + if self.all { + return true; + } + match resource_type { + "script" => !self.scripts.is_empty(), + "flow" => !self.flows.is_empty(), + "endpoint" => !self.endpoints.is_empty(), + _ => false, + } + } + /// Check if a resource is allowed based on its type and path pub fn is_allowed(&self, resource_type: &str, path: &str) -> bool { if self.all { @@ -324,6 +344,29 @@ mod tests { parse_mcp_scopes(&scopes.iter().map(|s| s.to_string()).collect::>()).unwrap() } + #[test] + fn test_has_any() { + // mcp:all grants everything by path. + assert!(cfg(&["mcp:all"]).has_any("script")); + + // mcp:favorites grants NO arbitrary-path access (favorites are reached + // via per-item tools, not by naming a path) — matches is_allowed. + let fav = cfg(&["mcp:favorites"]); + assert!(!fav.has_any("script")); + assert!(!fav.has_any("flow")); + assert!(!fav.is_allowed("script", "f/anything/x")); + + // Granular: only the resource types with at least one pattern. + let scripts_only = cfg(&["mcp:scripts:f/team/*"]); + assert!(scripts_only.has_any("script")); + assert!(!scripts_only.has_any("flow")); + assert!(!scripts_only.has_any("endpoint")); + + let endpoints_only = cfg(&["mcp:endpoints:runScriptByPath"]); + assert!(!endpoints_only.has_any("script")); + assert!(endpoints_only.has_any("endpoint")); + } + #[test] fn test_contains_subset_and_widening() { // mcp:all contains anything. diff --git a/backend/windmill-mcp/src/common/types.rs b/backend/windmill-mcp/src/common/types.rs index 6161ca7963..d6bd88a397 100644 --- a/backend/windmill-mcp/src/common/types.rs +++ b/backend/windmill-mcp/src/common/types.rs @@ -15,6 +15,27 @@ use sqlx::FromRow; #[derive(Clone, Debug)] pub struct WorkspaceId(pub String); +/// Marker extension inserted by the gateway middleware when an MCP token has no +/// bound workspace (`workspace_id IS NULL`). Signals the runner to operate in +/// multi-workspace mode: tools take an explicit `workspace_id` argument and the +/// per-workspace auth is resolved on demand from the raw token. +#[derive(Clone, Debug)] +pub struct MultiWorkspaceMcp; + +/// Raw bearer token wrapper for Axum extensions. In multi-workspace mode the +/// runner needs the raw token to re-resolve auth for each requested workspace. +#[derive(Clone, Debug)] +pub struct McpToken(pub String); + +/// Summary of a workspace the caller can access, returned by the +/// `list_workspaces` tool in multi-workspace mode. +#[derive(Serialize, Debug, Clone)] +#[cfg_attr(feature = "server", derive(FromRow))] +pub struct WorkspaceInfo { + pub id: String, + pub name: String, +} + /// Hub API response structure #[derive(Serialize, Deserialize, Debug)] pub struct HubResponse { diff --git a/backend/windmill-mcp/src/server/backend.rs b/backend/windmill-mcp/src/server/backend.rs index 7a6e007c4d..96a2bed8df 100644 --- a/backend/windmill-mcp/src/server/backend.rs +++ b/backend/windmill-mcp/src/server/backend.rs @@ -9,7 +9,7 @@ use serde_json::Value; use std::collections::HashMap; use crate::common::types::{ - FlowInfo, HubScriptInfo, ResourceInfo, ResourceType, SchemaType, ScriptInfo, + FlowInfo, HubScriptInfo, ResourceInfo, ResourceType, SchemaType, ScriptInfo, WorkspaceInfo, }; use crate::server::endpoints::EndpointTool; @@ -159,6 +159,27 @@ pub trait McpBackend: Send + Sync + Clone + 'static { args: Value, ) -> BackendResult; + // ───────────────────────────────────────────────────────────────── + // Multi-workspace support + // ───────────────────────────────────────────────────────────────── + + /// List the workspaces the caller (identified by `auth`) can access. Used by + /// the `list_workspaces` tool exposed in multi-workspace mode. + async fn list_accessible_workspaces( + &self, + auth: &Self::Auth, + ) -> BackendResult>; + + /// Resolve a workspace-specific auth for `workspace_id` from the raw bearer + /// `token`. Returns an error if the token's owner is not a member of the + /// workspace. Used in multi-workspace mode to authorize per-workspace tool + /// calls (the base auth carries no workspace-specific permissions). + async fn resolve_workspace_auth( + &self, + token: &str, + workspace_id: &str, + ) -> BackendResult; + // ───────────────────────────────────────────────────────────────── // Endpoint Tools // ───────────────────────────────────────────────────────────────── diff --git a/backend/windmill-mcp/src/server/endpoints.rs b/backend/windmill-mcp/src/server/endpoints.rs index 373db36eb1..c3a50f9715 100644 --- a/backend/windmill-mcp/src/server/endpoints.rs +++ b/backend/windmill-mcp/src/server/endpoints.rs @@ -73,6 +73,86 @@ pub fn endpoint_tool_to_mcp_tool(tool: &EndpointTool) -> Tool { } } +/// Convert an endpoint tool to an MCP tool for multi-workspace mode. +/// +/// Endpoints whose path is workspace-scoped (`/w/{workspace}/...`) gain a +/// required `workspace_id` argument — in multi-workspace mode there is no +/// ambient workspace, so the caller must name the target workspace explicitly. +/// Global endpoints (e.g. docs search) are returned unchanged. +pub fn endpoint_tool_to_mcp_tool_multi(tool: &EndpointTool) -> Tool { + let mut mcp_tool = endpoint_tool_to_mcp_tool(tool); + + if !tool.path.contains("{workspace}") { + return mcp_tool; + } + + let mut schema = (*mcp_tool.input_schema).clone(); + + if let Some(props) = schema.get_mut("properties").and_then(|p| p.as_object_mut()) { + props.insert( + "workspace_id".to_string(), + serde_json::json!({ + "type": "string", + "description": "Target workspace id (from list_workspaces)." + }), + ); + } + + match schema.get_mut("required").and_then(|r| r.as_array_mut()) { + Some(req) => { + if !req.iter().any(|v| v.as_str() == Some("workspace_id")) { + req.insert(0, serde_json::Value::String("workspace_id".to_string())); + } + } + None => { + schema.insert("required".to_string(), serde_json::json!(["workspace_id"])); + } + } + + // Surface the requirement in the prose description too (the schema is + // authoritative, but some models/clients lean on the text). Kept terse — this + // repeats across every workspace-scoped tool in the list. + if let Some(desc) = mcp_tool.description.take() { + mcp_tool.description = Some(format!("{desc} Requires `workspace_id`.").into()); + } else { + mcp_tool.description = Some("Requires `workspace_id`.".into()); + } + + mcp_tool.input_schema = Arc::new(schema); + mcp_tool +} + +/// Build the synthetic `list_workspaces` tool exposed only in multi-workspace +/// mode. It takes no arguments and returns the workspaces the token can access. +pub fn list_workspaces_tool() -> Tool { + let schema = serde_json::json!({ + "type": "object", + "properties": {}, + "required": [] + }); + + Tool { + name: Cow::Borrowed("list_workspaces"), + description: Some( + "List the Windmill workspaces this token can access. Use the returned workspace ids as the `workspace_id` argument of the other tools." + .into(), + ), + input_schema: Arc::new(schema.as_object().unwrap().clone()), + title: Some("List accessible workspaces".to_string()), + output_schema: None, + icons: None, + annotations: Some(ToolAnnotations { + title: Some("List accessible workspaces".to_string()), + read_only_hint: Some(true), + destructive_hint: Some(false), + idempotent_hint: Some(true), + open_world_hint: Some(false), + }), + meta: None, + execution: None, + } +} + /// Create appropriate annotations for endpoint tools based on HTTP method fn create_endpoint_annotations(tool: &EndpointTool) -> ToolAnnotations { let method = tool.method.as_ref(); @@ -116,3 +196,119 @@ fn merge_schema_into( } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn tool(name: &'static str, path: &'static str) -> EndpointTool { + EndpointTool { + name: Cow::Borrowed(name), + description: Cow::Borrowed("desc"), + instructions: Cow::Borrowed(""), + path: Cow::Borrowed(path), + method: Cow::Borrowed("GET"), + path_params_schema: None, + query_params_schema: Some(serde_json::json!({ + "type": "object", + "properties": { "starred_only": { "type": "boolean" } }, + "required": [] + })), + body_schema: None, + path_field_renames: None, + query_field_renames: None, + body_field_renames: None, + } + } + + #[test] + fn multi_injects_required_workspace_id_for_workspaced_tool() { + let mcp = + endpoint_tool_to_mcp_tool_multi(&tool("listScripts", "/w/{workspace}/scripts/list")); + let props = mcp + .input_schema + .get("properties") + .unwrap() + .as_object() + .unwrap(); + assert!( + props.contains_key("workspace_id"), + "workspace_id must be added as a property" + ); + // pre-existing param is preserved + assert!(props.contains_key("starred_only")); + let required = mcp + .input_schema + .get("required") + .unwrap() + .as_array() + .unwrap(); + assert!( + required.iter().any(|v| v.as_str() == Some("workspace_id")), + "workspace_id must be required" + ); + assert!( + mcp.description + .as_deref() + .unwrap_or_default() + .contains("workspace_id"), + "description must mention the workspace_id requirement" + ); + } + + #[test] + fn multi_leaves_global_tool_unchanged() { + let global = tool("searchDocs", "/docs/search"); + let plain = endpoint_tool_to_mcp_tool(&global); + let mcp = endpoint_tool_to_mcp_tool_multi(&global); + assert_eq!( + mcp.description, plain.description, + "global tool description must be unchanged" + ); + let props = mcp + .input_schema + .get("properties") + .unwrap() + .as_object() + .unwrap(); + assert!( + !props.contains_key("workspace_id"), + "global tools (no {{workspace}} in path) must not gain a workspace_id arg" + ); + let required = mcp + .input_schema + .get("required") + .unwrap() + .as_array() + .unwrap(); + assert!(required.iter().all(|v| v.as_str() != Some("workspace_id"))); + } + + #[test] + fn multi_does_not_duplicate_workspace_id() { + // Even if run twice, workspace_id stays a single required entry. + let once = endpoint_tool_to_mcp_tool_multi(&tool("listFlows", "/w/{workspace}/flows/list")); + let required = once + .input_schema + .get("required") + .unwrap() + .as_array() + .unwrap(); + let count = required + .iter() + .filter(|v| v.as_str() == Some("workspace_id")) + .count(); + assert_eq!( + count, 1, + "workspace_id must appear exactly once in required" + ); + } + + #[test] + fn list_workspaces_tool_has_no_params() { + let t = list_workspaces_tool(); + assert_eq!(t.name.as_ref(), "list_workspaces"); + let required = t.input_schema.get("required").unwrap().as_array().unwrap(); + assert!(required.is_empty()); + } +} diff --git a/backend/windmill-mcp/src/server/mod.rs b/backend/windmill-mcp/src/server/mod.rs index 688c12b827..d5d49bc746 100644 --- a/backend/windmill-mcp/src/server/mod.rs +++ b/backend/windmill-mcp/src/server/mod.rs @@ -11,8 +11,12 @@ pub mod runner; pub mod tools; // Re-export main types +pub use crate::common::types::{McpToken, MultiWorkspaceMcp, WorkspaceInfo}; pub use backend::{BackendResult, McpAuth, McpBackend}; -pub use endpoints::{endpoint_tool_to_mcp_tool, is_endpoint_read_only, EndpointTool}; +pub use endpoints::{ + endpoint_tool_to_mcp_tool, endpoint_tool_to_mcp_tool_multi, is_endpoint_read_only, + list_workspaces_tool, EndpointTool, +}; pub use runner::Runner; pub use tools::create_tool_from_item; diff --git a/backend/windmill-mcp/src/server/runner.rs b/backend/windmill-mcp/src/server/runner.rs index 6d33573d95..dd810269d9 100644 --- a/backend/windmill-mcp/src/server/runner.rs +++ b/backend/windmill-mcp/src/server/runner.rs @@ -9,9 +9,11 @@ use crate::common::transform::{ extract_hub_version_id_from_hashed, extract_path_prefix_from_hashed, parse_tool_prefix, reverse_transform, reverse_transform_key, }; -use crate::common::types::{ResourceInfo, ToolableItem, WorkspaceId}; +use crate::common::types::{McpToken, MultiWorkspaceMcp, ResourceInfo, ToolableItem, WorkspaceId}; use crate::server::backend::{McpAuth, McpBackend}; -use crate::server::endpoints::endpoint_tool_to_mcp_tool; +use crate::server::endpoints::{ + endpoint_tool_to_mcp_tool, endpoint_tool_to_mcp_tool_multi, list_workspaces_tool, +}; use crate::server::tools::create_tool_from_item; use rmcp::handler::server::ServerHandler; use rmcp::model::{ @@ -61,16 +63,28 @@ impl Clone for Runner { } } +/// Whether the request targets one bound workspace or spans every workspace the +/// token can access. +enum McpMode { + /// A single workspace, resolved from the URL path or the token's bound + /// workspace. Tools operate against this workspace implicitly. + Single(String), + /// The token has no bound workspace (`workspace_id IS NULL`). Tools take an + /// explicit `workspace_id` argument; the wrapped value is the raw bearer + /// token, used to re-resolve auth per requested workspace. + Multi(String), +} + impl Runner { /// Create a new Runner with the given backend pub fn new(backend: B) -> Self { Self { backend: Arc::new(backend) } } - /// Extract authentication and workspace from request context + /// Extract authentication and the workspace mode from request context fn extract_context( context: &RequestContext, - ) -> Result<(B::Auth, String), ErrorData> { + ) -> Result<(B::Auth, McpMode), ErrorData> { let http_parts = context.extensions.get::().ok_or_else(|| { tracing::error!("http::request::Parts not found"); ErrorData::internal_error("http::request::Parts not found", None) @@ -81,15 +95,6 @@ impl Runner { ErrorData::internal_error("Auth extension not found", None) })?; - let workspace_id = http_parts - .extensions - .get::() - .ok_or_else(|| { - tracing::error!("WorkspaceId not found"); - ErrorData::internal_error("WorkspaceId not found", None) - }) - .map(|w_id| w_id.0.clone())?; - // Validate MCP scope if !auth.has_mcp_scope() { tracing::error!("Unauthorized: missing mcp scope"); @@ -99,7 +104,39 @@ impl Runner { )); } - Ok((auth.clone(), workspace_id)) + let mode = if http_parts.extensions.get::().is_some() { + let token = http_parts.extensions.get::().ok_or_else(|| { + tracing::error!("MultiWorkspaceMcp set but McpToken missing"); + ErrorData::internal_error("MCP token not found for multi-workspace session", None) + })?; + McpMode::Multi(token.0.clone()) + } else { + let workspace_id = http_parts + .extensions + .get::() + .ok_or_else(|| { + tracing::error!("WorkspaceId not found"); + ErrorData::internal_error("WorkspaceId not found", None) + }) + .map(|w_id| w_id.0.clone())?; + McpMode::Single(workspace_id) + }; + + Ok((auth.clone(), mode)) + } +} + +/// The run-by-path endpoint tools execute an arbitrary script/flow named by a +/// `path` argument. In multi-workspace mode they are the only way to run +/// scripts/flows, so their authorization must honor the `mcp:scripts:` / +/// `mcp:flows:` path scopes (not the generic endpoint scope) — otherwise a +/// granular token could run items outside its allowed paths. Returns the scope +/// resource type ("script"/"flow") for these endpoints, `None` otherwise. +fn run_by_path_scope_kind(endpoint_name: &str) -> Option<&'static str> { + match endpoint_name { + "runScriptByPath" => Some("script"), + "runFlowByPath" => Some("flow"), + _ => None, } } @@ -137,16 +174,99 @@ impl ServerHandler for Runner { _request: Option, context: RequestContext, ) -> Result { - let (auth, workspace_id) = Self::extract_context(&context)?; + let (auth, mode) = Self::extract_context(&context)?; // Parse MCP scopes to determine what to expose let scopes = auth.scopes().unwrap_or(&[]); let scope_config = parse_mcp_scopes(scopes).map_err(|e| ErrorData::internal_error(e, None))?; - let favorites_only = scope_config.favorites; let read_only = auth.read_only(); + match mode { + McpMode::Single(workspace_id) => { + self.list_tools_single(&auth, &workspace_id, &scope_config, read_only) + .await + } + // Multi-workspace: expose the generic endpoint tools (each taking an + // explicit workspace_id) plus list_workspaces. Per-workspace scripts + // and flows are intentionally not enumerated here — doing so across + // every workspace would overload the tool list; callers run them via + // runScriptByPath / runFlowByPath with a workspace_id instead. + McpMode::Multi(_) => Ok(self.list_tools_multi(&scope_config, read_only)), + } + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + let (auth, mode) = Self::extract_context(&context)?; + + // Parse MCP scopes for authorization + let scopes = auth.scopes().unwrap_or(&[]); + let scope_config = + parse_mcp_scopes(scopes).map_err(|e| ErrorData::internal_error(e, None))?; + let read_only = auth.read_only(); + + let args = request.arguments.map(Value::Object).unwrap_or(Value::Null); + + match mode { + McpMode::Single(workspace_id) => { + self.call_tool_single( + &auth, + &workspace_id, + &scope_config, + read_only, + request.name, + args, + ) + .await + } + McpMode::Multi(token) => { + self.call_tool_multi(&auth, &token, &scope_config, read_only, request.name, args) + .await + } + } + } + + async fn list_resources( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(ListResourcesResult { resources: vec![], next_cursor: None, meta: None }) + } + + async fn list_prompts( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(ListPromptsResult::default()) + } + + async fn list_resource_templates( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(ListResourceTemplatesResult::default()) + } +} + +impl Runner { + /// List tools for a single, bound workspace (URL-path or token-bound). + async fn list_tools_single( + &self, + auth: &B::Auth, + workspace_id: &str, + scope_config: &crate::common::scope::McpScopeConfig, + read_only: bool, + ) -> Result { + let favorites_only = scope_config.favorites; + let mut tools = Vec::new(); // Read-only tokens cannot run scripts/flows/hub-scripts (running is a @@ -155,10 +275,10 @@ impl ServerHandler for Runner { if !read_only { let (scripts, flows, resource_types, hub_scripts) = tokio::try_join!( self.backend - .list_scripts(&auth, &workspace_id, favorites_only, None), + .list_scripts(auth, workspace_id, favorites_only, None), self.backend - .list_flows(&auth, &workspace_id, favorites_only, None), - self.backend.list_resource_types(&auth, &workspace_id), + .list_flows(auth, workspace_id, favorites_only, None), + self.backend.list_resource_types(auth, workspace_id), async { if let Some(ref apps) = scope_config.hub_apps { self.backend.list_hub_scripts(Some(apps)).await @@ -199,7 +319,7 @@ impl ServerHandler for Runner { .map(|rt| { let backend = self.backend.clone(); let auth = auth.clone(); - let workspace_id = workspace_id.clone(); + let workspace_id = workspace_id.to_string(); async move { backend .list_resources(&auth, &workspace_id, &rt) @@ -257,25 +377,20 @@ impl ServerHandler for Runner { Ok(ListToolsResult { tools, next_cursor: None, meta: None }) } - async fn call_tool( + /// Handle a tool call for a single, bound workspace. + async fn call_tool_single( &self, - request: CallToolRequestParams, - context: RequestContext, + auth: &B::Auth, + workspace_id: &str, + scope_config: &crate::common::scope::McpScopeConfig, + read_only: bool, + name: std::borrow::Cow<'static, str>, + args: Value, ) -> Result { - let (auth, workspace_id) = Self::extract_context(&context)?; - - // Parse MCP scopes for authorization - let scopes = auth.scopes().unwrap_or(&[]); - let scope_config = - parse_mcp_scopes(scopes).map_err(|e| ErrorData::internal_error(e, None))?; - let read_only = auth.read_only(); - - let args = request.arguments.map(Value::Object).unwrap_or(Value::Null); - // Check if this is an endpoint tool let endpoint_tools = self.backend.all_endpoint_tools(); for endpoint_tool in &endpoint_tools { - if endpoint_tool.name.as_ref() == request.name { + if endpoint_tool.name.as_ref() == name.as_ref() { // Validate endpoint scope if scope_config.granular && !scope_config.is_allowed("endpoint", &endpoint_tool.name) @@ -301,7 +416,7 @@ impl ServerHandler for Runner { // This is an endpoint tool, call via backend let result = self .backend - .call_endpoint(&auth, &workspace_id, endpoint_tool, args) + .call_endpoint(auth, workspace_id, endpoint_tool, args) .await .map_err(|e| ErrorData::internal_error(e.message, None))?; @@ -319,53 +434,50 @@ impl ServerHandler for Runner { return Err(ErrorData::internal_error( format!( "Access denied: tool '{}' runs a script/flow and this token is restricted to read-only operations", - request.name + name ), None, )); } // Resolve the tool name to (type, path, is_hub) - let (type_str, is_hub, is_hashed) = parse_tool_prefix(&request.name).map_err(|e| { + let (type_str, is_hub, is_hashed) = parse_tool_prefix(name.as_ref()).map_err(|e| { ErrorData::internal_error(format!("Failed to parse tool name: {}", e), None) })?; let (tool_type, path, is_hub) = if !is_hashed { - reverse_transform(&request.name).map_err(|e| { + reverse_transform(name.as_ref()).map_err(|e| { ErrorData::internal_error(format!("Failed to parse tool name: {}", e), None) })? } else if is_hub { - let version_id = extract_hub_version_id_from_hashed(&request.name).map_err(|e| { + let version_id = extract_hub_version_id_from_hashed(name.as_ref()).map_err(|e| { ErrorData::internal_error(format!("Failed to extract hub version_id: {}", e), None) })?; (type_str, version_id, true) } else { - let path_prefix = extract_path_prefix_from_hashed(&request.name); + let path_prefix = extract_path_prefix_from_hashed(name.as_ref()); let favorites_only = scope_config.favorites; let matched_path = if type_str == "script" { find_matching_path( self.backend - .list_scripts(&auth, &workspace_id, favorites_only, path_prefix.as_deref()) + .list_scripts(auth, workspace_id, favorites_only, path_prefix.as_deref()) .await .map_err(|e| ErrorData::internal_error(e.message, None))?, - &request.name, + name.as_ref(), ) } else { find_matching_path( self.backend - .list_flows(&auth, &workspace_id, favorites_only, path_prefix.as_deref()) + .list_flows(auth, workspace_id, favorites_only, path_prefix.as_deref()) .await .map_err(|e| ErrorData::internal_error(e.message, None))?, - &request.name, + name.as_ref(), ) }; let matched_path = matched_path.ok_or_else(|| { ErrorData::internal_error( - format!( - "No {} found matching hashed tool name '{}'", - type_str, request.name - ), + format!("No {} found matching hashed tool name '{}'", type_str, name), None, ) })?; @@ -396,7 +508,7 @@ impl ServerHandler for Runner { .map_err(|e| ErrorData::internal_error(e.message, None))? } else { self.backend - .get_item_schema(&auth, &workspace_id, &path, tool_type) + .get_item_schema(auth, workspace_id, &path, tool_type) .await .map_err(|e| ErrorData::internal_error(e.message, None))? }; @@ -422,11 +534,11 @@ impl ServerHandler for Runner { // Execute script or flow let result = if tool_type == "script" { self.backend - .run_script(&auth, &workspace_id, &script_or_flow_path, transformed_args) + .run_script(auth, workspace_id, &script_or_flow_path, transformed_args) .await } else { self.backend - .run_flow(&auth, &workspace_id, &script_or_flow_path, transformed_args) + .run_flow(auth, workspace_id, &script_or_flow_path, transformed_args) .await }; @@ -443,27 +555,181 @@ impl ServerHandler for Runner { } } - async fn list_resources( + /// List tools for a multi-workspace session: the synthetic `list_workspaces` + /// tool plus every generic endpoint tool, each taking an explicit + /// `workspace_id` argument. + fn list_tools_multi( &self, - _request: Option, - _context: RequestContext, - ) -> Result { - Ok(ListResourcesResult { resources: vec![], next_cursor: None, meta: None }) + scope_config: &crate::common::scope::McpScopeConfig, + read_only: bool, + ) -> ListToolsResult { + let mut tools = vec![list_workspaces_tool()]; + + let endpoint_tools = self.backend.all_endpoint_tools(); + for endpoint_tool in endpoint_tools { + // Run-by-path tools are gated by script/flow scope (they run an + // arbitrary path); every other endpoint by the endpoint scope. + let allowed = match run_by_path_scope_kind(&endpoint_tool.name) { + Some(kind) => scope_config.has_any(kind), + None => { + !scope_config.granular + || scope_config.is_allowed("endpoint", &endpoint_tool.name) + } + }; + if !allowed { + continue; + } + if read_only && !crate::server::is_endpoint_read_only(&endpoint_tool) { + continue; + } + + tools.push(endpoint_tool_to_mcp_tool_multi(&endpoint_tool)); + } + + ListToolsResult { tools, next_cursor: None, meta: None } } - async fn list_prompts( + /// Handle a tool call for a multi-workspace session. `base_auth` is the + /// workspace-less identity derived from the token; per-workspace auth is + /// resolved on demand from `token` for the workspace named in the args. + async fn call_tool_multi( &self, - _request: Option, - _context: RequestContext, - ) -> Result { - Ok(ListPromptsResult::default()) - } + base_auth: &B::Auth, + token: &str, + scope_config: &crate::common::scope::McpScopeConfig, + read_only: bool, + name: std::borrow::Cow<'static, str>, + args: Value, + ) -> Result { + if name.as_ref() == "list_workspaces" { + let workspaces = self + .backend + .list_accessible_workspaces(base_auth) + .await + .map_err(|e| ErrorData::internal_error(e.message, None))?; + return Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&workspaces).unwrap_or_else(|_| "[]".to_string()), + )])); + } - async fn list_resource_templates( - &self, - _request: Option, - _context: RequestContext, - ) -> Result { - Ok(ListResourceTemplatesResult::default()) + // Only endpoint tools are exposed in multi-workspace mode; scripts and + // flows are run through the runScriptByPath / runFlowByPath endpoints. + let endpoint_tools = self.backend.all_endpoint_tools(); + let endpoint_tool = endpoint_tools + .iter() + .find(|t| t.name.as_ref() == name.as_ref()) + .ok_or_else(|| { + ErrorData::invalid_params( + format!( + "Unknown tool '{}' in multi-workspace mode. Available tools are list_workspaces and the generic API endpoint tools (run scripts/flows via runScriptByPath / runFlowByPath).", + name + ), + None, + ) + })?; + + // Authorize the tool. Run-by-path endpoints (runScriptByPath / + // runFlowByPath) run an arbitrary `path` and must be checked against the + // script/flow scope for that path — the endpoint scope alone would let a + // granular token run items outside its allowed paths. + match run_by_path_scope_kind(&endpoint_tool.name) { + Some(kind) => { + let path = args + .get("path") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + ErrorData::invalid_params( + format!( + "Missing required 'path' argument for tool '{}'.", + endpoint_tool.name + ), + None, + ) + })?; + // No `granular` gate: is_allowed already encodes every mode — + // true for mcp:all, pattern-matched for granular scopes, and + // false for mcp:favorites (a favorites token can't run an + // arbitrary path, only its enumerated favorites). + if !scope_config.is_allowed(kind, path) { + return Err(ErrorData::internal_error( + format!("Access denied: {} '{}' not in token scope", kind, path), + None, + )); + } + } + None => { + if scope_config.granular + && !scope_config.is_allowed("endpoint", &endpoint_tool.name) + { + return Err(ErrorData::internal_error( + format!( + "Access denied: endpoint '{}' not in token scope", + endpoint_tool.name + ), + None, + )); + } + } + } + if read_only && !crate::server::is_endpoint_read_only(endpoint_tool) { + return Err(ErrorData::internal_error( + format!( + "Access denied: endpoint '{}' is not read-only and this token is restricted to read-only operations", + endpoint_tool.name + ), + None, + )); + } + + // Workspace-scoped endpoints need an explicit target workspace and a + // per-workspace auth; global endpoints (e.g. docs) use the base identity. + let needs_workspace = endpoint_tool.path.contains("{workspace}"); + let (workspace_id, resolved_auth) = if needs_workspace { + let workspace_id = args + .get("workspace_id") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + ErrorData::invalid_params( + format!( + "Missing required 'workspace_id' argument for tool '{}'. Call list_workspaces to see the workspaces you can access.", + endpoint_tool.name + ), + None, + ) + })? + .to_string(); + + let resolved = self + .backend + .resolve_workspace_auth(token, &workspace_id) + .await + .map_err(|e| ErrorData::internal_error(e.message, None))?; + (workspace_id, resolved) + } else { + (String::new(), base_auth.clone()) + }; + + // `workspace_id` is a synthetic argument only this layer understands; the + // target workspace is passed to call_endpoint separately. Strip it so it + // can't leak into a pass-through request body (e.g. runScriptByPath, whose + // body forwards all remaining args as the script's arguments). + let mut args = args; + if let Value::Object(map) = &mut args { + map.remove("workspace_id"); + } + + let result = self + .backend + .call_endpoint(&resolved_auth, &workspace_id, endpoint_tool, args) + .await + .map_err(|e| ErrorData::internal_error(e.message, None))?; + + Ok(CallToolResult::success(vec![Content::text( + truncate_tool_result( + serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string()), + ), + )])) } } diff --git a/frontend/src/lib/components/settings/CreateToken.svelte b/frontend/src/lib/components/settings/CreateToken.svelte index 80c6cb34ef..14920e96e9 100644 --- a/frontend/src/lib/components/settings/CreateToken.svelte +++ b/frontend/src/lib/components/settings/CreateToken.svelte @@ -37,6 +37,11 @@ displayCreateToken = true }: Props = $props() + // Sentinel workspace value meaning "all workspaces the user can access". + // Produces a workspace-less MCP token served through the /api/mcp/gateway + // endpoint, where tools take an explicit workspace_id argument. + const ALL_WORKSPACES = '*' + let newToken = $state(undefined) let newMcpToken = $state(undefined) let newTokenExpiration = $state(undefined) @@ -98,12 +103,18 @@ const tokenScopes = scopes ?? pickedScopes ?? undefined + const workspaceId = isAllWorkspaces + ? undefined + : mcpMode + ? newTokenWorkspace || $workspaceStore + : newTokenWorkspace + const createdToken = await UserService.createToken({ requestBody: { label: newTokenLabel, expiration: date?.toISOString(), scopes: tokenScopes, - workspace_id: mcpMode ? newTokenWorkspace || $workspaceStore : newTokenWorkspace, + workspace_id: workspaceId, read_only: readOnly } as NewToken }) @@ -126,7 +137,18 @@ } const workspaces = $derived(ensureCurrentWorkspaceIncluded($userWorkspaces, $workspaceStore)) - const mcpBaseUrl = $derived(`${window.location.origin}/api/mcp/w/${newTokenWorkspace}/mcp?token=`) + const isAllWorkspaces = $derived(newTokenWorkspace === ALL_WORKSPACES) + // The workspace used to browse scripts/flows/endpoints in the scope picker. + // For an all-workspaces token there is no single workspace, so fall back to + // the current one just for populating the endpoint list. + const scopeWorkspaceId = $derived( + isAllWorkspaces ? $workspaceStore || '' : newTokenWorkspace || $workspaceStore || '' + ) + const mcpBaseUrl = $derived( + isAllWorkspaces + ? `${window.location.origin}/api/mcp/gateway?token=` + : `${window.location.origin}/api/mcp/w/${newTokenWorkspace}/mcp?token=` + ) $effect(() => { const requestedMcpMode = mcpOnly || openWithMcpMode @@ -205,7 +227,7 @@ {#if !scopes || scopes.length === 0} @@ -218,8 +240,21 @@ Workspace