Skip to content

Commit 32b9fd6

Browse files
committed
🤖 refactor: persist workspace MCP overrides locally
Change-Id: I3850373c6aaa762b3f9973cc18cf1b3d5d22bc7a Signed-off-by: Thomas Kosiewski <[email protected]>
1 parent fead02c commit 32b9fd6

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
@@ -339,6 +339,7 @@ async function loadServices(): Promise<void> {
339339
serverService: services.serverService,
340340
featureFlagService: services.featureFlagService,
341341
sessionTimingService: services.sessionTimingService,
342+
workspaceMcpOverridesService: services.workspaceMcpOverridesService,
342343
mcpConfigService: services.mcpConfigService,
343344
mcpServerManager: services.mcpServerManager,
344345
menuEventService: services.menuEventService,

src/node/config.test.ts

Lines changed: 0 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -178,119 +178,4 @@ describe("Config", () => {
178178
expect(workspace.createdAt).toBe("2025-01-01T00:00:00.000Z");
179179
});
180180
});
181-
182-
describe("workspace MCP overrides", () => {
183-
it("should return undefined for non-existent workspace", () => {
184-
const result = config.getWorkspaceMCPOverrides("non-existent-id");
185-
expect(result).toBeUndefined();
186-
});
187-
188-
it("should return undefined for workspace without MCP overrides", async () => {
189-
const projectPath = "/fake/project";
190-
const workspacePath = path.join(config.srcDir, "project", "branch");
191-
192-
fs.mkdirSync(workspacePath, { recursive: true });
193-
194-
await config.editConfig((cfg) => {
195-
cfg.projects.set(projectPath, {
196-
workspaces: [{ path: workspacePath, id: "test-ws-id", name: "branch" }],
197-
});
198-
return cfg;
199-
});
200-
201-
const result = config.getWorkspaceMCPOverrides("test-ws-id");
202-
expect(result).toBeUndefined();
203-
});
204-
205-
it("should set and get MCP overrides for a workspace", async () => {
206-
const projectPath = "/fake/project";
207-
const workspacePath = path.join(config.srcDir, "project", "branch");
208-
209-
fs.mkdirSync(workspacePath, { recursive: true });
210-
211-
await config.editConfig((cfg) => {
212-
cfg.projects.set(projectPath, {
213-
workspaces: [{ path: workspacePath, id: "test-ws-id", name: "branch" }],
214-
});
215-
return cfg;
216-
});
217-
218-
// Set overrides
219-
await config.setWorkspaceMCPOverrides("test-ws-id", {
220-
disabledServers: ["server-a", "server-b"],
221-
toolAllowlist: { "server-c": ["tool1", "tool2"] },
222-
});
223-
224-
// Get overrides
225-
const result = config.getWorkspaceMCPOverrides("test-ws-id");
226-
expect(result).toBeDefined();
227-
expect(result!.disabledServers).toEqual(["server-a", "server-b"]);
228-
expect(result!.toolAllowlist).toEqual({ "server-c": ["tool1", "tool2"] });
229-
});
230-
231-
it("should remove MCP overrides when set to empty", async () => {
232-
const projectPath = "/fake/project";
233-
const workspacePath = path.join(config.srcDir, "project", "branch");
234-
235-
fs.mkdirSync(workspacePath, { recursive: true });
236-
237-
await config.editConfig((cfg) => {
238-
cfg.projects.set(projectPath, {
239-
workspaces: [
240-
{
241-
path: workspacePath,
242-
id: "test-ws-id",
243-
name: "branch",
244-
mcp: { disabledServers: ["server-a"] },
245-
},
246-
],
247-
});
248-
return cfg;
249-
});
250-
251-
// Clear overrides
252-
await config.setWorkspaceMCPOverrides("test-ws-id", {});
253-
254-
// Verify overrides are removed
255-
const result = config.getWorkspaceMCPOverrides("test-ws-id");
256-
expect(result).toBeUndefined();
257-
258-
// Verify workspace still exists
259-
const configData = config.loadConfigOrDefault();
260-
const projectConfig = configData.projects.get(projectPath);
261-
expect(projectConfig!.workspaces[0].id).toBe("test-ws-id");
262-
expect(projectConfig!.workspaces[0].mcp).toBeUndefined();
263-
});
264-
265-
it("should deduplicate disabledServers", async () => {
266-
const projectPath = "/fake/project";
267-
const workspacePath = path.join(config.srcDir, "project", "branch");
268-
269-
fs.mkdirSync(workspacePath, { recursive: true });
270-
271-
await config.editConfig((cfg) => {
272-
cfg.projects.set(projectPath, {
273-
workspaces: [{ path: workspacePath, id: "test-ws-id", name: "branch" }],
274-
});
275-
return cfg;
276-
});
277-
278-
// Set with duplicates
279-
await config.setWorkspaceMCPOverrides("test-ws-id", {
280-
disabledServers: ["server-a", "server-b", "server-a"],
281-
});
282-
283-
// Verify duplicates are removed
284-
const result = config.getWorkspaceMCPOverrides("test-ws-id");
285-
expect(result!.disabledServers).toHaveLength(2);
286-
expect(result!.disabledServers).toContain("server-a");
287-
expect(result!.disabledServers).toContain("server-b");
288-
});
289-
290-
it("should throw error when setting overrides for non-existent workspace", async () => {
291-
await expect(
292-
config.setWorkspaceMCPOverrides("non-existent-id", { disabledServers: ["server-a"] })
293-
).rejects.toThrow("Workspace non-existent-id not found in config");
294-
});
295-
});
296181
});

