mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-10 00:05:27 +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:
co-authored by
Claude Opus 4.8
parent
3b0781761b
commit
8343203ec2
@@ -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