Skip to content

Commit 3abb165

Browse files
committed
🤖 refactor: persist workspace MCP overrides locally
Change-Id: I3850373c6aaa762b3f9973cc18cf1b3d5d22bc7a Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 3f74a6d commit 3abb165

File tree

17 files changed

+591
-187
lines changed

17 files changed

+591
-187
lines changed

docs/mcp-servers.mdx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,15 @@ MCP servers have two scopes:
5252
- **Configuration** is per-project — The `.mux/mcp.jsonc` file lives in your project root and applies to all workspaces created from that project
5353
- **Runtime instances** are per-workspace — Each workspace runs its own server processes, so state in one workspace doesn't affect another
5454

55+
## Per-workspace overrides
56+
57+
Mux supports per-workspace MCP overrides (enable/disable servers and restrict tool allowlists) without modifying the project-level `.mux/mcp.jsonc`.
58+
59+
These overrides are stored in a workspace-local file: `.mux/mcp.local.jsonc`.
60+
61+
- This file is intended to be **gitignored** (it contains local-only workspace preferences)
62+
- Older mux versions stored these overrides in `~/.mux/config.json`; mux will migrate them into `.mux/mcp.local.jsonc` on first use
63+
5564
This means you configure servers once per project, but each workspace (branch) gets isolated server instances with independent state.
5665

5766
## Behavior