src/node/config.ts

Lines changed: 0 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -639,65 +639,6 @@ export class Config {
639639
});
640640
}
641641

642-
/**
643-
* Get MCP overrides for a workspace.
644-
* Returns undefined if workspace not found or no overrides set.
645-
*/
646-
getWorkspaceMCPOverrides(workspaceId: string): Workspace["mcp"] | undefined {
647-
const config = this.loadConfigOrDefault();
648-
for (const [_projectPath, projectConfig] of config.projects) {
649-
const workspace = projectConfig.workspaces.find((w) => w.id === workspaceId);
650-
if (workspace) {
651-
return workspace.mcp;
652-
}
653-
}
654-
return undefined;
655-
}
656-
657-
/**
658-
* Set MCP overrides for a workspace.
659-
* @throws Error if workspace not found
660-
*/
661-
async setWorkspaceMCPOverrides(workspaceId: string, overrides: Workspace["mcp"]): Promise<void> {
662-
await this.editConfig((config) => {
663-
for (const [_projectPath, projectConfig] of config.projects) {
664-
const workspace = projectConfig.workspaces.find((w) => w.id === workspaceId);
665-
if (workspace) {
666-
// Normalize: remove empty arrays to keep config clean
667-
const normalized = overrides
668-
? {
669-
disabledServers:
670-
overrides.disabledServers && overrides.disabledServers.length > 0
671-
? [...new Set(overrides.disabledServers)] // De-duplicate
672-
: undefined,
673-
enabledServers:
674-
overrides.enabledServers && overrides.enabledServers.length > 0
675-
? [...new Set(overrides.enabledServers)] // De-duplicate
676-
: undefined,
677-
toolAllowlist:
678-
overrides.toolAllowlist && Object.keys(overrides.toolAllowlist).length > 0
679-
? overrides.toolAllowlist
680-
: undefined,
681-
}
682-
: undefined;
683-
684-
// Remove mcp field entirely if no overrides
685-
if (
686-
!normalized?.disabledServers &&
687-
!normalized?.enabledServers &&
688-
!normalized?.toolAllowlist
689-
) {
690-
delete workspace.mcp;
691-
} else {
692-
workspace.mcp = normalized;
693-
}
694-
return config;
695-
}
696-
}
697-
throw new Error(`Workspace ${workspaceId} not found in config`);
698-
});
699-
}
700-
701642
/**
702643
* Load providers configuration from JSONC file
703644
* Supports comments in JSONC format

0 commit comments

Comments
 (0)