feat(mcp): add multi-workspace MCP tokens via the gateway endpoint (#10043)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-07-10 23:32:30 +02:00
committed by GitHub
parent 3b0781761b
commit 8343203ec2
13 changed files with 963 additions and 101 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
+7 -2
View File
@@ -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()
+117 -25
View File
@@ -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<AuthCache>,
}
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<AuthCache>,
) -> 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<Vec<WorkspaceInfo>> {
// 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<ApiAuthed> {
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<EndpointTool> {
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<axum::body::Body>) -> Option<String> {
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<DB>,
mut request: Request<axum::body::Body>,
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<AuthCache>,
) -> 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 {
+97
View File
@@ -412,12 +412,50 @@ pub fn build_request_body(
args_map: &serde_json::Map<String, Value>,
body_schema: &Option<Value>,
body_field_renames: &Option<Value>,
path_params_schema: &Option<Value>,
query_params_schema: &Option<Value>,
) -> Option<Value> {
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<String, Value> = 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<String, Value> = 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<String, Value> = 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<String, Value> = 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<String, Value> = 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 [
+43
View File
@@ -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::<Vec<_>>()).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.
+21
View File
@@ -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 {
+22 -1
View File
@@ -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<Value>;
// ─────────────────────────────────────────────────────────────────
// 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<Vec<WorkspaceInfo>>;
/// 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<Self::Auth>;
// ─────────────────────────────────────────────────────────────────
// Endpoint Tools
// ─────────────────────────────────────────────────────────────────
@@ -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());
}
}
+5 -1
View File
@@ -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;
+334 -68
View File
@@ -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<B: McpBackend> Clone for Runner<B> {
}
}
/// 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<B: McpBackend> Runner<B> {
/// 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<RoleServer>,
) -> Result<(B::Auth, String), ErrorData> {
) -> Result<(B::Auth, McpMode), ErrorData> {
let http_parts = context.extensions.get::<HttpParts>().ok_or_else(|| {
tracing::error!("http::request::Parts not found");
ErrorData::internal_error("http::request::Parts not found", None)
@@ -81,15 +95,6 @@ impl<B: McpBackend> Runner<B> {
ErrorData::internal_error("Auth extension not found", None)
})?;
let workspace_id = http_parts
.extensions
.get::<WorkspaceId>()
.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<B: McpBackend> Runner<B> {
));
}
Ok((auth.clone(), workspace_id))
let mode = if http_parts.extensions.get::<MultiWorkspaceMcp>().is_some() {
let token = http_parts.extensions.get::<McpToken>().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::<WorkspaceId>()
.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<B: McpBackend> ServerHandler for Runner<B> {
_request: Option<PaginatedRequestParams>,
context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, ErrorData> {
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<RoleServer>,
) -> Result<CallToolResult, ErrorData> {
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<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListResourcesResult, ErrorData> {
Ok(ListResourcesResult { resources: vec![], next_cursor: None, meta: None })
}
async fn list_prompts(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListPromptsResult, ErrorData> {
Ok(ListPromptsResult::default())
}
async fn list_resource_templates(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListResourceTemplatesResult, ErrorData> {
Ok(ListResourceTemplatesResult::default())
}
}
impl<B: McpBackend> Runner<B> {
/// 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<ListToolsResult, ErrorData> {
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<B: McpBackend> ServerHandler for Runner<B> {
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<B: McpBackend> ServerHandler for Runner<B> {
.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<B: McpBackend> ServerHandler for Runner<B> {
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<RoleServer>,
auth: &B::Auth,
workspace_id: &str,
scope_config: &crate::common::scope::McpScopeConfig,
read_only: bool,
name: std::borrow::Cow<'static, str>,
args: Value,
) -> Result<CallToolResult, ErrorData> {
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<B: McpBackend> ServerHandler for Runner<B> {
// 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<B: McpBackend> ServerHandler for Runner<B> {
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<B: McpBackend> ServerHandler for Runner<B> {
.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<B: McpBackend> ServerHandler for Runner<B> {
// 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<B: McpBackend> ServerHandler for Runner<B> {
}
}
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<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListResourcesResult, ErrorData> {
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<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListPromptsResult, ErrorData> {
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<CallToolResult, ErrorData> {
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<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListResourceTemplatesResult, ErrorData> {
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()),
),
)]))
}
}
@@ -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<string | undefined>(undefined)
let newMcpToken = $state<string | undefined>(undefined)
let newTokenExpiration = $state<number | undefined>(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}
<ScopesPicker
mode={mcpCreationMode ? 'mcp' : 'standard'}
workspaceId={newTokenWorkspace || $workspaceStore || ''}
workspaceId={scopeWorkspaceId}
bind:value={pickedScopes}
bind:readOnly
/>
@@ -218,8 +240,21 @@
<span class="block mb-1 text-emphasis text-xs font-semibold">Workspace</span>
<Select
bind:value={newTokenWorkspace}
items={workspaces.map((w) => ({ label: w.name, value: w.id, subtitle: w.id }))}
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}