mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
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:
+26
@@ -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"
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user