diff --git a/frontend/src/lib/components/copilot/chat/AssistantMcpSection.svelte b/frontend/src/lib/components/copilot/chat/AssistantMcpSection.svelte index 0ab2812b2e..c8d360e0f9 100644 --- a/frontend/src/lib/components/copilot/chat/AssistantMcpSection.svelte +++ b/frontend/src/lib/components/copilot/chat/AssistantMcpSection.svelte @@ -14,6 +14,11 @@ switch that decides whether this chat carries its tools. import DropdownV2 from '$lib/components/DropdownV2.svelte' import Toggle from '$lib/components/Toggle.svelte' import { isMcpEnabled, setMcpEnabled } from '$lib/components/mcp/enabledServers' + import { + isOwnOrSharedMcpPath, + MCP_LIST_PER_PAGE, + mcpViewer + } from '$lib/components/mcp/ownServers' import { loadProviderIcon } from '$lib/components/mcp/providerIcon' import { cachedProviderKey, @@ -275,11 +280,15 @@ switch that decides whether this chat carries its tools. loading = true loadError = undefined try { - const resources = await ResourceService.listResource({ - workspace: target, - resourceType: 'mcp', - perPage: 100 - }) + const [listed, viewer] = await Promise.all([ + ResourceService.listResource({ + workspace: target, + resourceType: 'mcp', + perPage: MCP_LIST_PER_PAGE + }), + mcpViewer(target) + ]) + const resources = listed.filter((r) => isOwnOrSharedMcpPath(r.path, r.extra_perms, viewer)) if (seq !== loadSeq) return servers = resources.map((r) => ({ path: r.path, diff --git a/frontend/src/lib/components/copilot/chat/global/mcpTools.test.ts b/frontend/src/lib/components/copilot/chat/global/mcpTools.test.ts index e5d3af8b36..9e1b46589c 100644 --- a/frontend/src/lib/components/copilot/chat/global/mcpTools.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/mcpTools.test.ts @@ -4,7 +4,7 @@ const { getMcpToolsMock, callMcpToolMock, listResourceMock, session } = vi.hoist getMcpToolsMock: vi.fn(), callMcpToolMock: vi.fn(), listResourceMock: vi.fn(), - session: { email: 'first@windmill.dev' } + session: { email: 'first@windmill.dev', workspace_id: 'test-ws', username: 'hugo', pgroups: [] } })) vi.mock('../shared', () => ({ diff --git a/frontend/src/lib/components/copilot/chat/global/mcpTools.ts b/frontend/src/lib/components/copilot/chat/global/mcpTools.ts index 6fd59e4a07..c1db1cac1b 100644 --- a/frontend/src/lib/components/copilot/chat/global/mcpTools.ts +++ b/frontend/src/lib/components/copilot/chat/global/mcpTools.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { ResourceService, type GetMcpToolsResponse } from '$lib/gen' import { createToolDef, type Tool } from '../shared' import { enabledMcpPaths } from '$lib/components/mcp/enabledServers' +import { isOwnOrSharedMcpPath, MCP_LIST_PER_PAGE, mcpViewer } from '$lib/components/mcp/ownServers' /** * Access to the MCP servers the user has connected (resources of type `mcp`) @@ -91,13 +92,18 @@ export async function loadMcpServers(workspace: string): Promise { const enabled = enabledMcpPaths(workspace) if (enabled.length === 0) return [] try { - const resources = await ResourceService.listResource({ - workspace, - resourceType: 'mcp', - perPage: 100 - }) + const [resources, viewer] = await Promise.all([ + ResourceService.listResource({ + workspace, + resourceType: 'mcp', + perPage: MCP_LIST_PER_PAGE + }), + mcpViewer(workspace) + ]) return resources - .filter((r) => enabled.includes(r.path)) + .filter( + (r) => enabled.includes(r.path) && isOwnOrSharedMcpPath(r.path, r.extra_perms, viewer) + ) .map((r) => ({ path: r.path, editedAt: r.edited_at })) } catch (e) { console.error('Failed to load MCP servers', e) diff --git a/frontend/src/lib/components/mcp/mcpMenu.svelte.ts b/frontend/src/lib/components/mcp/mcpMenu.svelte.ts index aea4a7b114..a911d71c07 100644 --- a/frontend/src/lib/components/mcp/mcpMenu.svelte.ts +++ b/frontend/src/lib/components/mcp/mcpMenu.svelte.ts @@ -8,6 +8,7 @@ import type { Item } from '$lib/utils' import type { AIChatManager } from '../copilot/chat/AIChatManager.svelte' import { isMcpEnabled, setMcpEnabled } from './enabledServers' import { cachedProviderKey, rememberProviderKey } from './iconCache' +import { isOwnOrSharedMcpPath, MCP_LIST_PER_PAGE, mcpViewer } from './ownServers' import { loadProviderIcon } from './providerIcon' import McpServerIcon from './McpServerIcon.svelte' @@ -56,17 +57,22 @@ export class McpMenu { async #load(ws: string) { const seq = ++this.#seq try { - const resources = await ResourceService.listResource({ - workspace: ws, - resourceType: 'mcp', - perPage: 100 - }) + const [resources, viewer] = await Promise.all([ + ResourceService.listResource({ + workspace: ws, + resourceType: 'mcp', + perPage: MCP_LIST_PER_PAGE + }), + mcpViewer(ws) + ]) if (seq !== this.#seq) return - this.#rows = resources.map((r) => ({ - path: r.path, - editedAt: r.edited_at, - enabled: isMcpEnabled(ws, r.path) - })) + this.#rows = resources + .filter((r) => isOwnOrSharedMcpPath(r.path, r.extra_perms, viewer)) + .map((r) => ({ + path: r.path, + editedAt: r.edited_at, + enabled: isMcpEnabled(ws, r.path) + })) this.#rowsWorkspace = ws void this.#loadIcons(ws, seq) } catch { diff --git a/frontend/src/lib/components/mcp/ownServers.test.ts b/frontend/src/lib/components/mcp/ownServers.test.ts new file mode 100644 index 0000000000..d6982790ba --- /dev/null +++ b/frontend/src/lib/components/mcp/ownServers.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { session, roleMock } = vi.hoisted(() => ({ + session: { workspace_id: 'browsed', username: 'alice', pgroups: ['g/browsed-only'] }, + roleMock: vi.fn() +})) + +vi.mock('$lib/stores', () => ({ + userStore: { subscribe: (run: (v: unknown) => void) => (run({ ...session }), () => {}) } +})) +vi.mock('$lib/user', () => ({ getWorkspaceRole: roleMock })) + +import { isOwnOrSharedMcpPath, mcpViewer } from './ownServers' + +const alice = { username: 'alice', pgroups: ['g/eng'] } + +describe('isOwnOrSharedMcpPath', () => { + it('keeps own, folder, and explicitly shared servers; drops the rest of u/', () => { + expect(isOwnOrSharedMcpPath('u/alice/notion', {}, alice)).toBe(true) + expect(isOwnOrSharedMcpPath('f/team/notion', {}, alice)).toBe(true) + expect(isOwnOrSharedMcpPath('u/bob/notion', {}, alice)).toBe(false) + expect(isOwnOrSharedMcpPath('u/alicex/notion', {}, alice)).toBe(false) + expect(isOwnOrSharedMcpPath('u/bob/notion', { 'u/alice': false }, alice)).toBe(true) + expect(isOwnOrSharedMcpPath('u/bob/notion', { 'g/eng': false }, alice)).toBe(true) + expect(isOwnOrSharedMcpPath('u/bob/notion', { 'g/ops': true }, alice)).toBe(false) + expect(isOwnOrSharedMcpPath('u/alice/notion', {}, { username: undefined, pgroups: [] })).toBe( + false + ) + }) +}) + +describe('mcpViewer', () => { + beforeEach(() => roleMock.mockReset()) + + it('answers from the store only for the browsed workspace', async () => { + expect(await mcpViewer('browsed')).toEqual({ username: 'alice', pgroups: ['g/browsed-only'] }) + expect(roleMock).not.toHaveBeenCalled() + }) + + it('looks the identity up for another workspace instead of reusing the browsed one', async () => { + roleMock.mockResolvedValue({ + kind: 'resolved', + user: { username: 'alice_other', pgroups: ['g/eng'] } + }) + expect(await mcpViewer('other')).toEqual({ username: 'alice_other', pgroups: ['g/eng'] }) + expect(roleMock).toHaveBeenCalledWith('other') + }) + + it('yields no identity when the lookup fails', async () => { + roleMock.mockResolvedValue({ kind: 'lookup_failed' }) + expect(await mcpViewer('other')).toEqual({ username: undefined, pgroups: [] }) + }) +}) diff --git a/frontend/src/lib/components/mcp/ownServers.ts b/frontend/src/lib/components/mcp/ownServers.ts new file mode 100644 index 0000000000..31c860a039 --- /dev/null +++ b/frontend/src/lib/components/mcp/ownServers.ts @@ -0,0 +1,48 @@ +import { get } from 'svelte/store' +import { userStore } from '$lib/stores' +import { getWorkspaceRole } from '$lib/user' + +/** The principals an `extra_perms` entry can name to grant this user access. */ +export type McpViewer = { username: string | undefined; pgroups: string[] } + +/** Wide enough that a workspace's whole MCP catalog arrives at once: the filter + * below runs after the server's LIMIT, so a short page could drop the viewer's + * own rows while foreign `u/` rows fill it. */ +export const MCP_LIST_PER_PAGE = 1000 + +/** + * Whether the chat lists this MCP server. A server under another user's `u/` + * prefix carries that person's credentials, and an admin's database role lets + * the resource listing return it. Acting through it would call the provider + * as them, so the chat only offers it when its owner shared it explicitly. + */ +export function isOwnOrSharedMcpPath( + path: string, + extraPerms: Record | undefined, + viewer: McpViewer +): boolean { + if (!path.startsWith('u/')) return true + if (viewer.username !== undefined && path.startsWith(`u/${viewer.username}/`)) return true + if (!extraPerms) return false + return ( + (viewer.username !== undefined && `u/${viewer.username}` in extraPerms) || + viewer.pgroups.some((g) => g in extraPerms) + ) +} + +/** + * The viewer's identity in `workspace`. Username and groups are per workspace, + * and a session chat can operate on a workspace other than the one being + * browsed, so `userStore` only answers when it describes that same workspace. + * A failed lookup yields no username: every `u/` server is then hidden rather + * than judged against another workspace's identity. + */ +export async function mcpViewer(workspace: string): Promise { + const u = get(userStore) + if (u?.workspace_id === workspace) return { username: u.username, pgroups: u.pgroups ?? [] } + const role = await getWorkspaceRole(workspace) + if (role.kind === 'resolved') { + return { username: role.user.username, pgroups: role.user.pgroups ?? [] } + } + return { username: undefined, pgroups: [] } +}