src/cli/cli.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ async function createTestServer(authToken?: string): Promise<TestServerHandle> {
6969
updateService: services.updateService,
7070
tokenizerService: services.tokenizerService,
7171
serverService: services.serverService,
72+
workspaceMcpOverridesService: services.workspaceMcpOverridesService,
7273
mcpConfigService: services.mcpConfigService,
7374
featureFlagService: services.featureFlagService,
7475
sessionTimingService: services.sessionTimingService,

src/cli/server.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ async function createTestServer(): Promise<TestServerHandle> {
7272
updateService: services.updateService,
7373
tokenizerService: services.tokenizerService,
7474
serverService: services.serverService,
75+
workspaceMcpOverridesService: services.workspaceMcpOverridesService,
7576
mcpConfigService: services.mcpConfigService,
7677
featureFlagService: services.featureFlagService,
7778
sessionTimingService: services.sessionTimingService,

src/cli/server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ const mockWindow: BrowserWindow = {
8989
tokenizerService: serviceContainer.tokenizerService,
9090
serverService: serviceContainer.serverService,
9191
menuEventService: serviceContainer.menuEventService,
92+
workspaceMcpOverridesService: serviceContainer.workspaceMcpOverridesService,
9293
mcpConfigService: serviceContainer.mcpConfigService,
9394
featureFlagService: serviceContainer.featureFlagService,
9495
sessionTimingService: serviceContainer.sessionTimingService,

src/common/orpc/schemas/mcp.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { z } from "zod";
33
/**
44
* Per-workspace MCP overrides.
55
*
6-
* Stored in ~/.mux/config.json under each workspace entry.
6+
* Stored per-workspace in <workspace>/.mux/mcp.local.jsonc (workspace-local, intended to be gitignored).
77
* Allows workspaces to disable servers or restrict tool allowlists
88
* without modifying the project-level .mux/mcp.jsonc.
99
*/

src/common/orpc/schemas/project.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@ export const WorkspaceConfigSchema = z.object({
5858
"Trunk branch used to create/init this agent task workspace (used for restart-safe init on queued tasks).",
5959
}),
6060
mcp: WorkspaceMCPOverridesSchema.optional().meta({
61-
description: "Per-workspace MCP overrides (disabled servers, tool allowlists)",
61+
description:
62+
"LEGACY: Per-workspace MCP overrides (migrated to <workspace>/.mux/mcp.local.jsonc)",
6263
}),
6364
archivedAt: z.string().optional().meta({
6465
description:

src/common/types/mcp.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,10 @@ export interface CachedMCPTestResult {
5353
/**
5454
* Per-workspace MCP overrides.
5555
*
56-
* Stored in ~/.mux/config.json under each workspace entry.
57-
* Allows workspaces to override project-level server enabled/disabled state
58-
* and restrict tool allowlists.
56+
* Stored per-workspace in <workspace>/.mux/mcp.local.jsonc (workspace-local and intended to be gitignored).
57+
*
58+
* Legacy note: older mux versions stored these overrides in ~/.mux/config.json under each workspace entry.
59+
* Newer versions migrate those values into the workspace-local file on first read/write.
5960
*/
6061
export interface WorkspaceMCPOverrides {
6162
/**

src/desktop/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,7 @@ async function loadServices(): Promise<void> {
336336
serverService: services.serverService,
337337
featureFlagService: services.featureFlagService,
338338
sessionTimingService: services.sessionTimingService,
339+
workspaceMcpOverridesService: services.workspaceMcpOverridesService,
339340
mcpConfigService: services.mcpConfigService,
340341
mcpServerManager: services.mcpServerManager,
341342
menuEventService: services.menuEventService,

src/node/config.test.ts

Lines changed: 0 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -148,119 +148,4 @@ describe("Config", () => {
148148
expect(workspace.createdAt).toBe("2025-01-01T00:00:00.000Z");
149149
});
150150
});
151-
152-
describe("workspace MCP overrides", () => {
153-
it("should return undefined for non-existent workspace", () => {
154-
const result = config.getWorkspaceMCPOverrides("non-existent-id");
155-
expect(result).toBeUndefined();
156-
});
157-
158-
it("should return undefined for workspace without MCP overrides", async () => {
159-
const projectPath = "/fake/project";
160-
const workspacePath = path.join(config.srcDir, "project", "branch");
161-
162-
fs.mkdirSync(workspacePath, { recursive: true });
163-
164-
await config.editConfig((cfg) => {
165-
cfg.projects.set(projectPath, {
166-
workspaces: [{ path: workspacePath, id: "test-ws-id", name: "branch" }],
167-
});
168-
return cfg;
169-
});
170-
171-
const result = config.getWorkspaceMCPOverrides("test-ws-id");
172-
expect(result).toBeUndefined();
173-
});
174-
175-
it("should set and get MCP overrides for a workspace", async () => {
176-
const projectPath = "/fake/project";
177-
const workspacePath = path.join(config.srcDir, "project", "branch");
178-
179-
fs.mkdirSync(workspacePath, { recursive: true });
180-
181-
await config.editConfig((cfg) => {
182-
cfg.projects.set(projectPath, {
183-
workspaces: [{ path: workspacePath, id: "test-ws-id", name: "branch" }],
184-
});
185-
return cfg;
186-
});
187-
188-
// Set overrides
189-
await config.setWorkspaceMCPOverrides("test-ws-id", {
190-
disabledServers: ["server-a", "server-b"],
191-
toolAllowlist: { "server-c": ["tool1", "tool2"] },
192-
});
193-
194-
// Get overrides
195-
const result = config.getWorkspaceMCPOverrides("test-ws-id");
196-
expect(result).toBeDefined();
197-
expect(result!.disabledServers).toEqual(["server-a", "server-b"]);
198-
expect(result!.toolAllowlist).toEqual({ "server-c": ["tool1", "tool2"] });
199-
});
200-
201-
it("should remove MCP overrides when set to empty", async () => {
202-
const projectPath = "/fake/project";
203-
const workspacePath = path.join(config.srcDir, "project", "branch");
204-
205-
fs.mkdirSync(workspacePath, { recursive: true });
206-
207-
await config.editConfig((cfg) => {
208-
cfg.projects.set(projectPath, {
209-
workspaces: [
210-
{
211-
path: workspacePath,
212-
id: "test-ws-id",
213-
name: "branch",
214-
mcp: { disabledServers: ["server-a"] },
215-
},
216-
],
217-
});
218-
return cfg;
219-
});
220-
221-
// Clear overrides
222-
await config.setWorkspaceMCPOverrides("test-ws-id", {});
223-
224-
// Verify overrides are removed
225-
const result = config.getWorkspaceMCPOverrides("test-ws-id");
226-
expect(result).toBeUndefined();
227-
228-
// Verify workspace still exists
229-
const configData = config.loadConfigOrDefault();
230-
const projectConfig = configData.projects.get(projectPath);
231-
expect(projectConfig!.workspaces[0].id).toBe("test-ws-id");
232-
expect(projectConfig!.workspaces[0].mcp).toBeUndefined();
233-
});
234-
235-
it("should deduplicate disabledServers", async () => {
236-
const projectPath = "/fake/project";
237-
const workspacePath = path.join(config.srcDir, "project", "branch");
238-
239-
fs.mkdirSync(workspacePath, { recursive: true });
240-
241-
await config.editConfig((cfg) => {
242-
cfg.projects.set(projectPath, {
243-
workspaces: [{ path: workspacePath, id: "test-ws-id", name: "branch" }],
244-
});
245-
return cfg;
246-
});
247-
248-
// Set with duplicates
249-
await config.setWorkspaceMCPOverrides("test-ws-id", {
250-
disabledServers: ["server-a", "server-b", "server-a"],
251-
});
252-
253-
// Verify duplicates are removed
254-
const result = config.getWorkspaceMCPOverrides("test-ws-id");
255-
expect(result!.disabledServers).toHaveLength(2);
256-
expect(result!.disabledServers).toContain("server-a");
257-
expect(result!.disabledServers).toContain("server-b");
258-
});
259-
260-
it("should throw error when setting overrides for non-existent workspace", async () => {
261-
await expect(
262-
config.setWorkspaceMCPOverrides("non-existent-id", { disabledServers: ["server-a"] })
263-
).rejects.toThrow("Workspace non-existent-id not found in config");
264-
});
265-
});
266151
});

src/node/config.ts

Lines changed: 0 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -589,65 +589,6 @@ export class Config {
589589
});
590590
}
591591

592-
/**
593-
* Get MCP overrides for a workspace.
594-
* Returns undefined if workspace not found or no overrides set.
595-
*/
596-
getWorkspaceMCPOverrides(workspaceId: string): Workspace["mcp"] | undefined {
597-
const config = this.loadConfigOrDefault();
598-
for (const [_projectPath, projectConfig] of config.projects) {
599-
const workspace = projectConfig.workspaces.find((w) => w.id === workspaceId);
600-
if (workspace) {
601-
return workspace.mcp;
602-
}
603-
}
604-
return undefined;
605-
}
606-
607-
/**
608-
* Set MCP overrides for a workspace.
609-
* @throws Error if workspace not found
610-
*/
611-
async setWorkspaceMCPOverrides(workspaceId: string, overrides: Workspace["mcp"]): Promise<void> {
612-
await this.editConfig((config) => {
613-
for (const [_projectPath, projectConfig] of config.projects) {
614-
const workspace = projectConfig.workspaces.find((w) => w.id === workspaceId);
615-
if (workspace) {
616-
// Normalize: remove empty arrays to keep config clean
617-
const normalized = overrides
618-
? {
619-
disabledServers:
620-
overrides.disabledServers && overrides.disabledServers.length > 0
621-
? [...new Set(overrides.disabledServers)] // De-duplicate
622-
: undefined,
623-
enabledServers:
624-
overrides.enabledServers && overrides.enabledServers.length > 0
625-
? [...new Set(overrides.enabledServers)] // De-duplicate
626-
: undefined,
627-
toolAllowlist:
628-
overrides.toolAllowlist && Object.keys(overrides.toolAllowlist).length > 0
629-
? overrides.toolAllowlist
630-
: undefined,
631-
}
632-
: undefined;
633-
634-
// Remove mcp field entirely if no overrides
635-
if (
636-
!normalized?.disabledServers &&
637-
!normalized?.enabledServers &&
638-
!normalized?.toolAllowlist
639-
) {
640-
delete workspace.mcp;
641-
} else {
642-
workspace.mcp = normalized;
643-
}
644-
return config;
645-
}
646-
}
647-
throw new Error(`Workspace ${workspaceId} not found in config`);
648-
});
649-
}
650-
651592
/**
652593
* Load providers configuration from JSONC file
653594
* Supports comments in JSONC format

0 commit comments

Comments
 (0)