From 50bf1c1fe3a0b371f84c875b947a710af1868a60 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 15 Dec 2025 19:17:53 +0100 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=A4=96=20feat:=20selective=20MCP=20to?= =?UTF-8?q?ol=20configuration=20at=20project=20and=20workspace=20levels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the ability to configure which MCP servers and tools are available at both project and workspace levels. - Enable/disable MCP servers per project - Configure tool allowlists per server (restrict which tools are exposed) - Override project settings per workspace - Enable servers that are disabled at project level (opt-in) - Disable servers that are enabled at project level (opt-out) - Configure workspace-specific tool allowlists - New WorkspaceMCPModal for per-workspace MCP configuration - Shared ToolSelector component with All/None bulk selection buttons - Shared useMCPTestCache hook - tools fetched in Settings are cached and immediately available in workspace modal - Tool count display (e.g., '5/43') shows filtering at a glance WorkspaceMCPOverrides: - disabledServers: string[] - servers to disable (overrides project enabled) - enabledServers: string[] - servers to enable (overrides project disabled) - toolAllowlist: Record - per-server tool filtering Signed-off-by: Thomas Kosiewski --- _Generated with `mux`_ Change-Id: Idc97d085b6f1d1c5c0e98196d6d31625dea117d8 --- src/browser/components/AIView.tsx | 1 + .../sections/ProjectSettingsSection.tsx | 185 ++++++--- src/browser/components/ToolSelector.tsx | 76 ++++ src/browser/components/WorkspaceHeader.tsx | 29 +- src/browser/components/WorkspaceMCPModal.tsx | 366 ++++++++++++++++++ src/browser/hooks/useMCPTestCache.ts | 77 ++++ src/common/orpc/schemas/api.ts | 20 + src/common/orpc/schemas/mcp.ts | 37 +- src/common/orpc/schemas/project.ts | 4 + src/common/types/mcp.ts | 37 ++ src/node/config.test.ts | 115 ++++++ src/node/config.ts | 59 +++ src/node/orpc/router.ts | 32 ++ src/node/services/aiService.ts | 7 +- src/node/services/mcpConfigService.test.ts | 99 +++++ src/node/services/mcpConfigService.ts | 64 ++- src/node/services/mcpServerManager.ts | 174 +++++++-- 17 files changed, 1300 insertions(+), 82 deletions(-) create mode 100644 src/browser/components/ToolSelector.tsx create mode 100644 src/browser/components/WorkspaceMCPModal.tsx create mode 100644 src/browser/hooks/useMCPTestCache.ts diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index d00d631421..f64891ebb0 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -540,6 +540,7 @@ const AIViewInner: React.FC = ({ = ({ serverName, availableTools, currentAllowlist, testedAt, projectPath }) => { + const { api } = useAPI(); + const [expanded, setExpanded] = useState(false); + const [saving, setSaving] = useState(false); + // Always use an array internally - undefined from props means all tools allowed + const [localAllowlist, setLocalAllowlist] = useState( + () => currentAllowlist ?? [...availableTools] + ); -type CachedResults = Record; + // Sync local state when prop changes + useEffect(() => { + setLocalAllowlist(currentAllowlist ?? [...availableTools]); + }, [currentAllowlist, availableTools]); -/** Hook to manage MCP test results with localStorage caching */ -function useMCPTestCache(projectPath: string) { - const storageKey = useMemo( - () => (projectPath ? getMCPTestResultsKey(projectPath) : ""), - [projectPath] - ); + const allAllowed = localAllowlist.length === availableTools.length; + const allDisabled = localAllowlist.length === 0; - const [cache, setCache] = useState(() => - storageKey ? readPersistedState(storageKey, {}) : {} - ); + const handleToggleTool = useCallback( + async (toolName: string, allowed: boolean) => { + if (!api) return; - // Reload cache when project changes - useEffect(() => { - if (storageKey) { - setCache(readPersistedState(storageKey, {})); - } else { - setCache({}); - } - }, [storageKey]); - - const setResult = useCallback( - (name: string, result: CachedMCPTestResult["result"]) => { - const entry: CachedMCPTestResult = { result, testedAt: Date.now() }; - setCache((prev) => { - const next = { ...prev, [name]: entry }; - if (storageKey) updatePersistedState(storageKey, next); - return next; - }); + const newAllowlist = allowed + ? [...localAllowlist, toolName] + : localAllowlist.filter((t) => t !== toolName); + + // Optimistic update + setLocalAllowlist(newAllowlist); + setSaving(true); + + try { + const result = await api.projects.mcp.setToolAllowlist({ + projectPath, + name: serverName, + toolAllowlist: newAllowlist, + }); + if (!result.success) { + setLocalAllowlist(currentAllowlist ?? [...availableTools]); + console.error("Failed to update tool allowlist:", result.error); + } + } catch (err) { + setLocalAllowlist(currentAllowlist ?? [...availableTools]); + console.error("Failed to update tool allowlist:", err); + } finally { + setSaving(false); + } }, - [storageKey] + [api, projectPath, serverName, localAllowlist, currentAllowlist, availableTools] ); - const clearResult = useCallback( - (name: string) => { - setCache((prev) => { - const next = { ...prev }; - delete next[name]; - if (storageKey) updatePersistedState(storageKey, next); - return next; + const handleAllowAll = useCallback(async () => { + if (!api || allAllowed) return; + + const newAllowlist = [...availableTools]; + setLocalAllowlist(newAllowlist); + setSaving(true); + + try { + const result = await api.projects.mcp.setToolAllowlist({ + projectPath, + name: serverName, + toolAllowlist: newAllowlist, }); - }, - [storageKey] - ); + if (!result.success) { + setLocalAllowlist(currentAllowlist ?? [...availableTools]); + console.error("Failed to clear tool allowlist:", result.error); + } + } catch (err) { + setLocalAllowlist(currentAllowlist ?? [...availableTools]); + console.error("Failed to clear tool allowlist:", err); + } finally { + setSaving(false); + } + }, [api, projectPath, serverName, allAllowed, currentAllowlist, availableTools]); + + const handleSelectNone = useCallback(async () => { + if (!api || allDisabled) return; - return { cache, setResult, clearResult }; -} + setLocalAllowlist([]); + setSaving(true); + + try { + const result = await api.projects.mcp.setToolAllowlist({ + projectPath, + name: serverName, + toolAllowlist: [], + }); + if (!result.success) { + setLocalAllowlist(currentAllowlist ?? [...availableTools]); + console.error("Failed to set empty tool allowlist:", result.error); + } + } catch (err) { + setLocalAllowlist(currentAllowlist ?? [...availableTools]); + console.error("Failed to set empty tool allowlist:", err); + } finally { + setSaving(false); + } + }, [api, projectPath, serverName, allDisabled, currentAllowlist, availableTools]); + + return ( +
+ + + {expanded && ( +
+ void handleToggleTool(tool, allowed)} + onSelectAll={() => void handleAllowAll()} + onSelectNone={() => void handleSelectNone()} + disabled={saving} + /> +
+ )} +
+ ); +}; export const ProjectSettingsSection: React.FC = () => { const { api } = useAPI(); @@ -478,12 +564,13 @@ export const ProjectSettingsSection: React.FC = () => { )} {cached?.result.success && cached.result.tools.length > 0 && !isEditing && ( -

- Tools: {cached.result.tools.join(", ")} - - ({formatRelativeTime(cached.testedAt)}) - -

+ )} ); diff --git a/src/browser/components/ToolSelector.tsx b/src/browser/components/ToolSelector.tsx new file mode 100644 index 0000000000..45509b21ae --- /dev/null +++ b/src/browser/components/ToolSelector.tsx @@ -0,0 +1,76 @@ +import React from "react"; +import { Checkbox } from "@/browser/components/ui/checkbox"; +import { Button } from "@/browser/components/ui/button"; + +interface ToolSelectorProps { + /** All available tools for this server */ + availableTools: string[]; + /** Currently allowed tools (empty = none allowed) */ + allowedTools: string[]; + /** Called when tool selection changes */ + onToggle: (toolName: string, allowed: boolean) => void; + /** Called to select all tools */ + onSelectAll: () => void; + /** Called to deselect all tools */ + onSelectNone: () => void; + /** Whether controls are disabled */ + disabled?: boolean; +} + +/** + * Reusable tool selector grid with All/None buttons. + * Used by both project-level and workspace-level MCP config UIs. + */ +export const ToolSelector: React.FC = ({ + availableTools, + allowedTools, + onToggle, + onSelectAll, + onSelectNone, + disabled = false, +}) => { + const allAllowed = allowedTools.length === availableTools.length; + const noneAllowed = allowedTools.length === 0; + + return ( +
+
+ Select tools to expose: +
+ + +
+
+
+ {availableTools.map((tool) => ( + + ))} +
+
+ ); +}; diff --git a/src/browser/components/WorkspaceHeader.tsx b/src/browser/components/WorkspaceHeader.tsx index 9578950c3f..3fe9c83ca0 100644 --- a/src/browser/components/WorkspaceHeader.tsx +++ b/src/browser/components/WorkspaceHeader.tsx @@ -1,8 +1,9 @@ import React, { useCallback, useEffect, useState } from "react"; -import { Pencil } from "lucide-react"; +import { Pencil, Server } from "lucide-react"; import { GitStatusIndicator } from "./GitStatusIndicator"; import { RuntimeBadge } from "./RuntimeBadge"; import { BranchSelector } from "./BranchSelector"; +import { WorkspaceMCPModal } from "./WorkspaceMCPModal"; import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import { useGitStatus } from "@/browser/stores/GitStatusStore"; @@ -16,6 +17,7 @@ import { useOpenInEditor } from "@/browser/hooks/useOpenInEditor"; interface WorkspaceHeaderProps { workspaceId: string; projectName: string; + projectPath: string; workspaceName: string; namedWorkspacePath: string; runtimeConfig?: RuntimeConfig; @@ -24,6 +26,7 @@ interface WorkspaceHeaderProps { export const WorkspaceHeader: React.FC = ({ workspaceId, projectName, + projectPath, workspaceName, namedWorkspacePath, runtimeConfig, @@ -34,6 +37,7 @@ export const WorkspaceHeader: React.FC = ({ const { canInterrupt } = useWorkspaceSidebarState(workspaceId); const { startSequence: startTutorial, isSequenceCompleted } = useTutorial(); const [editorError, setEditorError] = useState(null); + const [mcpModalOpen, setMcpModalOpen] = useState(false); const handleOpenTerminal = useCallback(() => { openTerminal(workspaceId, runtimeConfig); @@ -89,8 +93,23 @@ export const WorkspaceHeader: React.FC = ({ + + + Configure MCP servers for this workspace + + + + + @@ -118,6 +137,12 @@ export const WorkspaceHeader: React.FC = ({ + ); }; diff --git a/src/browser/components/WorkspaceMCPModal.tsx b/src/browser/components/WorkspaceMCPModal.tsx new file mode 100644 index 0000000000..58b67a535d --- /dev/null +++ b/src/browser/components/WorkspaceMCPModal.tsx @@ -0,0 +1,366 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { Server, Loader2 } from "lucide-react"; +import { Button } from "@/browser/components/ui/button"; +import { Switch } from "@/browser/components/ui/switch"; +import { useAPI } from "@/browser/contexts/API"; +import { cn } from "@/common/lib/utils"; +import type { MCPServerInfo, WorkspaceMCPOverrides } from "@/common/types/mcp"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/browser/components/ui/dialog"; +import { useMCPTestCache } from "@/browser/hooks/useMCPTestCache"; +import { ToolSelector } from "@/browser/components/ToolSelector"; + +interface WorkspaceMCPModalProps { + workspaceId: string; + projectPath: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const WorkspaceMCPModal: React.FC = ({ + workspaceId, + projectPath, + open, + onOpenChange, +}) => { + const { api } = useAPI(); + + // State for project servers and workspace overrides + const [servers, setServers] = useState>({}); + const [overrides, setOverrides] = useState({}); + const [loadingTools, setLoadingTools] = useState>({}); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + // Use shared cache for tool test results + const { getTools, setResult, reload: reloadCache } = useMCPTestCache(projectPath); + + // Load project servers and workspace overrides when modal opens + useEffect(() => { + if (!open || !api) return; + + // Reload cache when modal opens + reloadCache(); + + const loadData = async () => { + setLoading(true); + setError(null); + try { + const [projectServers, workspaceOverrides] = await Promise.all([ + api.projects.mcp.list({ projectPath }), + api.workspace.mcp.get({ workspaceId }), + ]); + setServers(projectServers ?? {}); + setOverrides(workspaceOverrides ?? {}); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load MCP configuration"); + } finally { + setLoading(false); + } + }; + + void loadData(); + }, [open, api, projectPath, workspaceId, reloadCache]); + + // Fetch/refresh tools for a server + const fetchTools = useCallback( + async (serverName: string) => { + if (!api) return; + setLoadingTools((prev) => ({ ...prev, [serverName]: true })); + try { + const result = await api.projects.mcp.test({ projectPath, name: serverName }); + setResult(serverName, result); + if (!result.success) { + setError(`Failed to fetch tools for ${serverName}: ${result.error}`); + } + } catch (err) { + setError(err instanceof Error ? err.message : `Failed to fetch tools for ${serverName}`); + } finally { + setLoadingTools((prev) => ({ ...prev, [serverName]: false })); + } + }, + [api, projectPath, setResult] + ); + + /** + * Determine if a server is effectively enabled for this workspace. + * Logic: + * - If in enabledServers: enabled (overrides project disabled) + * - If in disabledServers: disabled (overrides project enabled) + * - Otherwise: use project-level state (info.disabled) + */ + const isServerEnabled = useCallback( + (serverName: string, projectDisabled: boolean): boolean => { + if (overrides.enabledServers?.includes(serverName)) return true; + if (overrides.disabledServers?.includes(serverName)) return false; + return !projectDisabled; + }, + [overrides.enabledServers, overrides.disabledServers] + ); + + // Toggle server enabled/disabled for workspace + const toggleServerEnabled = useCallback( + (serverName: string, enabled: boolean, projectDisabled: boolean) => { + setOverrides((prev) => { + const currentEnabled = prev.enabledServers ?? []; + const currentDisabled = prev.disabledServers ?? []; + + let newEnabled: string[]; + let newDisabled: string[]; + + if (enabled) { + // Enabling the server + newDisabled = currentDisabled.filter((s) => s !== serverName); + if (projectDisabled) { + // Need explicit enable to override project disabled + newEnabled = [...currentEnabled, serverName]; + } else { + // Project already enabled, just remove from disabled list + newEnabled = currentEnabled.filter((s) => s !== serverName); + } + } else { + // Disabling the server + newEnabled = currentEnabled.filter((s) => s !== serverName); + if (projectDisabled) { + // Project already disabled, just remove from enabled list + newDisabled = currentDisabled.filter((s) => s !== serverName); + } else { + // Need explicit disable to override project enabled + newDisabled = [...currentDisabled, serverName]; + } + } + + return { + ...prev, + enabledServers: newEnabled.length > 0 ? newEnabled : undefined, + disabledServers: newDisabled.length > 0 ? newDisabled : undefined, + }; + }); + }, + [] + ); + + // Check if all tools are allowed (no allowlist set) + const hasNoAllowlist = useCallback( + (serverName: string): boolean => { + return !overrides.toolAllowlist?.[serverName]; + }, + [overrides.toolAllowlist] + ); + + // Toggle tool in allowlist + const toggleToolAllowed = useCallback( + (serverName: string, toolName: string, allowed: boolean) => { + const allTools = getTools(serverName) ?? []; + setOverrides((prev) => { + const currentAllowlist = prev.toolAllowlist ?? {}; + const serverAllowlist = currentAllowlist[serverName]; + + let newServerAllowlist: string[]; + if (allowed) { + // Adding tool to allowlist + if (!serverAllowlist) { + // No allowlist yet - create one with all tools except this one removed + // Actually, if we're adding and there's no allowlist, all are already allowed + // So we don't need to do anything + return prev; + } + newServerAllowlist = [...serverAllowlist, toolName]; + } else { + // Removing tool from allowlist + if (!serverAllowlist) { + // No allowlist yet - create one with all tools except this one + newServerAllowlist = allTools.filter((t) => t !== toolName); + } else { + newServerAllowlist = serverAllowlist.filter((t) => t !== toolName); + } + } + + // If allowlist contains all tools, remove it (same as no restriction) + const newAllowlist = { ...currentAllowlist }; + if (newServerAllowlist.length === allTools.length) { + delete newAllowlist[serverName]; + } else { + newAllowlist[serverName] = newServerAllowlist; + } + + return { + ...prev, + toolAllowlist: Object.keys(newAllowlist).length > 0 ? newAllowlist : undefined, + }; + }); + }, + [getTools] + ); + + // Set "all tools allowed" for a server (remove from allowlist) + const setAllToolsAllowed = useCallback((serverName: string) => { + setOverrides((prev) => { + const newAllowlist = { ...prev.toolAllowlist }; + delete newAllowlist[serverName]; + return { + ...prev, + toolAllowlist: Object.keys(newAllowlist).length > 0 ? newAllowlist : undefined, + }; + }); + }, []); + + // Set "no tools allowed" for a server (empty allowlist) + const setNoToolsAllowed = useCallback((serverName: string) => { + setOverrides((prev) => { + return { + ...prev, + toolAllowlist: { + ...prev.toolAllowlist, + [serverName]: [], + }, + }; + }); + }, []); + + // Save overrides + const handleSave = useCallback(async () => { + if (!api) return; + setSaving(true); + setError(null); + try { + const result = await api.workspace.mcp.set({ workspaceId, overrides }); + if (!result.success) { + setError(result.error); + } else { + onOpenChange(false); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save configuration"); + } finally { + setSaving(false); + } + }, [api, workspaceId, overrides, onOpenChange]); + + const serverEntries = Object.entries(servers); + const hasServers = serverEntries.length > 0; + + return ( + + + + + + Workspace MCP Configuration + + + + {loading ? ( +
+ +
+ ) : !hasServers ? ( +
+

No MCP servers configured for this project.

+

+ Configure servers in Settings → Projects to use them here. +

+
+ ) : ( +
+

+ Customize which MCP servers and tools are available in this workspace. Changes only + affect this workspace. +

+ + {error && ( +
+ {error} +
+ )} + +
+ {serverEntries.map(([name, info]) => { + const projectDisabled = info.disabled; + const effectivelyEnabled = isServerEnabled(name, projectDisabled); + const tools = getTools(name); + const isLoadingTools = loadingTools[name]; + const allowedTools = overrides.toolAllowlist?.[name] ?? tools ?? []; + + return ( +
+
+
+ + toggleServerEnabled(name, checked, projectDisabled) + } + /> +
+
{name}
+ {projectDisabled && ( +
(disabled at project level)
+ )} +
+
+ {effectivelyEnabled && ( + + )} +
+ + {/* Tool allowlist section */} + {effectivelyEnabled && tools && tools.length > 0 && ( +
+ toggleToolAllowed(name, tool, allowed)} + onSelectAll={() => setAllToolsAllowed(name)} + onSelectNone={() => setNoToolsAllowed(name)} + /> + {!hasNoAllowlist(name) && ( +
+ {allowedTools.length} of {tools.length} tools enabled +
+ )} +
+ )} + + {effectivelyEnabled && tools?.length === 0 && ( +
No tools available
+ )} +
+ ); + })} +
+ +
+ + +
+
+ )} +
+
+ ); +}; diff --git a/src/browser/hooks/useMCPTestCache.ts b/src/browser/hooks/useMCPTestCache.ts new file mode 100644 index 0000000000..93ec2fc3a6 --- /dev/null +++ b/src/browser/hooks/useMCPTestCache.ts @@ -0,0 +1,77 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { CachedMCPTestResult, MCPTestResult } from "@/common/types/mcp"; +import { getMCPTestResultsKey } from "@/common/constants/storage"; +import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; + +type CachedResults = Record; + +/** + * Hook for managing MCP server test results cache. + * Persists results to localStorage, shared across Settings and WorkspaceMCPModal. + */ +export function useMCPTestCache(projectPath: string) { + const storageKey = useMemo( + () => (projectPath ? getMCPTestResultsKey(projectPath) : ""), + [projectPath] + ); + + const [cache, setCache] = useState(() => + storageKey ? readPersistedState(storageKey, {}) : {} + ); + + // Reload cache when project changes + useEffect(() => { + if (storageKey) { + setCache(readPersistedState(storageKey, {})); + } else { + setCache({}); + } + }, [storageKey]); + + /** Update cache with a test result */ + const setResult = useCallback( + (name: string, result: MCPTestResult) => { + const entry: CachedMCPTestResult = { result, testedAt: Date.now() }; + setCache((prev) => { + const next = { ...prev, [name]: entry }; + if (storageKey) updatePersistedState(storageKey, next); + return next; + }); + }, + [storageKey] + ); + + /** Clear cached result for a server */ + const clearResult = useCallback( + (name: string) => { + setCache((prev) => { + const next = { ...prev }; + delete next[name]; + if (storageKey) updatePersistedState(storageKey, next); + return next; + }); + }, + [storageKey] + ); + + /** Get tools for a server (returns null if not cached or failed) */ + const getTools = useCallback( + (name: string): string[] | null => { + const cached = cache[name]; + if (cached?.result.success) { + return cached.result.tools; + } + return null; + }, + [cache] + ); + + /** Reload cache from storage (useful when opening modal) */ + const reload = useCallback(() => { + if (storageKey) { + setCache(readPersistedState(storageKey, {})); + } + }, [storageKey]); + + return { cache, setResult, clearResult, getTools, reload }; +} diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 82a535c32d..1e5a0d3b89 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -20,8 +20,10 @@ import { MCPRemoveParamsSchema, MCPServerMapSchema, MCPSetEnabledParamsSchema, + MCPSetToolAllowlistParamsSchema, MCPTestParamsSchema, MCPTestResultSchema, + WorkspaceMCPOverridesSchema, } from "./mcp"; // Re-export telemetry schemas @@ -160,6 +162,10 @@ export const projects = { input: MCPSetEnabledParamsSchema, output: ResultSchema(z.void(), z.string()), }, + setToolAllowlist: { + input: MCPSetToolAllowlistParamsSchema, + output: ResultSchema(z.void(), z.string()), + }, }, secrets: { get: { @@ -408,6 +414,20 @@ export const workspace = { input: z.object({ workspaceId: z.string() }), output: SessionUsageFileSchema.optional(), }, + /** Per-workspace MCP configuration (overrides project-level mcp.jsonc) */ + mcp: { + get: { + input: z.object({ workspaceId: z.string() }), + output: WorkspaceMCPOverridesSchema, + }, + set: { + input: z.object({ + workspaceId: z.string(), + overrides: WorkspaceMCPOverridesSchema, + }), + output: ResultSchema(z.void(), z.string()), + }, + }, }; export type WorkspaceSendMessageOutput = z.infer; diff --git a/src/common/orpc/schemas/mcp.ts b/src/common/orpc/schemas/mcp.ts index 1ed06b7de4..b49d5cd05c 100644 --- a/src/common/orpc/schemas/mcp.ts +++ b/src/common/orpc/schemas/mcp.ts @@ -1,5 +1,29 @@ import { z } from "zod"; +/** + * Per-workspace MCP overrides. + * + * Stored in ~/.mux/config.json under each workspace entry. + * Allows workspaces to disable servers or restrict tool allowlists + * without modifying the project-level .mux/mcp.jsonc. + */ +export const WorkspaceMCPOverridesSchema = z.object({ + /** Server names to explicitly disable for this workspace. */ + disabledServers: z.array(z.string()).optional(), + /** Server names to explicitly enable for this workspace (overrides project-level disabled). */ + enabledServers: z.array(z.string()).optional(), + + /** + * Per-server tool allowlist. + * Key: server name (from .mux/mcp.jsonc) + * Value: raw MCP tool names (NOT namespaced) + * + * If omitted for a server => expose all tools from that server. + * If present but empty => expose no tools from that server. + */ + toolAllowlist: z.record(z.string(), z.array(z.string())).optional(), +}); + export const MCPAddParamsSchema = z.object({ projectPath: z.string(), name: z.string(), @@ -19,9 +43,20 @@ export const MCPSetEnabledParamsSchema = z.object({ export const MCPServerMapSchema = z.record( z.string(), - z.object({ command: z.string(), disabled: z.boolean() }) + z.object({ + command: z.string(), + disabled: z.boolean(), + toolAllowlist: z.array(z.string()).optional(), + }) ); +export const MCPSetToolAllowlistParamsSchema = z.object({ + projectPath: z.string(), + name: z.string(), + /** Tool names to allow. Empty array = no tools allowed. */ + toolAllowlist: z.array(z.string()), +}); + /** * Unified test params - provide either name (to test configured server) or command (to test arbitrary command). * At least one of name or command must be provided. diff --git a/src/common/orpc/schemas/project.ts b/src/common/orpc/schemas/project.ts index e9915c8274..455e47210c 100644 --- a/src/common/orpc/schemas/project.ts +++ b/src/common/orpc/schemas/project.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { RuntimeConfigSchema } from "./runtime"; +import { WorkspaceMCPOverridesSchema } from "./mcp"; export const WorkspaceConfigSchema = z.object({ path: z.string().meta({ @@ -22,6 +23,9 @@ export const WorkspaceConfigSchema = z.object({ runtimeConfig: RuntimeConfigSchema.optional().meta({ description: "Runtime configuration (local vs SSH) - optional, defaults to local", }), + mcp: WorkspaceMCPOverridesSchema.optional().meta({ + description: "Per-workspace MCP overrides (disabled servers, tool allowlists)", + }), }); export const ProjectConfigSchema = z.object({ diff --git a/src/common/types/mcp.ts b/src/common/types/mcp.ts index 532c403fd5..07ad64be52 100644 --- a/src/common/types/mcp.ts +++ b/src/common/types/mcp.ts @@ -2,6 +2,12 @@ export interface MCPServerInfo { command: string; disabled: boolean; + /** + * Optional tool allowlist at project level. + * If set, only these tools are exposed from this server. + * If not set, all tools are exposed. + */ + toolAllowlist?: string[]; } export interface MCPConfig { @@ -19,3 +25,34 @@ export interface CachedMCPTestResult { result: MCPTestResult; testedAt: number; // Unix timestamp ms } + +/** + * Per-workspace MCP overrides. + * + * Stored in ~/.mux/config.json under each workspace entry. + * Allows workspaces to override project-level server enabled/disabled state + * and restrict tool allowlists. + */ +export interface WorkspaceMCPOverrides { + /** + * Server names to explicitly disable for this workspace. + * Overrides project-level enabled state. + */ + disabledServers?: string[]; + + /** + * Server names to explicitly enable for this workspace. + * Overrides project-level disabled state. + */ + enabledServers?: string[]; + + /** + * Per-server tool allowlist. + * Key: server name (from .mux/mcp.jsonc) + * Value: raw MCP tool names (NOT namespaced) + * + * If omitted for a server => expose all tools from that server. + * If present but empty => expose no tools from that server. + */ + toolAllowlist?: Record; +} diff --git a/src/node/config.test.ts b/src/node/config.test.ts index eacd60ae21..783c389668 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -148,4 +148,119 @@ describe("Config", () => { expect(workspace.createdAt).toBe("2025-01-01T00:00:00.000Z"); }); }); + + describe("workspace MCP overrides", () => { + it("should return undefined for non-existent workspace", () => { + const result = config.getWorkspaceMCPOverrides("non-existent-id"); + expect(result).toBeUndefined(); + }); + + it("should return undefined for workspace without MCP overrides", async () => { + const projectPath = "/fake/project"; + const workspacePath = path.join(config.srcDir, "project", "branch"); + + fs.mkdirSync(workspacePath, { recursive: true }); + + await config.editConfig((cfg) => { + cfg.projects.set(projectPath, { + workspaces: [{ path: workspacePath, id: "test-ws-id", name: "branch" }], + }); + return cfg; + }); + + const result = config.getWorkspaceMCPOverrides("test-ws-id"); + expect(result).toBeUndefined(); + }); + + it("should set and get MCP overrides for a workspace", async () => { + const projectPath = "/fake/project"; + const workspacePath = path.join(config.srcDir, "project", "branch"); + + fs.mkdirSync(workspacePath, { recursive: true }); + + await config.editConfig((cfg) => { + cfg.projects.set(projectPath, { + workspaces: [{ path: workspacePath, id: "test-ws-id", name: "branch" }], + }); + return cfg; + }); + + // Set overrides + await config.setWorkspaceMCPOverrides("test-ws-id", { + disabledServers: ["server-a", "server-b"], + toolAllowlist: { "server-c": ["tool1", "tool2"] }, + }); + + // Get overrides + const result = config.getWorkspaceMCPOverrides("test-ws-id"); + expect(result).toBeDefined(); + expect(result!.disabledServers).toEqual(["server-a", "server-b"]); + expect(result!.toolAllowlist).toEqual({ "server-c": ["tool1", "tool2"] }); + }); + + it("should remove MCP overrides when set to empty", async () => { + const projectPath = "/fake/project"; + const workspacePath = path.join(config.srcDir, "project", "branch"); + + fs.mkdirSync(workspacePath, { recursive: true }); + + await config.editConfig((cfg) => { + cfg.projects.set(projectPath, { + workspaces: [ + { + path: workspacePath, + id: "test-ws-id", + name: "branch", + mcp: { disabledServers: ["server-a"] }, + }, + ], + }); + return cfg; + }); + + // Clear overrides + await config.setWorkspaceMCPOverrides("test-ws-id", {}); + + // Verify overrides are removed + const result = config.getWorkspaceMCPOverrides("test-ws-id"); + expect(result).toBeUndefined(); + + // Verify workspace still exists + const configData = config.loadConfigOrDefault(); + const projectConfig = configData.projects.get(projectPath); + expect(projectConfig!.workspaces[0].id).toBe("test-ws-id"); + expect(projectConfig!.workspaces[0].mcp).toBeUndefined(); + }); + + it("should deduplicate disabledServers", async () => { + const projectPath = "/fake/project"; + const workspacePath = path.join(config.srcDir, "project", "branch"); + + fs.mkdirSync(workspacePath, { recursive: true }); + + await config.editConfig((cfg) => { + cfg.projects.set(projectPath, { + workspaces: [{ path: workspacePath, id: "test-ws-id", name: "branch" }], + }); + return cfg; + }); + + // Set with duplicates + await config.setWorkspaceMCPOverrides("test-ws-id", { + disabledServers: ["server-a", "server-b", "server-a"], + }); + + // Verify duplicates are removed + const result = config.getWorkspaceMCPOverrides("test-ws-id"); + expect(result!.disabledServers).toHaveLength(2); + expect(result!.disabledServers).toContain("server-a"); + expect(result!.disabledServers).toContain("server-b"); + }); + + it("should throw error when setting overrides for non-existent workspace", async () => { + await expect( + config.setWorkspaceMCPOverrides("non-existent-id", { disabledServers: ["server-a"] }) + ).rejects.toThrow("Workspace non-existent-id not found in config"); + }); + }); }); diff --git a/src/node/config.ts b/src/node/config.ts index 122ed4fd9d..3cec6d9403 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -489,6 +489,65 @@ export class Config { }); } + /** + * Get MCP overrides for a workspace. + * Returns undefined if workspace not found or no overrides set. + */ + getWorkspaceMCPOverrides(workspaceId: string): Workspace["mcp"] | undefined { + const config = this.loadConfigOrDefault(); + for (const [_projectPath, projectConfig] of config.projects) { + const workspace = projectConfig.workspaces.find((w) => w.id === workspaceId); + if (workspace) { + return workspace.mcp; + } + } + return undefined; + } + + /** + * Set MCP overrides for a workspace. + * @throws Error if workspace not found + */ + async setWorkspaceMCPOverrides(workspaceId: string, overrides: Workspace["mcp"]): Promise { + await this.editConfig((config) => { + for (const [_projectPath, projectConfig] of config.projects) { + const workspace = projectConfig.workspaces.find((w) => w.id === workspaceId); + if (workspace) { + // Normalize: remove empty arrays to keep config clean + const normalized = overrides + ? { + disabledServers: + overrides.disabledServers && overrides.disabledServers.length > 0 + ? [...new Set(overrides.disabledServers)] // De-duplicate + : undefined, + enabledServers: + overrides.enabledServers && overrides.enabledServers.length > 0 + ? [...new Set(overrides.enabledServers)] // De-duplicate + : undefined, + toolAllowlist: + overrides.toolAllowlist && Object.keys(overrides.toolAllowlist).length > 0 + ? overrides.toolAllowlist + : undefined, + } + : undefined; + + // Remove mcp field entirely if no overrides + if ( + !normalized?.disabledServers && + !normalized?.enabledServers && + !normalized?.toolAllowlist + ) { + delete workspace.mcp; + } else { + workspace.mcp = normalized; + } + return config; + } + } + throw new Error(`Workspace ${workspaceId} not found in config`); + }); + } + /** * Load providers configuration from JSONC file * Supports comments in JSONC format diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index c2789df4f7..e3630aed6b 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -265,6 +265,16 @@ export const router = (authToken?: string) => { .handler(({ context, input }) => context.mcpConfigService.setServerEnabled(input.projectPath, input.name, input.enabled) ), + setToolAllowlist: t + .input(schemas.projects.mcp.setToolAllowlist.input) + .output(schemas.projects.mcp.setToolAllowlist.output) + .handler(({ context, input }) => + context.mcpConfigService.setToolAllowlist( + input.projectPath, + input.name, + input.toolAllowlist + ) + ), }, }, nameGeneration: { @@ -740,6 +750,28 @@ export const router = (authToken?: string) => { .handler(async ({ context, input }) => { return context.sessionUsageService.getSessionUsage(input.workspaceId); }), + mcp: { + get: t + .input(schemas.workspace.mcp.get.input) + .output(schemas.workspace.mcp.get.output) + .handler(({ context, input }) => { + const overrides = context.config.getWorkspaceMCPOverrides(input.workspaceId); + // Return empty object if no overrides (matches schema default) + return overrides ?? {}; + }), + set: t + .input(schemas.workspace.mcp.set.input) + .output(schemas.workspace.mcp.set.output) + .handler(async ({ context, input }) => { + try { + await context.config.setWorkspaceMCPOverrides(input.workspaceId, input.overrides); + return { success: true, data: undefined }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: message }; + } + }), + }, }, window: { setTitle: t diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 5c230b9dc8..239bb47a86 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -1040,9 +1040,13 @@ export class AIService extends EventEmitter { ? metadata.projectPath : runtime.getWorkspacePath(metadata.projectPath, metadata.name); + // Fetch workspace MCP overrides (for filtering servers and tools) + const mcpOverrides = this.config.getWorkspaceMCPOverrides(workspaceId); + // Fetch MCP server config for system prompt (before building message) + // Pass overrides to filter out disabled servers const mcpServers = this.mcpServerManager - ? await this.mcpServerManager.listServers(metadata.projectPath) + ? await this.mcpServerManager.listServers(metadata.projectPath, mcpOverrides) : undefined; // Construct plan mode instruction if in plan mode @@ -1164,6 +1168,7 @@ export class AIService extends EventEmitter { projectPath: metadata.projectPath, runtime, workspacePath, + overrides: mcpOverrides, }); } catch (error) { log.error("Failed to start MCP servers", { workspaceId, error }); diff --git a/src/node/services/mcpConfigService.test.ts b/src/node/services/mcpConfigService.test.ts index 9107d9457d..b53a9776c9 100644 --- a/src/node/services/mcpConfigService.test.ts +++ b/src/node/services/mcpConfigService.test.ts @@ -4,6 +4,7 @@ import * as path from "path"; import * as os from "os"; import { MCPConfigService } from "./mcpConfigService"; import { MCPServerManager } from "./mcpServerManager"; +import type { WorkspaceMCPOverrides } from "@/common/types/mcp"; describe("MCP server disable filtering", () => { let tempDir: string; @@ -41,3 +42,101 @@ describe("MCP server disable filtering", () => { expect(enabledServers).toEqual({ "enabled-server": "cmd1" }); }); }); + +describe("Workspace MCP overrides filtering", () => { + let tempDir: string; + let configService: MCPConfigService; + let serverManager: MCPServerManager; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mcp-test-")); + configService = new MCPConfigService(); + serverManager = new MCPServerManager(configService); + + // Set up multiple servers for testing + await configService.addServer(tempDir, "server-a", "cmd-a"); + await configService.addServer(tempDir, "server-b", "cmd-b"); + await configService.addServer(tempDir, "server-c", "cmd-c"); + }); + + afterEach(async () => { + serverManager.dispose(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + test("listServers with no overrides returns all enabled servers", async () => { + const servers = await serverManager.listServers(tempDir); + expect(servers).toEqual({ + "server-a": "cmd-a", + "server-b": "cmd-b", + "server-c": "cmd-c", + }); + }); + + test("listServers with empty overrides returns all enabled servers", async () => { + const overrides: WorkspaceMCPOverrides = {}; + const servers = await serverManager.listServers(tempDir, overrides); + expect(servers).toEqual({ + "server-a": "cmd-a", + "server-b": "cmd-b", + "server-c": "cmd-c", + }); + }); + + test("listServers with disabledServers filters out disabled servers", async () => { + const overrides: WorkspaceMCPOverrides = { + disabledServers: ["server-a", "server-c"], + }; + const servers = await serverManager.listServers(tempDir, overrides); + expect(servers).toEqual({ "server-b": "cmd-b" }); + }); + + test("listServers with disabledServers removes servers not in config (no error)", async () => { + const overrides: WorkspaceMCPOverrides = { + disabledServers: ["non-existent-server"], + }; + const servers = await serverManager.listServers(tempDir, overrides); + expect(servers).toEqual({ + "server-a": "cmd-a", + "server-b": "cmd-b", + "server-c": "cmd-c", + }); + }); + + test("enabledServers overrides project-level disabled", async () => { + // Disable server-a at project level + await configService.setServerEnabled(tempDir, "server-a", false); + + // Without override, server-a should be disabled + const serversWithoutOverride = await serverManager.listServers(tempDir); + expect(serversWithoutOverride).toEqual({ + "server-b": "cmd-b", + "server-c": "cmd-c", + }); + + // With enabledServers override, server-a should be re-enabled + const overrides: WorkspaceMCPOverrides = { + enabledServers: ["server-a"], + }; + const serversWithOverride = await serverManager.listServers(tempDir, overrides); + expect(serversWithOverride).toEqual({ + "server-a": "cmd-a", + "server-b": "cmd-b", + "server-c": "cmd-c", + }); + }); + + test("project-disabled and workspace-disabled work together", async () => { + // Disable server-a at project level + await configService.setServerEnabled(tempDir, "server-a", false); + + // Disable server-b at workspace level + const overrides: WorkspaceMCPOverrides = { + disabledServers: ["server-b"], + }; + + const servers = await serverManager.listServers(tempDir, overrides); + // Only server-c should remain + expect(servers).toEqual({ "server-c": "cmd-c" }); + }); +}); diff --git a/src/node/services/mcpConfigService.ts b/src/node/services/mcpConfigService.ts index b6a39e9681..75bbbb76e2 100644 --- a/src/node/services/mcpConfigService.ts +++ b/src/node/services/mcpConfigService.ts @@ -29,11 +29,17 @@ export class MCPConfigService { } /** Raw config file format - string (legacy) or object */ - private normalizeEntry(entry: string | { command: string; disabled?: boolean }): MCPServerInfo { + private normalizeEntry( + entry: string | { command: string; disabled?: boolean; toolAllowlist?: string[] } + ): MCPServerInfo { if (typeof entry === "string") { return { command: entry, disabled: false }; } - return { command: entry.command, disabled: entry.disabled ?? false }; + return { + command: entry.command, + disabled: entry.disabled ?? false, + toolAllowlist: entry.toolAllowlist, + }; } async getConfig(projectPath: string): Promise { @@ -65,10 +71,26 @@ export class MCPConfigService { private async saveConfig(projectPath: string, config: MCPConfig): Promise { await this.ensureProjectDir(projectPath); const filePath = this.getConfigPath(projectPath); - // Write minimal format: string for enabled, object only when disabled - const output: Record = {}; + // Write minimal format: string for simple enabled servers, object when has settings + // toolAllowlist: undefined = all tools (omit), [] = no tools, [...] = those tools + const output: Record< + string, + string | { command: string; disabled?: true; toolAllowlist?: string[] } + > = {}; for (const [name, entry] of Object.entries(config.servers)) { - output[name] = entry.disabled ? { command: entry.command, disabled: true } : entry.command; + const hasSettings = entry.disabled || entry.toolAllowlist !== undefined; + if (hasSettings) { + const obj: { command: string; disabled?: true; toolAllowlist?: string[] } = { + command: entry.command, + }; + if (entry.disabled) obj.disabled = true; + if (entry.toolAllowlist !== undefined) { + obj.toolAllowlist = entry.toolAllowlist; + } + output[name] = obj; + } else { + output[name] = entry.command; + } } await writeFileAtomic(filePath, JSON.stringify({ servers: output }, null, 2), "utf-8"); } @@ -89,8 +111,12 @@ export class MCPConfigService { const cfg = await this.getConfig(projectPath); const existing = cfg.servers[name]; - // Preserve disabled state if updating existing server - cfg.servers[name] = { command, disabled: existing?.disabled ?? false }; + // Preserve disabled state and toolAllowlist if updating existing server + cfg.servers[name] = { + command, + disabled: existing?.disabled ?? false, + toolAllowlist: existing?.toolAllowlist, + }; try { await this.saveConfig(projectPath, cfg); @@ -135,4 +161,28 @@ export class MCPConfigService { return Err(error instanceof Error ? error.message : String(error)); } } + + async setToolAllowlist( + projectPath: string, + name: string, + toolAllowlist: string[] + ): Promise> { + const cfg = await this.getConfig(projectPath); + const entry = cfg.servers[name]; + if (!entry) { + return Err(`Server ${name} not found`); + } + // [] = no tools allowed, [...tools] = those tools allowed + cfg.servers[name] = { + ...entry, + toolAllowlist, + }; + try { + await this.saveConfig(projectPath, cfg); + return Ok(undefined); + } catch (error) { + log.error("Failed to update MCP server tool allowlist", { projectPath, name, error }); + return Err(error instanceof Error ? error.message : String(error)); + } + } } diff --git a/src/node/services/mcpServerManager.ts b/src/node/services/mcpServerManager.ts index 38494b31b7..2e7b30616b 100644 --- a/src/node/services/mcpServerManager.ts +++ b/src/node/services/mcpServerManager.ts @@ -2,7 +2,12 @@ import { experimental_createMCPClient, type MCPTransport } from "@ai-sdk/mcp"; import type { Tool } from "ai"; import { log } from "@/node/services/log"; import { MCPStdioTransport } from "@/node/services/mcpStdioTransport"; -import type { MCPServerMap, MCPTestResult } from "@/common/types/mcp"; +import type { + MCPServerInfo, + MCPServerMap, + MCPTestResult, + WorkspaceMCPOverrides, +} from "@/common/types/mcp"; import type { Runtime } from "@/node/runtime/Runtime"; import type { MCPConfigService } from "@/node/services/mcpConfigService"; import { createRuntime } from "@/node/runtime/runtimeFactory"; @@ -149,31 +154,127 @@ export class MCPServerManager { } /** - * Get merged servers: config file servers (unless ignoreConfigFile) + inline servers. - * Inline servers take precedence over config file servers with the same name. - * Filters out disabled servers. + * Get all servers from config (both enabled and disabled) + inline servers. + * Returns full MCPServerInfo to preserve disabled state. */ - private async getMergedServers(projectPath: string): Promise { - const allServers = this.ignoreConfigFile + private async getAllServers(projectPath: string): Promise> { + const configServers = this.ignoreConfigFile ? {} : await this.configService.listServers(projectPath); - // Filter to enabled servers only, extract command strings - const configServers: MCPServerMap = {}; - for (const [name, entry] of Object.entries(allServers)) { - if (!entry.disabled) { - configServers[name] = entry.command; - } + // Inline servers override config file servers (always enabled) + const inlineAsInfo: Record = {}; + for (const [name, command] of Object.entries(this.inlineServers)) { + inlineAsInfo[name] = { command, disabled: false }; } - // Inline servers override config file servers - return { ...configServers, ...this.inlineServers }; + return { ...configServers, ...inlineAsInfo }; } /** * List configured MCP servers for a project (name -> command). * Used to show server info in the system prompt. + * + * Applies both project-level disabled state and workspace-level overrides: + * - Project disabled + workspace enabled => enabled + * - Project enabled + workspace disabled => disabled + * - No workspace override => use project state + * + * @param projectPath - Project path to get servers for + * @param overrides - Optional workspace-level overrides + */ + async listServers(projectPath: string, overrides?: WorkspaceMCPOverrides): Promise { + const allServers = await this.getAllServers(projectPath); + return this.applyServerOverrides(allServers, overrides); + } + + /** + * Apply workspace MCP overrides to determine final server enabled state. + * + * Logic: + * - If server is in enabledServers: enabled (overrides project disabled) + * - If server is in disabledServers: disabled (overrides project enabled) + * - Otherwise: use project-level disabled state + */ + private applyServerOverrides( + servers: Record, + overrides?: WorkspaceMCPOverrides + ): MCPServerMap { + const enabledSet = new Set(overrides?.enabledServers ?? []); + const disabledSet = new Set(overrides?.disabledServers ?? []); + + const result: MCPServerMap = {}; + for (const [name, info] of Object.entries(servers)) { + // Workspace overrides take precedence + if (enabledSet.has(name)) { + result[name] = info.command; // Explicitly enabled at workspace level + } else if (disabledSet.has(name)) { + // Explicitly disabled at workspace level - skip + continue; + } else if (!info.disabled) { + result[name] = info.command; // Enabled at project level, no workspace override + } + // If disabled at project level with no workspace override, skip + } + return result; + } + + /** + * Apply tool allowlists to filter tools from a server. + * Project-level allowlist is applied first, then workspace-level (intersection). + * + * @param serverName - Name of the MCP server (used for allowlist lookup) + * @param tools - Record of tool name -> Tool (NOT namespaced) + * @param projectAllowlist - Optional project-level tool allowlist (from .mux/mcp.jsonc) + * @param workspaceOverrides - Optional workspace MCP overrides containing toolAllowlist + * @returns Filtered tools record */ - async listServers(projectPath: string): Promise { - return this.getMergedServers(projectPath); + private applyToolAllowlist( + serverName: string, + tools: Record, + projectAllowlist?: string[], + workspaceOverrides?: WorkspaceMCPOverrides + ): Record { + const workspaceAllowlist = workspaceOverrides?.toolAllowlist?.[serverName]; + + // Determine effective allowlist: + // - If both exist: intersection (workspace restricts further) + // - If only project: use project + // - If only workspace: use workspace + // - If neither: no filtering + let effectiveAllowlist: Set | null = null; + + if (projectAllowlist && projectAllowlist.length > 0 && workspaceAllowlist) { + // Intersection of both allowlists + const projectSet = new Set(projectAllowlist); + effectiveAllowlist = new Set(workspaceAllowlist.filter((t) => projectSet.has(t))); + } else if (projectAllowlist && projectAllowlist.length > 0) { + effectiveAllowlist = new Set(projectAllowlist); + } else if (workspaceAllowlist) { + effectiveAllowlist = new Set(workspaceAllowlist); + } + + if (!effectiveAllowlist) { + // No allowlist => return all tools + return tools; + } + + // Filter to only allowed tools + const filtered: Record = {}; + for (const [name, tool] of Object.entries(tools)) { + if (effectiveAllowlist.has(name)) { + filtered[name] = tool; + } + } + + log.debug("[MCP] Applied tool allowlist", { + serverName, + projectAllowlist, + workspaceAllowlist, + effectiveCount: effectiveAllowlist.size, + originalCount: Object.keys(tools).length, + filteredCount: Object.keys(filtered).length, + }); + + return filtered; } async getToolsForWorkspace(options: { @@ -181,9 +282,16 @@ export class MCPServerManager { projectPath: string; runtime: Runtime; workspacePath: string; + /** Per-workspace MCP overrides (disabled servers, tool allowlists) */ + overrides?: WorkspaceMCPOverrides; }): Promise> { - const { workspaceId, projectPath, runtime, workspacePath } = options; - const servers = await this.getMergedServers(projectPath); + const { workspaceId, projectPath, runtime, workspacePath, overrides } = options; + + // Fetch full server info for project-level allowlists and server filtering + const fullServerInfo = await this.getAllServers(projectPath); + + // Apply server-level overrides (enabled/disabled) before caching + const servers = this.applyServerOverrides(fullServerInfo, overrides); const signature = JSON.stringify(servers); const serverNames = Object.keys(servers); @@ -192,7 +300,8 @@ export class MCPServerManager { // Update activity timestamp to prevent idle cleanup existing.lastActivity = Date.now(); log.debug("[MCP] Using cached servers", { workspaceId, serverCount: serverNames.length }); - return this.collectTools(existing.instances); + // Apply tool-level filtering (allowlists) each time - they can change without server restart + return this.collectTools(existing.instances, fullServerInfo, overrides); } // Config changed or not started yet -> restart @@ -206,7 +315,7 @@ export class MCPServerManager { instances, lastActivity: Date.now(), }); - return this.collectTools(instances); + return this.collectTools(instances, fullServerInfo, overrides); } async stopServers(workspaceId: string): Promise { @@ -244,10 +353,31 @@ export class MCPServerManager { return { success: false, error: "Either name or command is required" }; } - private collectTools(instances: Map): Record { + /** + * Collect tools from all server instances, applying tool allowlists. + * + * @param instances - Map of server instances + * @param serverInfo - Project-level server info (for project-level tool allowlists) + * @param workspaceOverrides - Optional workspace MCP overrides for tool allowlists + * @returns Aggregated tools record with namespaced names (serverName_toolName) + */ + private collectTools( + instances: Map, + serverInfo: Record, + workspaceOverrides?: WorkspaceMCPOverrides + ): Record { const aggregated: Record = {}; for (const instance of instances.values()) { - for (const [toolName, tool] of Object.entries(instance.tools)) { + // Get project-level allowlist for this server + const projectAllowlist = serverInfo[instance.name]?.toolAllowlist; + // Apply tool allowlist filtering (project-level + workspace-level) + const filteredTools = this.applyToolAllowlist( + instance.name, + instance.tools, + projectAllowlist, + workspaceOverrides + ); + for (const [toolName, tool] of Object.entries(filteredTools)) { // Namespace tools with server name to prevent collisions const namespacedName = `${instance.name}_${toolName}`; aggregated[namespacedName] = tool; From a6ee0ab307c0bb6410c00956cf8193028478ce3e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 15 Dec 2025 19:25:48 +0100 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=A4=96=20test:=20add=20storybook=20st?= =?UTF-8?q?ories=20for=20MCP=20configuration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added comprehensive storybook stories for MCP configuration: - ProjectSettingsEmpty: No MCP servers configured - ProjectSettingsWithServers: Multiple servers configured - ProjectSettingsMixedState: Mix of enabled/disabled servers - ProjectSettingsWithToolAllowlist: Servers with tool filtering - WorkspaceMCPNoOverrides: Default state, inherits project settings - WorkspaceMCPProjectDisabledServer: Server disabled at project level - WorkspaceMCPEnabledOverride: Project-disabled server enabled at workspace - WorkspaceMCPDisabledOverride: Project-enabled server disabled at workspace - WorkspaceMCPWithToolAllowlist: Workspace-level tool filtering - ToolSelectorInteraction: Tests All/None bulk selection buttons - ToggleServerEnabled: Tests toggling server enabled state Also added: - MCP mock endpoints to .storybook/mocks/orpc.ts - Test IDs to WorkspaceHeader for story automation Signed-off-by: Thomas Kosiewski --- _Generated with mux_ Change-Id: Ic01bbfe0568b2eafba6e7d2e5568a34c30316354 --- .storybook/mocks/orpc.ts | 33 ++ .../sections/ProjectSettingsSection.tsx | 4 +- src/browser/components/WorkspaceHeader.tsx | 6 +- src/browser/stories/App.mcp.stories.tsx | 522 ++++++++++++++++++ 4 files changed, 563 insertions(+), 2 deletions(-) create mode 100644 src/browser/stories/App.mcp.stories.tsx diff --git a/.storybook/mocks/orpc.ts b/.storybook/mocks/orpc.ts index 1e9064aee2..bd50557ead 100644 --- a/.storybook/mocks/orpc.ts +++ b/.storybook/mocks/orpc.ts @@ -70,6 +70,18 @@ export interface MockORPCClientOptions { >; /** Session usage data per workspace (for Costs tab) */ sessionUsage?: Map; + /** MCP server configuration per project */ + mcpServers?: Map< + string, + Record + >; + /** MCP workspace overrides per workspace */ + mcpOverrides?: Map< + string, + { disabledServers?: string[]; enabledServers?: string[]; toolAllowlist?: Record } + >; + /** MCP test results - maps server name to tools list or error */ + mcpTestResults?: Map; } /** @@ -100,6 +112,9 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl onProjectRemove, backgroundProcesses = new Map(), sessionUsage = new Map(), + mcpServers = new Map(), + mcpOverrides = new Map(), + mcpTestResults = new Map(), } = options; const workspaceMap = new Map(workspaces.map((w) => [w.id, w])); @@ -159,6 +174,20 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl get: async () => [], update: async () => ({ success: true, data: undefined }), }, + mcp: { + list: async (input: { projectPath: string }) => mcpServers.get(input.projectPath) ?? {}, + add: async () => ({ success: true, data: undefined }), + remove: async () => ({ success: true, data: undefined }), + test: async (input: { projectPath: string; name?: string }) => { + if (input.name && mcpTestResults.has(input.name)) { + return mcpTestResults.get(input.name)!; + } + // Default: return empty tools + return { success: true, tools: [] }; + }, + setEnabled: async () => ({ success: true, data: undefined }), + setToolAllowlist: async () => ({ success: true, data: undefined }), + }, }, workspace: { list: async () => workspaces, @@ -239,6 +268,10 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl sendToBackground: async () => ({ success: true, data: undefined }), }, getSessionUsage: async (input: { workspaceId: string }) => sessionUsage.get(input.workspaceId), + mcp: { + get: async (input: { workspaceId: string }) => mcpOverrides.get(input.workspaceId) ?? {}, + set: async () => ({ success: true, data: undefined }), + }, }, window: { setTitle: async () => undefined, diff --git a/src/browser/components/Settings/sections/ProjectSettingsSection.tsx b/src/browser/components/Settings/sections/ProjectSettingsSection.tsx index f0b6d46eab..052c634992 100644 --- a/src/browser/components/Settings/sections/ProjectSettingsSection.tsx +++ b/src/browser/components/Settings/sections/ProjectSettingsSection.tsx @@ -143,7 +143,9 @@ const ToolAllowlistSection: React.FC<{ className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs" > {expanded ? : } - Tools: {localAllowlist.length}/{availableTools.length} + + Tools: {localAllowlist.length}/{availableTools.length} + ({formatRelativeTime(testedAt)}) {saving && } diff --git a/src/browser/components/WorkspaceHeader.tsx b/src/browser/components/WorkspaceHeader.tsx index 3fe9c83ca0..f902da58b2 100644 --- a/src/browser/components/WorkspaceHeader.tsx +++ b/src/browser/components/WorkspaceHeader.tsx @@ -68,7 +68,10 @@ export const WorkspaceHeader: React.FC = ({ }, [startTutorial, isSequenceCompleted]); return ( -
+
= ({ size="icon" onClick={() => setMcpModalOpen(true)} className="text-muted hover:text-foreground h-6 w-6 shrink-0" + data-testid="workspace-mcp-button" > diff --git a/src/browser/stories/App.mcp.stories.tsx b/src/browser/stories/App.mcp.stories.tsx new file mode 100644 index 0000000000..0a3a599c2c --- /dev/null +++ b/src/browser/stories/App.mcp.stories.tsx @@ -0,0 +1,522 @@ +/** + * MCP (Model Context Protocol) configuration stories + * + * Shows different states and interactions for MCP server configuration: + * - Project-level settings (enable/disable servers, tool allowlists) + * - Workspace-level overrides (enable disabled servers, disable enabled servers) + * - Tool selection with All/None bulk actions + * + * Uses play functions to navigate to settings and interact with the UI. + */ + +import type { APIClient } from "@/browser/contexts/API"; +import { appMeta, AppWithMocks, type AppStory } from "./meta.js"; +import { createWorkspace, groupWorkspacesByProject } from "./mockFactory"; +import { selectWorkspace } from "./storyHelpers"; +import { createMockORPCClient } from "../../../.storybook/mocks/orpc"; +import { within, userEvent, expect } from "@storybook/test"; +import { getMCPTestResultsKey } from "@/common/constants/storage"; + +export default { + ...appMeta, + title: "App/MCP", +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST DATA +// ═══════════════════════════════════════════════════════════════════════════════ + +const MOCK_TOOLS = [ + "file_read", + "file_write", + "bash", + "web_search", + "web_fetch", + "todo_write", + "todo_read", + "status_set", +]; + +const POSTHOG_TOOLS = [ + "add-insight-to-dashboard", + "dashboard-create", + "dashboard-delete", + "dashboard-get", + "dashboards-get-all", + "dashboard-update", + "docs-search", + "error-details", + "list-errors", + "create-feature-flag", + "delete-feature-flag", + "feature-flag-get-all", + "experiment-get-all", + "experiment-create", +]; + +// ═══════════════════════════════════════════════════════════════════════════════ +// HELPERS +// ═══════════════════════════════════════════════════════════════════════════════ + +interface MCPStoryOptions { + /** MCP servers configured at project level */ + servers?: Record; + /** Workspace-level MCP overrides */ + workspaceOverrides?: { + disabledServers?: string[]; + enabledServers?: string[]; + toolAllowlist?: Record; + }; + /** Test results for each server (tools available) */ + testResults?: Record; + /** Pre-cache test results in localStorage */ + preCacheTools?: boolean; +} + +function setupMCPStory(options: MCPStoryOptions = {}): APIClient { + const projectPath = "/Users/test/my-app"; + const workspaceId = "ws-mcp-test"; + const workspaces = [ + createWorkspace({ + id: workspaceId, + name: "main", + projectName: "my-app", + projectPath, + }), + ]; + + selectWorkspace(workspaces[0]); + + // Pre-cache tool test results if requested + if (options.preCacheTools && options.testResults) { + const cacheKey = getMCPTestResultsKey(projectPath); + const cacheData: Record< + string, + { result: { success: true; tools: string[] }; testedAt: number } + > = {}; + for (const [serverName, tools] of Object.entries(options.testResults)) { + cacheData[serverName] = { + result: { success: true, tools }, + testedAt: Date.now(), + }; + } + localStorage.setItem(cacheKey, JSON.stringify(cacheData)); + } + + // Build mock data + const mcpServers = new Map< + string, + Record + >(); + if (options.servers) { + mcpServers.set(projectPath, options.servers); + } + + const mcpOverrides = new Map< + string, + { + disabledServers?: string[]; + enabledServers?: string[]; + toolAllowlist?: Record; + } + >(); + if (options.workspaceOverrides) { + mcpOverrides.set(workspaceId, options.workspaceOverrides); + } + + const mcpTestResults = new Map(); + if (options.testResults) { + for (const [serverName, tools] of Object.entries(options.testResults)) { + mcpTestResults.set(serverName, { success: true, tools }); + } + } + + return createMockORPCClient({ + projects: groupWorkspacesByProject(workspaces), + workspaces, + mcpServers, + mcpOverrides, + mcpTestResults, + }); +} + +/** Open settings modal and navigate to Projects section */ +async function openProjectSettings(canvasElement: HTMLElement): Promise { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + + const settingsButton = await canvas.findByTestId("settings-button", {}, { timeout: 10000 }); + await userEvent.click(settingsButton); + + await body.findByRole("dialog"); + + const projectsButton = await body.findByRole("button", { name: /Projects/i }); + await userEvent.click(projectsButton); +} + +/** Open the workspace MCP modal */ +async function openWorkspaceMCPModal(canvasElement: HTMLElement): Promise { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + + // Wait for workspace header to load + await canvas.findByTestId("workspace-header", {}, { timeout: 10000 }); + + // Click the MCP server button in the header + const mcpButton = await canvas.findByTestId("workspace-mcp-button"); + await userEvent.click(mcpButton); + + // Wait for dialog + await body.findByRole("dialog"); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// PROJECT SETTINGS STORIES +// ═══════════════════════════════════════════════════════════════════════════════ + +/** Project settings with no MCP servers configured */ +export const ProjectSettingsEmpty: AppStory = { + render: () => setupMCPStory({})} />, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await openProjectSettings(canvasElement); + }, +}; + +/** Project settings with MCP servers configured (all enabled) */ +export const ProjectSettingsWithServers: AppStory = { + render: () => ( + + setupMCPStory({ + servers: { + mux: { command: "npx -y @anthropics/mux-server", disabled: false }, + posthog: { command: "npx -y posthog-mcp-server", disabled: false }, + filesystem: { command: "npx -y @anthropics/filesystem-server /tmp", disabled: false }, + }, + testResults: { + mux: MOCK_TOOLS, + posthog: POSTHOG_TOOLS, + filesystem: ["read_file", "write_file", "list_directory"], + }, + preCacheTools: true, + }) + } + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await openProjectSettings(canvasElement); + + // Verify servers are shown + const body = within(canvasElement.ownerDocument.body); + await body.findByText("mux"); + await body.findByText("posthog"); + await body.findByText("filesystem"); + }, +}; + +/** Project settings with a mix of enabled and disabled servers */ +export const ProjectSettingsMixedState: AppStory = { + render: () => ( + + setupMCPStory({ + servers: { + mux: { command: "npx -y @anthropics/mux-server", disabled: false }, + posthog: { command: "npx -y posthog-mcp-server", disabled: true }, + filesystem: { command: "npx -y @anthropics/filesystem-server /tmp", disabled: false }, + }, + testResults: { + mux: MOCK_TOOLS, + posthog: POSTHOG_TOOLS, + filesystem: ["read_file", "write_file", "list_directory"], + }, + preCacheTools: true, + }) + } + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await openProjectSettings(canvasElement); + + const body = within(canvasElement.ownerDocument.body); + + // posthog should show as disabled + await body.findByText("posthog"); + // The switch should be off for posthog + }, +}; + +/** Project settings showing tool allowlist (tools filtered) */ +export const ProjectSettingsWithToolAllowlist: AppStory = { + render: () => ( + + setupMCPStory({ + servers: { + mux: { + command: "npx -y @anthropics/mux-server", + disabled: false, + toolAllowlist: ["file_read", "file_write", "bash"], + }, + }, + testResults: { + mux: MOCK_TOOLS, + }, + preCacheTools: true, + }) + } + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await openProjectSettings(canvasElement); + + const body = within(canvasElement.ownerDocument.body); + await body.findByText("mux"); + + // Should show "3/8" tools indicator (3 allowed out of 8 total) + await body.findByText(/3\/8/); + }, +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// WORKSPACE MCP MODAL STORIES +// ═══════════════════════════════════════════════════════════════════════════════ + +/** Workspace MCP modal with servers from project (no overrides) */ +export const WorkspaceMCPNoOverrides: AppStory = { + render: () => ( + + setupMCPStory({ + servers: { + mux: { command: "npx -y @anthropics/mux-server", disabled: false }, + posthog: { command: "npx -y posthog-mcp-server", disabled: false }, + }, + testResults: { + mux: MOCK_TOOLS, + posthog: POSTHOG_TOOLS, + }, + preCacheTools: true, + }) + } + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await openWorkspaceMCPModal(canvasElement); + + const body = within(canvasElement.ownerDocument.body); + + // Both servers should be shown and enabled + await body.findByText("mux"); + await body.findByText("posthog"); + }, +}; + +/** Workspace MCP modal - server disabled at project level, can be enabled */ +export const WorkspaceMCPProjectDisabledServer: AppStory = { + render: () => ( + + setupMCPStory({ + servers: { + mux: { command: "npx -y @anthropics/mux-server", disabled: false }, + posthog: { command: "npx -y posthog-mcp-server", disabled: true }, + }, + testResults: { + mux: MOCK_TOOLS, + posthog: POSTHOG_TOOLS, + }, + preCacheTools: true, + }) + } + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await openWorkspaceMCPModal(canvasElement); + + const body = within(canvasElement.ownerDocument.body); + + // posthog should show "(disabled at project level)" but switch should still be toggleable + await body.findByText("posthog"); + await body.findByText(/disabled at project level/i); + }, +}; + +/** Workspace MCP modal - server disabled at project level, enabled at workspace level */ +export const WorkspaceMCPEnabledOverride: AppStory = { + render: () => ( + + setupMCPStory({ + servers: { + mux: { command: "npx -y @anthropics/mux-server", disabled: false }, + posthog: { command: "npx -y posthog-mcp-server", disabled: true }, + }, + workspaceOverrides: { + enabledServers: ["posthog"], + }, + testResults: { + mux: MOCK_TOOLS, + posthog: POSTHOG_TOOLS, + }, + preCacheTools: true, + }) + } + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await openWorkspaceMCPModal(canvasElement); + + const body = within(canvasElement.ownerDocument.body); + + // posthog should be enabled despite project-level disable + await body.findByText("posthog"); + await body.findByText(/disabled at project level/i); + + // The switch should be ON (enabled at workspace level) + }, +}; + +/** Workspace MCP modal - server enabled at project level, disabled at workspace level */ +export const WorkspaceMCPDisabledOverride: AppStory = { + render: () => ( + + setupMCPStory({ + servers: { + mux: { command: "npx -y @anthropics/mux-server", disabled: false }, + posthog: { command: "npx -y posthog-mcp-server", disabled: false }, + }, + workspaceOverrides: { + disabledServers: ["posthog"], + }, + testResults: { + mux: MOCK_TOOLS, + posthog: POSTHOG_TOOLS, + }, + preCacheTools: true, + }) + } + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await openWorkspaceMCPModal(canvasElement); + + const body = within(canvasElement.ownerDocument.body); + + // mux should be enabled, posthog should be disabled + await body.findByText("mux"); + await body.findByText("posthog"); + }, +}; + +/** Workspace MCP modal with tool allowlist filtering */ +export const WorkspaceMCPWithToolAllowlist: AppStory = { + render: () => ( + + setupMCPStory({ + servers: { + posthog: { command: "npx -y posthog-mcp-server", disabled: false }, + }, + workspaceOverrides: { + toolAllowlist: { + posthog: ["docs-search", "error-details", "list-errors"], + }, + }, + testResults: { + posthog: POSTHOG_TOOLS, + }, + preCacheTools: true, + }) + } + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await openWorkspaceMCPModal(canvasElement); + + const body = within(canvasElement.ownerDocument.body); + await body.findByText("posthog"); + + // Should show filtered tool count + await body.findByText(/3 of 14 tools enabled/i); + }, +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// INTERACTION STORIES +// ═══════════════════════════════════════════════════════════════════════════════ + +/** Interact with tool selector - click All/None buttons */ +export const ToolSelectorInteraction: AppStory = { + render: () => ( + + setupMCPStory({ + servers: { + mux: { command: "npx -y @anthropics/mux-server", disabled: false }, + }, + testResults: { + mux: MOCK_TOOLS, + }, + preCacheTools: true, + }) + } + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await openWorkspaceMCPModal(canvasElement); + + const body = within(canvasElement.ownerDocument.body); + + // Find the tool selector section + await body.findByText("mux"); + + // Click "None" to deselect all tools + const noneButton = await body.findByRole("button", { name: /^None$/i }); + await userEvent.click(noneButton); + + // Should now show "0 of X tools enabled" + await expect(body.findByText(/0 of \d+ tools enabled/i)).resolves.toBeInTheDocument(); + + // Click "All" to select all tools + const allButton = await body.findByRole("button", { name: /^All$/i }); + await userEvent.click(allButton); + }, +}; + +/** Toggle server enabled state in workspace modal */ +export const ToggleServerEnabled: AppStory = { + render: () => ( + + setupMCPStory({ + servers: { + mux: { command: "npx -y @anthropics/mux-server", disabled: false }, + posthog: { command: "npx -y posthog-mcp-server", disabled: false }, + }, + testResults: { + mux: MOCK_TOOLS, + posthog: POSTHOG_TOOLS, + }, + preCacheTools: true, + }) + } + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await openWorkspaceMCPModal(canvasElement); + + const body = within(canvasElement.ownerDocument.body); + + // Find the posthog server row + await body.findByText("posthog"); + + // Find all switches and click the second one (posthog) + const switches = await body.findAllByRole("switch"); + // posthog should be the second switch + if (switches.length >= 2) { + await userEvent.click(switches[1]); + } + }, +};