Skip to content

Commit b220762

Browse files
authored
🤖 feat: add per-workspace MCP server and tool configuration (#1180)
## Summary Adds per-workspace MCP server and tool configuration. Users can now customize which MCP servers are enabled and which tools are exposed for each workspace, without modifying the project-level `.mux/mcp.jsonc`. ## Changes ### Backend - **Types & Schemas**: Added `WorkspaceMCPOverrides` schema with `disabledServers` and `toolAllowlist` fields - **Config helpers**: `getWorkspaceMCPOverrides()` and `setWorkspaceMCPOverrides()` methods in Config class - **ORPC endpoints**: `workspace.mcp.get` and `workspace.mcp.set` for frontend access - **MCPServerManager**: Updated to filter servers and tools based on workspace overrides - **AIService**: Fetches and passes overrides when getting MCP tools ### Frontend - **WorkspaceMCPModal**: New modal component for per-workspace MCP configuration - **WorkspaceHeader**: Added Server button (⚙️) to open the modal - UI features: - Toggle servers enabled/disabled for the workspace - Fetch tools from each server - Checkbox allowlist to select which tools to expose - "Allow all" shortcut button ### Tests - 5 tests for workspace override filtering in MCPServerManager - 6 tests for workspace MCP override Config helpers ## How it works 1. **Server definitions remain per-project** in `.mux/mcp.jsonc` 2. **Per-workspace overrides** are stored in `~/.mux/config.json` under each workspace entry 3. Effective servers = (project servers) - (workspace `disabledServers`) 4. Effective tools = filtered by `toolAllowlist[server]` if set --- <details> <summary>📋 Implementation Plan</summary> # Plan: Selective MCP tools + per-workspace configuration ## Goals 1. **Selective MCP tools** — For each workspace, expose only an allowlisted subset of MCP tools to the model. 2. **Per-workspace MCP configuration** — Allow enabling/disabling MCP servers (and tool allowlists) **per workspace**, without impacting other workspaces. 3. **Backwards compatible** — Existing per-project `.mux/mcp.jsonc` continues to work unchanged. ## Proposed approach (recommended) ### 1) Keep server *definitions* per-project; add per-workspace *overrides* - **Per-project** (existing): `PROJECT_ROOT/.mux/mcp.jsonc` - Source of truth for **server commands**. - **Per-workspace** (new, stored in mux global config `~/.mux/config.json` under each workspace entry): - Which servers are enabled for that workspace. - Which tools are allowlisted (per server) for that workspace. This avoids needing to read/write config inside SSH workspaces (remote filesystem) and keeps the current "configure once per project" ergonomics, while still enabling workspace-specific behavior. ### 2) Workspace MCP override schema Add an optional `mcp` field on `WorkspaceConfigSchema`: ```ts // Stored under each workspace entry in ~/.mux/config.json export interface WorkspaceMCPOverrides { /** If set, these servers are disabled for this workspace (after project config). */ disabledServers?: string[]; /** * Optional per-server allowlist of tools. * 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. */ toolAllowlist?: Record<string, string[]>; } ``` Semantics: - Effective servers for a workspace = (project-config enabled servers) minus `disabledServers`. - Effective tools for a server: - If `toolAllowlist[server]` present => only expose those tool names. - Else => expose all tools from that server. ### 3) Runtime behavior changes - `MCPServerManager.getToolsForWorkspace(...)` filters the returned tool map using the workspace allowlists *before* it is merged into `getToolsForModel`. - `MCPServerManager.listServers(...)` gains a workspace-aware variant so the system prompt lists **only the servers enabled for that workspace**. - **No restart required** when only tool allowlists change (servers stay running; we just filter what we expose). </details> --- _Generated with `mux` • Model: `anthropic:claude-opus-4-5` • Thinking: `high`_
1 parent 9aee49b commit b220762

File tree

19 files changed

+1862
-83
lines changed

19 files changed

+1862
-83
lines changed

.storybook/mocks/orpc.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,18 @@ export interface MockORPCClientOptions {
7070
>;
7171
/** Session usage data per workspace (for Costs tab) */
7272
sessionUsage?: Map<string, MockSessionUsage>;
73+
/** MCP server configuration per project */
74+
mcpServers?: Map<
75+
string,
76+
Record<string, { command: string; disabled: boolean; toolAllowlist?: string[] }>
77+
>;
78+
/** MCP workspace overrides per workspace */
79+
mcpOverrides?: Map<
80+
string,
81+
{ disabledServers?: string[]; enabledServers?: string[]; toolAllowlist?: Record<string, string[]> }
82+
>;
83+
/** MCP test results - maps server name to tools list or error */
84+
mcpTestResults?: Map<string, { success: true; tools: string[] } | { success: false; error: string }>;
7385
}
7486

7587
/**
@@ -100,6 +112,9 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
100112
onProjectRemove,
101113
backgroundProcesses = new Map(),
102114
sessionUsage = new Map(),
115+
mcpServers = new Map(),
116+
mcpOverrides = new Map(),
117+
mcpTestResults = new Map(),
103118
} = options;
104119

105120
const workspaceMap = new Map(workspaces.map((w) => [w.id, w]));
@@ -159,6 +174,20 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
159174
get: async () => [],
160175
update: async () => ({ success: true, data: undefined }),
161176
},
177+
mcp: {
178+
list: async (input: { projectPath: string }) => mcpServers.get(input.projectPath) ?? {},
179+
add: async () => ({ success: true, data: undefined }),
180+
remove: async () => ({ success: true, data: undefined }),
181+
test: async (input: { projectPath: string; name?: string }) => {
182+
if (input.name && mcpTestResults.has(input.name)) {
183+
return mcpTestResults.get(input.name)!;
184+
}
185+
// Default: return empty tools
186+
return { success: true, tools: [] };
187+
},
188+
setEnabled: async () => ({ success: true, data: undefined }),
189+
setToolAllowlist: async () => ({ success: true, data: undefined }),
190+
},
162191
},
163192
workspace: {
164193
list: async () => workspaces,
@@ -239,6 +268,10 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
239268
sendToBackground: async () => ({ success: true, data: undefined }),
240269
},
241270
getSessionUsage: async (input: { workspaceId: string }) => sessionUsage.get(input.workspaceId),
271+
mcp: {
272+
get: async (input: { workspaceId: string }) => mcpOverrides.get(input.workspaceId) ?? {},
273+
set: async () => ({ success: true, data: undefined }),
274+
},
242275
},
243276
window: {
244277
setTitle: async () => undefined,

src/browser/components/AIView.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
540540
<WorkspaceHeader
541541
workspaceId={workspaceId}
542542
projectName={projectName}
543+
projectPath={projectPath}
543544
workspaceName={workspaceName}
544545
namedWorkspacePath={namedWorkspacePath}
545546
runtimeConfig={runtimeConfig}

src/browser/components/Settings/sections/ProjectSettingsSection.tsx

Lines changed: 138 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useCallback, useEffect, useMemo, useState } from "react";
1+
import React, { useCallback, useEffect, useState } from "react";
22
import { useAPI } from "@/browser/contexts/API";
33
import { useProjectContext } from "@/browser/contexts/ProjectContext";
44
import {
@@ -12,6 +12,8 @@ import {
1212
Pencil,
1313
Check,
1414
X,
15+
ChevronDown,
16+
ChevronRight,
1517
} from "lucide-react";
1618
import { Button } from "@/browser/components/ui/button";
1719
import {
@@ -26,57 +28,143 @@ import { Switch } from "@/browser/components/ui/switch";
2628
import { cn } from "@/common/lib/utils";
2729
import { formatRelativeTime } from "@/browser/utils/ui/dateTime";
2830
import type { CachedMCPTestResult, MCPServerInfo } from "@/common/types/mcp";
29-
import { getMCPTestResultsKey } from "@/common/constants/storage";
30-
import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState";
31+
import { useMCPTestCache } from "@/browser/hooks/useMCPTestCache";
32+
import { ToolSelector } from "@/browser/components/ToolSelector";
33+
34+
/** Component for managing tool allowlist for a single MCP server */
35+
const ToolAllowlistSection: React.FC<{
36+
serverName: string;
37+
availableTools: string[];
38+
currentAllowlist?: string[];
39+
testedAt: number;
40+
projectPath: string;
41+
}> = ({ serverName, availableTools, currentAllowlist, testedAt, projectPath }) => {
42+
const { api } = useAPI();
43+
const [expanded, setExpanded] = useState(false);
44+
const [saving, setSaving] = useState(false);
45+
// Always use an array internally - undefined from props means all tools allowed
46+
const [localAllowlist, setLocalAllowlist] = useState<string[]>(
47+
() => currentAllowlist ?? [...availableTools]
48+
);
3149

32-
type CachedResults = Record<string, CachedMCPTestResult>;
50+
// Sync local state when prop changes
51+
useEffect(() => {
52+
setLocalAllowlist(currentAllowlist ?? [...availableTools]);
53+
}, [currentAllowlist, availableTools]);
3354

34-
/** Hook to manage MCP test results with localStorage caching */
35-
function useMCPTestCache(projectPath: string) {
36-
const storageKey = useMemo(
37-
() => (projectPath ? getMCPTestResultsKey(projectPath) : ""),
38-
[projectPath]
39-
);
55+
const allAllowed = localAllowlist.length === availableTools.length;
56+
const allDisabled = localAllowlist.length === 0;
4057

41-
const [cache, setCache] = useState<CachedResults>(() =>
42-
storageKey ? readPersistedState<CachedResults>(storageKey, {}) : {}
43-
);
58+
const handleToggleTool = useCallback(
59+
async (toolName: string, allowed: boolean) => {
60+
if (!api) return;
4461

45-
// Reload cache when project changes
46-
useEffect(() => {
47-
if (storageKey) {
48-
setCache(readPersistedState<CachedResults>(storageKey, {}));
49-
} else {
50-
setCache({});
51-
}
52-
}, [storageKey]);
53-
54-
const setResult = useCallback(
55-
(name: string, result: CachedMCPTestResult["result"]) => {
56-
const entry: CachedMCPTestResult = { result, testedAt: Date.now() };
57-
setCache((prev) => {
58-
const next = { ...prev, [name]: entry };
59-
if (storageKey) updatePersistedState(storageKey, next);
60-
return next;
61-
});
62+
const newAllowlist = allowed
63+
? [...localAllowlist, toolName]
64+
: localAllowlist.filter((t) => t !== toolName);
65+
66+
// Optimistic update
67+
setLocalAllowlist(newAllowlist);
68+
setSaving(true);
69+
70+
try {
71+
const result = await api.projects.mcp.setToolAllowlist({
72+
projectPath,
73+
name: serverName,
74+
toolAllowlist: newAllowlist,
75+
});
76+
if (!result.success) {
77+
setLocalAllowlist(currentAllowlist ?? [...availableTools]);
78+
console.error("Failed to update tool allowlist:", result.error);
79+
}
80+
} catch (err) {
81+
setLocalAllowlist(currentAllowlist ?? [...availableTools]);
82+
console.error("Failed to update tool allowlist:", err);
83+
} finally {
84+
setSaving(false);
85+
}
6286
},
63-
[storageKey]
87+
[api, projectPath, serverName, localAllowlist, currentAllowlist, availableTools]
6488
);
6589

66-
const clearResult = useCallback(
67-
(name: string) => {
68-
setCache((prev) => {
69-
const next = { ...prev };
70-
delete next[name];
71-
if (storageKey) updatePersistedState(storageKey, next);
72-
return next;
90+
const handleAllowAll = useCallback(async () => {
91+
if (!api || allAllowed) return;
92+
93+
const newAllowlist = [...availableTools];
94+
setLocalAllowlist(newAllowlist);
95+
setSaving(true);
96+
97+
try {
98+
const result = await api.projects.mcp.setToolAllowlist({
99+
projectPath,
100+
name: serverName,
101+
toolAllowlist: newAllowlist,
73102
});
74-
},
75-
[storageKey]
76-
);
103+
if (!result.success) {
104+
setLocalAllowlist(currentAllowlist ?? [...availableTools]);
105+
console.error("Failed to clear tool allowlist:", result.error);
106+
}
107+
} catch (err) {
108+
setLocalAllowlist(currentAllowlist ?? [...availableTools]);
109+
console.error("Failed to clear tool allowlist:", err);
110+
} finally {
111+
setSaving(false);
112+
}
113+
}, [api, projectPath, serverName, allAllowed, currentAllowlist, availableTools]);
114+
115+
const handleSelectNone = useCallback(async () => {
116+
if (!api || allDisabled) return;
77117

78-
return { cache, setResult, clearResult };
79-
}
118+
setLocalAllowlist([]);
119+
setSaving(true);
120+
121+
try {
122+
const result = await api.projects.mcp.setToolAllowlist({
123+
projectPath,
124+
name: serverName,
125+
toolAllowlist: [],
126+
});
127+
if (!result.success) {
128+
setLocalAllowlist(currentAllowlist ?? [...availableTools]);
129+
console.error("Failed to set empty tool allowlist:", result.error);
130+
}
131+
} catch (err) {
132+
setLocalAllowlist(currentAllowlist ?? [...availableTools]);
133+
console.error("Failed to set empty tool allowlist:", err);
134+
} finally {
135+
setSaving(false);
136+
}
137+
}, [api, projectPath, serverName, allDisabled, currentAllowlist, availableTools]);
138+
139+
return (
140+
<div className="mt-2">
141+
<button
142+
onClick={() => setExpanded(!expanded)}
143+
className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs"
144+
>
145+
{expanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
146+
<span>
147+
Tools: {localAllowlist.length}/{availableTools.length}
148+
</span>
149+
<span className="text-muted-foreground/60 ml-1">({formatRelativeTime(testedAt)})</span>
150+
{saving && <Loader2 className="ml-1 h-3 w-3 animate-spin" />}
151+
</button>
152+
153+
{expanded && (
154+
<div className="border-border-light mt-2 border-l-2 pl-3">
155+
<ToolSelector
156+
availableTools={availableTools}
157+
allowedTools={localAllowlist}
158+
onToggle={(tool, allowed) => void handleToggleTool(tool, allowed)}
159+
onSelectAll={() => void handleAllowAll()}
160+
onSelectNone={() => void handleSelectNone()}
161+
disabled={saving}
162+
/>
163+
</div>
164+
)}
165+
</div>
166+
);
167+
};
80168

81169
export const ProjectSettingsSection: React.FC = () => {
82170
const { api } = useAPI();
@@ -478,12 +566,13 @@ export const ProjectSettingsSection: React.FC = () => {
478566
</div>
479567
)}
480568
{cached?.result.success && cached.result.tools.length > 0 && !isEditing && (
481-
<p className="text-muted-foreground mt-2 text-xs">
482-
Tools: {cached.result.tools.join(", ")}
483-
<span className="text-muted-foreground/60 ml-2">
484-
({formatRelativeTime(cached.testedAt)})
485-
</span>
486-
</p>
569+
<ToolAllowlistSection
570+
serverName={name}
571+
availableTools={cached.result.tools}
572+
currentAllowlist={entry.toolAllowlist}
573+
testedAt={cached.testedAt}
574+
projectPath={selectedProject}
575+
/>
487576
)}
488577
</li>
489578
);
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import React from "react";
2+
import { Checkbox } from "@/browser/components/ui/checkbox";
3+
import { Button } from "@/browser/components/ui/button";
4+
5+
interface ToolSelectorProps {
6+
/** All available tools for this server */
7+
availableTools: string[];
8+
/** Currently allowed tools (empty = none allowed) */
9+
allowedTools: string[];
10+
/** Called when tool selection changes */
11+
onToggle: (toolName: string, allowed: boolean) => void;
12+
/** Called to select all tools */
13+
onSelectAll: () => void;
14+
/** Called to deselect all tools */
15+
onSelectNone: () => void;
16+
/** Whether controls are disabled */
17+
disabled?: boolean;
18+
}
19+
20+
/**
21+
* Reusable tool selector grid with All/None buttons.
22+
* Used by both project-level and workspace-level MCP config UIs.
23+
*/
24+
export const ToolSelector: React.FC<ToolSelectorProps> = ({
25+
availableTools,
26+
allowedTools,
27+
onToggle,
28+
onSelectAll,
29+
onSelectNone,
30+
disabled = false,
31+
}) => {
32+
const allAllowed = allowedTools.length === availableTools.length;
33+
const noneAllowed = allowedTools.length === 0;
34+
35+
return (
36+
<div>
37+
<div className="mb-2 flex items-center justify-between">
38+
<span className="text-muted-foreground text-xs">Select tools to expose:</span>
39+
<div className="flex gap-1">
40+
<Button
41+
variant="ghost"
42+
size="sm"
43+
className="h-5 px-2 text-xs"
44+
onClick={onSelectAll}
45+
disabled={disabled || allAllowed}
46+
>
47+
All
48+
</Button>
49+
<Button
50+
variant="ghost"
51+
size="sm"
52+
className="h-5 px-2 text-xs"
53+
onClick={onSelectNone}
54+
disabled={disabled || noneAllowed}
55+
>
56+
None
57+
</Button>
58+
</div>
59+
</div>
60+
<div className="grid grid-cols-2 gap-1">
61+
{availableTools.map((tool) => (
62+
<label key={tool} className="flex cursor-pointer items-center gap-2 py-0.5 text-xs">
63+
<Checkbox
64+
checked={allowedTools.includes(tool)}
65+
onCheckedChange={(checked) => onToggle(tool, checked === true)}
66+
disabled={disabled}
67+
/>
68+
<span className="truncate font-mono" title={tool}>
69+
{tool}
70+
</span>
71+
</label>
72+
))}
73+
</div>
74+
</div>
75+
);
76+
};

0 commit comments

Comments
 (0)