From c1c65720b40807fce97e2cd0a4418e2fec07baae Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Tue, 12 Aug 2025 14:21:16 +0200 Subject: [PATCH] feat(mcp): allow filtering by folder (#6366) * allow filtering mcp by folder * nit * add error if wrong format --- backend/windmill-api/src/mcp.rs | 21 +++++-- .../components/settings/CreateToken.svelte | 56 +++++++++++++++---- 2 files changed, 62 insertions(+), 15 deletions(-) diff --git a/backend/windmill-api/src/mcp.rs b/backend/windmill-api/src/mcp.rs index 8141dec491..ced7682a08 100644 --- a/backend/windmill-api/src/mcp.rs +++ b/backend/windmill-api/src/mcp.rs @@ -242,7 +242,7 @@ impl Runner { || scopes .unwrap() .iter() - .all(|scope| scope != "mcp:all" && scope != "mcp:favorites" && !scope.starts_with("mcp:hub:")) + .all(|scope| !scope.starts_with("mcp:all") && !scope.starts_with("mcp:favorites") && !scope.starts_with("mcp:hub:")) { tracing::error!("Unauthorized: missing mcp scope"); return Err(Error::internal_error("Unauthorized: missing mcp scope".to_string(), None)); @@ -405,6 +405,7 @@ impl Runner { workspace_id: &str, scope_type: &str, item_type: &str, + scope_path: Option<&str>, ) -> Result, Error> { let mut sqlb = SqlBuilder::select_from(&format!("{} as o", item_type)); let fields = vec!["o.path", "o.summary", "o.description", "o.schema"]; @@ -422,6 +423,17 @@ impl Runner { 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(Error::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" @@ -1011,9 +1023,9 @@ impl ServerHandler for Runner { .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 = owned_scope.map_or("all", |scope| { + let (scope_type, scope_path) = owned_scope.map_or(("all", None), |scope| { let parts = scope.split(":").collect::>(); - parts[1] + (parts[1], if parts.len() == 3 { Some(parts[2]) } else { None }) }); let scope_integrations = hub_scope.and_then(|scope| { let parts = scope.split(":").collect::>(); @@ -1030,9 +1042,10 @@ impl ServerHandler for Runner { &workspace_id, scope_type, "script", + scope_path.as_deref(), ); let flows_fn = - Runner::inner_get_items::(user_db, authed, &workspace_id, scope_type, "flow"); + Runner::inner_get_items::(user_db, authed, &workspace_id, scope_type, "flow", scope_path.as_deref()); let resources_types_fn = Runner::inner_get_resources_types(user_db, authed, &workspace_id); let hub_scripts_fn = Runner::inner_get_scripts_from_hub(db, scope_integrations.as_deref()); let (scripts, flows, resources_types, hub_scripts) = if scope_integrations.is_some() { diff --git a/frontend/src/lib/components/settings/CreateToken.svelte b/frontend/src/lib/components/settings/CreateToken.svelte index c17602944a..ad7e47b297 100644 --- a/frontend/src/lib/components/settings/CreateToken.svelte +++ b/frontend/src/lib/components/settings/CreateToken.svelte @@ -11,6 +11,7 @@ import TokenDisplay from './TokenDisplay.svelte' import ScopeSelector from './ScopeSelector.svelte' import Alert from '../common/alert/Alert.svelte' + import FolderPicker from '../FolderPicker.svelte' interface Props { showMcpMode?: boolean @@ -42,6 +43,7 @@ let allApps = $state([]) let loadingRunnables = $state(false) let includedRunnables = $state([]) + let selectedFolder = $state('') let runnablesCache = new Map() @@ -71,7 +73,12 @@ let tokenScopes = scopes if (mcpMode) { - tokenScopes = [`mcp:${newMcpScope}`] + if (newMcpScope === 'folder') { + const folderPath = `f/${selectedFolder}/*` + tokenScopes = [`mcp:all:${folderPath}`] + } else { + tokenScopes = [`mcp:${newMcpScope}`] + } if (newMcpApps.length > 0) { tokenScopes.push(`mcp:hub:${newMcpApps.join(',')}`) } @@ -134,30 +141,34 @@ } } - async function getScripts(favoriteOnly: boolean = false, workspace: string) { + async function getScripts(favoriteOnly: boolean = false, workspace: string, folder: string | undefined) { if (!workspace) { return [] } + const pathStart = folder ? `f/${folder}` : undefined const scripts = await ScriptService.listScripts({ starredOnly: favoriteOnly, - workspace + workspace, + pathStart }) return scripts.map((x) => x.path) } - async function getFlows(favoriteOnly: boolean = false, workspace: string) { + async function getFlows(favoriteOnly: boolean = false, workspace: string, folder: string | undefined) { if (!workspace) { return [] } + const pathStart = folder ? `f/${folder}` : undefined const flows = await FlowService.listFlows({ starredOnly: favoriteOnly, - workspace + workspace, + pathStart }) return flows.map((x) => x.path) } - async function getScriptsAndFlows(favoriteOnly: boolean = false, workspace: string) { - const cacheKey = `${workspace}-${favoriteOnly}` + async function getScriptsAndFlows(favoriteOnly: boolean = false, workspace: string, folder: string | undefined) { + const cacheKey = `${workspace}-${favoriteOnly}${folder ? `-${folder}` : ''}` if (runnablesCache.has(cacheKey)) { includedRunnables = runnablesCache.get(cacheKey) || [] return @@ -165,7 +176,7 @@ try { loadingRunnables = true - const [scripts, flows] = await Promise.all([getScripts(favoriteOnly, workspace), getFlows(favoriteOnly, workspace)]) + const [scripts, flows] = await Promise.all([getScripts(favoriteOnly, workspace, folder), getFlows(favoriteOnly, workspace, folder)]) const combined = [...scripts, ...flows] runnablesCache.set(cacheKey, combined) includedRunnables = combined @@ -178,12 +189,19 @@ if (mcpCreationMode) { const workspace = newTokenWorkspace || $workspaceStore if (workspace) { - getScriptsAndFlows(newMcpScope === 'favorites', workspace) + const folderParam = selectedFolder.length > 0 ? selectedFolder : undefined + getScriptsAndFlows(newMcpScope === 'favorites', workspace, folderParam) } } else { includedRunnables = [] } }) + + $effect(() => { + if (mcpCreationMode && newMcpScope !== 'folder') { + selectedFolder = '' + } + })
@@ -270,10 +288,23 @@ label="All scripts/flows" tooltip="Make all your scripts and flows available as tools" /> + {/snippet}
+ {#if newMcpScope === 'folder'} +
+ Select Folder + +
+ {/if} +
Hub scripts (optional) {#if loadingApps} @@ -321,7 +352,9 @@
- {:else if loadingRunnables} + {/if} + {#if mcpCreationMode && (newMcpScope !== 'folder' || selectedFolder.length > 0)} + {#if loadingRunnables}
Scripts & Flows that will be available via MCP
@@ -358,6 +391,7 @@ {/if}
{/if} + {/if}
@@ -371,7 +405,7 @@