From 88d04b9cbeee98f3256b78e9d34beb930cd729ec Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Mon, 17 Nov 2025 23:33:16 +0100 Subject: [PATCH] feat(mcp): granular token scopes for scripts, flows, and endpoints (#7130) * feat(mcp): add granular scope parsing and filtering - Add scope_matcher.rs with McpScopeConfig and parsing logic - Support new scope format: mcp:scripts:{paths}, mcp:flows:{paths}, mcp:endpoints:{names} - Update check_scopes() to accept any mcp:* scope - Implement resource matching with wildcard support (f/folder/*) - Full backward compatibility with legacy scopes (mcp:all, mcp:favorites, mcp:hub:{app}) - Add comprehensive unit tests for scope parsing and matching Co-authored-by: centdix * feat(mcp): add badge-based UI for granular scope selection - Add 'Custom' toggle option to MCP token creation UI - Implement clickable badge interface for selecting scripts/flows/endpoints - Selected badges show in blue, unselected in gray with opacity - Automatically load all workspace scripts/flows/endpoints in custom mode - Generate granular scopes: mcp:scripts:{paths}, mcp:flows:{paths}, mcp:endpoints:{names} - Validate at least one resource is selected before token creation - Display selection count for user feedback Co-authored-by: centdix * docs(openapi): document granular MCP scope format - Add comprehensive documentation for MCP scopes in NewToken schema - Document new granular format: mcp:scripts:{paths}, mcp:flows:{paths}, mcp:endpoints:{names} - Provide usage examples for common scenarios - Mark legacy scopes (mcp:all, mcp:favorites) as deprecated but supported - Include wildcard pattern examples (f/folder/*) Co-authored-by: centdix * fix: add type annotation for None in scope_path tuple Fix compilation error where type parameter T could not be inferred for Option. Changed None to None::<&str> to explicitly specify the type. Co-authored-by: centdix * better ui * cleaning * fix back comp * fix * fix * cleaning --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: centdix --- backend/windmill-api/openapi.yaml | 2 +- backend/windmill-api/src/mcp/server.rs | 124 ++++++---- .../windmill-api/src/mcp/utils/database.rs | 27 +-- backend/windmill-api/src/mcp/utils/mod.rs | 3 +- .../src/mcp/utils/scope_matcher.rs | 229 ++++++++++++++++++ .../components/settings/CreateToken.svelte | 148 ++++++++++- 6 files changed, 455 insertions(+), 78 deletions(-) create mode 100644 backend/windmill-api/src/mcp/utils/scope_matcher.rs diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index c8345e4b38..72d3ac3407 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -15113,7 +15113,7 @@ components: schema: type: integer JobTriggerKind: - name: trigger_kind + name: trigger_kind description: trigger kind (schedule, http, websocket...) in: query schema: diff --git a/backend/windmill-api/src/mcp/server.rs b/backend/windmill-api/src/mcp/server.rs index 8a09459202..0e1a585383 100644 --- a/backend/windmill-api/src/mcp/server.rs +++ b/backend/windmill-api/src/mcp/server.rs @@ -39,6 +39,7 @@ use super::utils::{ FlowInfo, ResourceInfo, ResourceType, SchemaType, ScriptInfo, ToolableItem, WorkspaceId, }, schema::transform_schema_for_resources, + scope_matcher::{is_resource_allowed, parse_mcp_scopes}, transform::{reverse_transform, reverse_transform_key}, }; @@ -151,6 +152,10 @@ impl ServerHandler for Runner { check_scopes(authed)?; + // Parse MCP scopes for authorization + let scopes = authed.scopes.as_ref().map(|s| s.as_slice()).unwrap_or(&[]); + let scope_config = parse_mcp_scopes(scopes)?; + if request.name.ends_with("_TRUNC") { return Ok(CallToolResult::error( vec![ @@ -197,6 +202,19 @@ impl ServerHandler for Runner { let endpoint_tools = all_endpoint_tools(); for endpoint_tool in endpoint_tools { if endpoint_tool.name.as_ref() == request.name { + // Validate endpoint scope + if scope_config.granular + && !is_resource_allowed(&endpoint_tool.name, &scope_config.endpoints) + { + return Err(ErrorData::internal_error( + format!( + "Access denied: endpoint '{}' not in token scope", + endpoint_tool.name + ), + None, + )); + } + // This is an endpoint tool, forward to the actual HTTP endpoint let result = call_endpoint_tool(&endpoint_tool, args.clone(), &workspace_id, &authed) @@ -212,6 +230,21 @@ impl ServerHandler for Runner { ErrorData::internal_error(format!("Failed to reverse transform path: {}", e), None) })?; + // Validate script/flow scope + if !is_hub && scope_config.granular { + if tool_type == "script" && !is_resource_allowed(&path, &scope_config.scripts) { + return Err(ErrorData::internal_error( + format!("Access denied: script '{}' not in token scope", path), + None, + )); + } else if tool_type == "flow" && !is_resource_allowed(&path, &scope_config.flows) { + return Err(ErrorData::internal_error( + format!("Access denied: flow '{}' not in token scope", path), + None, + )); + } + } + let item_schema = if is_hub { get_hub_script_schema(&format!("hub/{}", path), db).await? } else { @@ -337,53 +370,23 @@ impl ServerHandler for Runner { }) .map(|w_id| w_id.0.clone())?; - let scopes = authed.scopes.as_ref(); - let owned_scope = scopes.and_then(|scopes| { - scopes - .iter() - .find(|scope| scope.starts_with("mcp:") && !scope.contains("hub")) - }); - let hub_scope = - scopes.and_then(|scopes| scopes.iter().find(|scope| scope.starts_with("mcp:hub"))); - let (scope_type, scope_path) = owned_scope.map_or(("all", None), |scope| { - let parts = scope.split(":").collect::>(); - ( - parts[1], - if parts.len() == 3 { - Some(parts[2]) - } else { - None - }, - ) - }); - let scope_integrations = hub_scope.and_then(|scope| { - let parts = scope.split(":").collect::>(); - if parts.len() == 3 { - Some(parts[2]) - } else { - None - } - }); + // Parse MCP scopes to determine what to expose + let scopes = authed.scopes.as_ref().map(|s| s.as_slice()).unwrap_or(&[]); + let scope_config = parse_mcp_scopes(scopes)?; - let scripts_fn = get_items::( - user_db, - authed, - &workspace_id, - scope_type, - "script", - scope_path.as_deref(), - ); - let flows_fn = get_items::( - user_db, - authed, - &workspace_id, - scope_type, - "flow", - scope_path.as_deref(), - ); + let scope_type = if scope_config.favorites { + "favorites" + } else { + // Fetch all items if either all or granular scope set (we filter later for granular scopes) + "all" + }; + + let scripts_fn = + get_items::(user_db, authed, &workspace_id, scope_type, "script"); + let flows_fn = get_items::(user_db, authed, &workspace_id, scope_type, "flow"); let resources_types_fn = get_resources_types(user_db, authed, &workspace_id); - let hub_scripts_fn = get_scripts_from_hub(db, scope_integrations.as_deref()); - let (scripts, flows, resources_types, hub_scripts) = if scope_integrations.is_some() { + let hub_scripts_fn = get_scripts_from_hub(db, scope_config.hub_apps.as_deref()); + let (scripts, flows, resources_types, hub_scripts) = if scope_config.hub_apps.is_some() { let (scripts, flows, resources_types, hub_scripts) = try_join!(scripts_fn, flows_fn, resources_types_fn, hub_scripts_fn)?; (scripts, flows, resources_types, hub_scripts) @@ -396,7 +399,13 @@ impl ServerHandler for Runner { let mut resources_cache: HashMap> = HashMap::new(); let mut tools: Vec = Vec::new(); + // Filter and add scripts based on scope for script in scripts { + // For granular scopes, filter by path + if scope_config.granular && !is_resource_allowed(&script.path, &scope_config.scripts) { + continue; + } + tools.push( Runner::create_tool_from_item( &script, @@ -410,7 +419,13 @@ impl ServerHandler for Runner { ); } + // Filter and add flows based on scope for flow in flows { + // For granular scopes, filter by path + if scope_config.granular && !is_resource_allowed(&flow.path, &scope_config.flows) { + continue; + } + tools.push( Runner::create_tool_from_item( &flow, @@ -438,10 +453,23 @@ impl ServerHandler for Runner { ); } - // Add endpoint tools from the generated MCP tools + // Add endpoint tools from the generated MCP tools, filtered by scope let endpoint_tools = all_endpoint_tools(); - let mcp_tools_converted = endpoint_tools_to_mcp_tools(endpoint_tools); - tools.extend(mcp_tools_converted); + for endpoint_tool in endpoint_tools { + // For granular scopes, filter by endpoint name + if scope_config.granular + && !is_resource_allowed(&endpoint_tool.name, &scope_config.endpoints) + { + continue; + } + + tools.push( + endpoint_tools_to_mcp_tools(vec![endpoint_tool]) + .into_iter() + .next() + .unwrap(), + ); + } Ok(ListToolsResult { tools, next_cursor: None }) } diff --git a/backend/windmill-api/src/mcp/utils/database.rs b/backend/windmill-api/src/mcp/utils/database.rs index 39aac1ae6e..32da184638 100644 --- a/backend/windmill-api/src/mcp/utils/database.rs +++ b/backend/windmill-api/src/mcp/utils/database.rs @@ -18,11 +18,10 @@ use crate::HTTP_CLIENT; pub fn check_scopes(authed: &ApiAuthed) -> Result<(), ErrorData> { let scopes = authed.scopes.as_ref(); if scopes.is_none() - || scopes.unwrap().iter().all(|scope| { - !scope.starts_with("mcp:all") - && !scope.starts_with("mcp:favorites") - && !scope.starts_with("mcp:hub:") - }) + || scopes + .unwrap() + .iter() + .all(|scope| !scope.starts_with("mcp:")) { tracing::error!("Unauthorized: missing mcp scope"); return Err(ErrorData::internal_error( @@ -141,7 +140,6 @@ pub async fn get_items sqlx::FromRow<'a, sqlx::postgres::PgRow> + Sen workspace_id: &str, scope_type: &str, item_type: &str, - scope_path: Option<&str>, ) -> Result, ErrorData> { let mut sqlb = SqlBuilder::select_from(&format!("{} as o", item_type)); let fields = vec!["o.path", "o.summary", "o.description", "o.schema"]; @@ -159,23 +157,6 @@ pub async fn get_items sqlx::FromRow<'a, sqlx::postgres::PgRow> + Sen sqlb.and_where("(o.no_main_func IS NOT TRUE OR o.no_main_func IS NULL)"); } - // scope path is always a folder path, format is f/my_folder/* - if let Some(scope_path) = scope_path { - if scope_path.split("/").count() != 3 - || !scope_path.starts_with("f/") - || !scope_path.ends_with("/*") - { - return Err(ErrorData::internal_error( - format!( - "Invalid folder format: {}, expected format is f/my_folder/*", - scope_path - ), - None, - )); - } - sqlb.and_where_like_left("o.path", &scope_path[..scope_path.len() - 2]); - } - sqlb.order_by( if item_type == "flow" { "o.edited_at" diff --git a/backend/windmill-api/src/mcp/utils/mod.rs b/backend/windmill-api/src/mcp/utils/mod.rs index 04464be481..e4072313a0 100644 --- a/backend/windmill-api/src/mcp/utils/mod.rs +++ b/backend/windmill-api/src/mcp/utils/mod.rs @@ -6,4 +6,5 @@ pub mod models; pub mod database; pub mod schema; -pub mod transform; \ No newline at end of file +pub mod transform; +pub mod scope_matcher; \ No newline at end of file diff --git a/backend/windmill-api/src/mcp/utils/scope_matcher.rs b/backend/windmill-api/src/mcp/utils/scope_matcher.rs new file mode 100644 index 0000000000..05fdafb706 --- /dev/null +++ b/backend/windmill-api/src/mcp/utils/scope_matcher.rs @@ -0,0 +1,229 @@ +//! MCP Scope matching utilities +//! +//! Contains utilities for parsing and matching MCP token scopes to determine +//! which scripts, flows, and endpoints a token has access to. + +use rmcp::ErrorData; + +/// Configuration for MCP scopes parsed from token scopes +#[derive(Debug, Clone, Default)] +pub struct McpScopeConfig { + /// Script paths/patterns allowed by this token + pub scripts: Vec, + /// Flow paths/patterns allowed by this token + pub flows: Vec, + /// Endpoint names/patterns allowed by this token + pub endpoints: Vec, + /// Whether this is a legacy "all" scope + pub all: bool, + /// Whether this is a "favorites" scope + pub favorites: bool, + /// Whether a granular scope is detected + pub granular: bool, + /// Hub app filter (if any) + pub hub_apps: Option, +} + +/// Parse MCP scopes from token scope strings +pub fn parse_mcp_scopes(scopes: &[String]) -> Result { + let mut config = McpScopeConfig::default(); + + for scope in scopes { + if !scope.starts_with("mcp:") { + continue; + } + + if scope == "mcp:all" { + // Legacy scope: grant access to everything + config.all = true; + config.scripts.push("*".to_string()); + config.flows.push("*".to_string()); + config.endpoints.push("*".to_string()); + continue; + } + + if scope == "mcp:favorites" { + // Legacy favorites scope + config.favorites = true; + continue; + } + + // Legacy folder scope: mcp:all:f/folder/* + if scope.starts_with("mcp:all:") { + if let Some(folder_pattern) = scope.strip_prefix("mcp:all:") { + // Parse as folder pattern - add to both scripts and flows. Also add all endpoints. + config.scripts.push(folder_pattern.to_string()); + config.flows.push(folder_pattern.to_string()); + config.endpoints.push("*".to_string()); + } + continue; + } + + if scope.starts_with("mcp:hub:") { + // Legacy hub scope + if let Some(apps) = scope.strip_prefix("mcp:hub:") { + config.hub_apps = Some(apps.to_string()); + } + continue; + } + + if let Some(resources) = scope.strip_prefix("mcp:scripts:") { + // New granular script scope: mcp:scripts:path1,path2,f/folder/* + config.scripts.extend(parse_resource_list(resources)?); + continue; + } + + if let Some(resources) = scope.strip_prefix("mcp:flows:") { + // New granular flow scope: mcp:flows:path1,path2,f/folder/* + config.flows.extend(parse_resource_list(resources)?); + continue; + } + + if let Some(resources) = scope.strip_prefix("mcp:endpoints:") { + // New granular endpoint scope: mcp:endpoints:name1,name2 + config.endpoints.extend(parse_resource_list(resources)?); + continue; + } + + tracing::warn!("Unrecognized MCP scope format: {}", scope); + } + + config.granular = !config.all && !config.favorites; + + Ok(config) +} + +/// Parse comma-separated resource list +fn parse_resource_list(resources: &str) -> Result, ErrorData> { + if resources.is_empty() { + return Ok(vec![]); + } + + Ok(resources + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect()) +} + +/// Check if a resource path matches any pattern in the allowed list +pub fn is_resource_allowed(resource_path: &str, allowed_patterns: &[String]) -> bool { + if allowed_patterns.is_empty() { + return false; + } + + // Wildcard grants all access + if allowed_patterns.contains(&"*".to_string()) { + return true; + } + + // Check against each pattern + for pattern in allowed_patterns { + if resource_matches_pattern(resource_path, pattern) { + return true; + } + } + + false +} + +/// Check if a resource path matches a pattern (supports wildcards like f/folder/*) +fn resource_matches_pattern(resource_path: &str, pattern: &str) -> bool { + // Exact match + if pattern == resource_path { + return true; + } + + // Wildcard pattern matching + if pattern.ends_with("/*") { + let prefix = &pattern[..pattern.len() - 2]; + + if !resource_path.starts_with(prefix) { + return false; + } + + // If the resource is exactly the prefix, it matches + if resource_path.len() == prefix.len() { + return true; + } + + // If the resource is longer, the next character must be '/' for a valid match + // This prevents "u/user" from matching "u/use/*" + return resource_path.chars().nth(prefix.len()) == Some('/'); + } + + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_legacy_scopes() { + let scopes = vec!["mcp:all".to_string()]; + let config = parse_mcp_scopes(&scopes).unwrap(); + assert!(config.all); + assert_eq!(config.scripts, vec!["*"]); + assert_eq!(config.flows, vec!["*"]); + assert_eq!(config.endpoints, vec!["*"]); + + let scopes = vec!["mcp:favorites".to_string()]; + let config = parse_mcp_scopes(&scopes).unwrap(); + assert!(config.favorites); + + let scopes = vec!["mcp:hub:slack".to_string()]; + let config = parse_mcp_scopes(&scopes).unwrap(); + assert_eq!(config.hub_apps, Some("slack".to_string())); + } + + #[test] + fn test_parse_granular_scopes() { + let scopes = vec![ + "mcp:scripts:u/admin/script1,u/admin/script2".to_string(), + "mcp:flows:f/automation/*".to_string(), + "mcp:endpoints:list_jobs,get_job".to_string(), + ]; + let config = parse_mcp_scopes(&scopes).unwrap(); + + assert_eq!(config.scripts, vec!["u/admin/script1", "u/admin/script2"]); + assert_eq!(config.flows, vec!["f/automation/*"]); + assert_eq!(config.endpoints, vec!["list_jobs", "get_job"]); + } + + #[test] + fn test_resource_matching() { + // Exact match + assert!(resource_matches_pattern("u/admin/script", "u/admin/script")); + + // Wildcard folder match + assert!(resource_matches_pattern("f/folder/script", "f/folder/*")); + assert!(resource_matches_pattern( + "f/folder/sub/script", + "f/folder/*" + )); + + // Should NOT match - prefix is not complete + assert!(!resource_matches_pattern("u/username", "u/user/*")); + + // Should match - exact prefix + assert!(resource_matches_pattern("u/user/script", "u/user/*")); + } + + #[test] + fn test_is_resource_allowed() { + let patterns = vec!["u/admin/script1".to_string(), "f/folder/*".to_string()]; + + assert!(is_resource_allowed("u/admin/script1", &patterns)); + assert!(is_resource_allowed("f/folder/anything", &patterns)); + assert!(!is_resource_allowed("u/other/script", &patterns)); + + // Test wildcard + let wildcard = vec!["*".to_string()]; + assert!(is_resource_allowed("any/path", &wildcard)); + + // Test empty patterns + let empty: Vec = vec![]; + assert!(!is_resource_allowed("any/path", &empty)); + } +} diff --git a/frontend/src/lib/components/settings/CreateToken.svelte b/frontend/src/lib/components/settings/CreateToken.svelte index e6c36bdf70..ec06cabf7a 100644 --- a/frontend/src/lib/components/settings/CreateToken.svelte +++ b/frontend/src/lib/components/settings/CreateToken.svelte @@ -58,6 +58,13 @@ let includedRunnables = $state([]) let selectedFolder = $state('') + // Granular scope selection + let selectedScripts = $state([]) + let selectedFlows = $state([]) + let selectedEndpoints = $state([]) + let allScripts = $state([]) + let allFlows = $state([]) + let runnablesCache = new Map() let customScopes = $state([]) @@ -86,9 +93,21 @@ let tokenScopes = scopes if (mcpMode) { - if (newMcpScope === 'folder') { + if (newMcpScope === 'custom') { + // Granular scope format + tokenScopes = [] + if (selectedScripts.length > 0) { + tokenScopes.push(`mcp:scripts:${selectedScripts.join(',')}`) + } + if (selectedFlows.length > 0) { + tokenScopes.push(`mcp:flows:${selectedFlows.join(',')}`) + } + if (selectedEndpoints.length > 0) { + tokenScopes.push(`mcp:endpoints:${selectedEndpoints.join(',')}`) + } + } else if (newMcpScope === 'folder') { const folderPath = `f/${selectedFolder}/*` - tokenScopes = [`mcp:all:${folderPath}`] + tokenScopes = [`mcp:scripts:${folderPath}`, `mcp:flows:${folderPath}`, `mcp:endpoints:*`] } else { tokenScopes = [`mcp:${newMcpScope}`] } @@ -247,6 +266,53 @@ selectedFolder = '' } }) + + $effect(() => { + if (mcpCreationMode && newMcpScope === 'custom') { + const workspace = newTokenWorkspace || $workspaceStore + if (workspace) { + loadAllScriptsAndFlows(workspace) + } + } + }) + + async function loadAllScriptsAndFlows(workspace: string) { + try { + loadingRunnables = true + const [scripts, flows] = await Promise.all([ + getScripts(false, workspace, undefined), + getFlows(false, workspace, undefined) + ]) + allScripts = scripts + allFlows = flows + } finally { + loadingRunnables = false + } + } + + function selectAllScripts() { + selectedScripts = [...allScripts] + } + + function clearAllScripts() { + selectedScripts = [] + } + + function selectAllFlows() { + selectedFlows = [...allFlows] + } + + function clearAllFlows() { + selectedFlows = [] + } + + function selectAllEndpoints() { + selectedEndpoints = [...mcpEndpointTools.map((e) => e.name)] + } + + function clearAllEndpoints() { + selectedEndpoints = [] + }
@@ -317,7 +383,7 @@
{#if mcpCreationMode} -
+
Scope {#snippet children({ item })} @@ -339,6 +405,12 @@ label="Folder" tooltip="Make all scripts and flows in the selected folder available as tools" /> + {/snippet}
@@ -404,7 +476,68 @@ />
{/if} - {#if mcpCreationMode && (newMcpScope !== 'folder' || selectedFolder.length > 0)} + {#if mcpCreationMode && newMcpScope === 'custom'} + {#if loadingRunnables} +
+ Loading scripts and flows... +
+ Loading... +
+
+ {:else} + {#snippet sectionHeader(label: string, selectAll: () => void, clearAll: () => void)} +
+ {label} +
+ + +
+
+ {/snippet} + +
+
+ {@render sectionHeader('Scripts', selectAllScripts, clearAllScripts)} + {#if allScripts.length > 0} + + {:else} +

No scripts available

+ {/if} +
+ +
+ {@render sectionHeader('Flows', selectAllFlows, clearAllFlows)} + {#if allFlows.length > 0} + + {:else} +

No flows available

+ {/if} +
+ +
+ {@render sectionHeader('API Endpoints', selectAllEndpoints, clearAllEndpoints)} + e.name))} + placeholder="Select endpoints" + bind:value={selectedEndpoints} + /> +
+ +
+ Selected: {selectedScripts.length} scripts, {selectedFlows.length} flows, {selectedEndpoints.length} + endpoints +
+
+ {/if} + {:else if mcpCreationMode && (newMcpScope !== 'folder' || selectedFolder.length > 0)} {#if loadingRunnables}
createToken(mcpCreationMode)} disabled={mcpCreationMode && - (newTokenWorkspace == undefined || (newMcpScope === 'folder' && !selectedFolder))} + (newTokenWorkspace == undefined || + (newMcpScope === 'folder' && !selectedFolder) || + (newMcpScope === 'custom' && + selectedScripts.length === 0 && + selectedFlows.length === 0 && + selectedEndpoints.length === 0))} variant="accent" > New token