From 8da0a549f3b57a771fb9364cf3e36265b7933de7 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 22 Dec 2025 12:10:44 +0100 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20persist=20worksp?= =?UTF-8?q?ace=20MCP=20overrides=20locally?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: I3850373c6aaa762b3f9973cc18cf1b3d5d22bc7a Signed-off-by: Thomas Kosiewski --- docs/mcp-servers.mdx | 9 + src/cli/cli.test.ts | 1 + src/cli/server.test.ts | 1 + src/cli/server.ts | 1 + src/common/orpc/schemas/mcp.ts | 2 +- src/common/orpc/schemas/project.ts | 3 +- src/common/types/mcp.ts | 7 +- src/desktop/main.ts | 1 + src/node/config.test.ts | 115 ------ src/node/config.ts | 59 ---- src/node/orpc/context.ts | 2 + src/node/orpc/router.ts | 18 +- src/node/services/aiService.ts | 21 +- src/node/services/serviceContainer.ts | 6 +- .../workspaceMcpOverridesService.test.ts | 201 +++++++++++ .../services/workspaceMcpOverridesService.ts | 330 ++++++++++++++++++ tests/ipc/setup.ts | 1 + 17 files changed, 591 insertions(+), 187 deletions(-) create mode 100644 src/node/services/workspaceMcpOverridesService.test.ts create mode 100644 src/node/services/workspaceMcpOverridesService.ts diff --git a/docs/mcp-servers.mdx b/docs/mcp-servers.mdx index 275f399dcb..5610621cb2 100644 --- a/docs/mcp-servers.mdx +++ b/docs/mcp-servers.mdx @@ -52,6 +52,15 @@ MCP servers have two scopes: - **Configuration** is per-project — The `.mux/mcp.jsonc` file lives in your project root and applies to all workspaces created from that project - **Runtime instances** are per-workspace — Each workspace runs its own server processes, so state in one workspace doesn't affect another +## Per-workspace overrides + +Mux supports per-workspace MCP overrides (enable/disable servers and restrict tool allowlists) without modifying the project-level `.mux/mcp.jsonc`. + +These overrides are stored in a workspace-local file: `.mux/mcp.local.jsonc`. + +- This file is intended to be **gitignored** (it contains local-only workspace preferences) +- Older mux versions stored these overrides in `~/.mux/config.json`; mux will migrate them into `.mux/mcp.local.jsonc` on first use + This means you configure servers once per project, but each workspace (branch) gets isolated server instances with independent state. ## Behavior diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index 61d104be81..8cae90cea6 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -69,6 +69,7 @@ async function createTestServer(authToken?: string): Promise { updateService: services.updateService, tokenizerService: services.tokenizerService, serverService: services.serverService, + workspaceMcpOverridesService: services.workspaceMcpOverridesService, mcpConfigService: services.mcpConfigService, featureFlagService: services.featureFlagService, sessionTimingService: services.sessionTimingService, diff --git a/src/cli/server.test.ts b/src/cli/server.test.ts index 8945477bee..e7f456d987 100644 --- a/src/cli/server.test.ts +++ b/src/cli/server.test.ts @@ -72,6 +72,7 @@ async function createTestServer(): Promise { updateService: services.updateService, tokenizerService: services.tokenizerService, serverService: services.serverService, + workspaceMcpOverridesService: services.workspaceMcpOverridesService, mcpConfigService: services.mcpConfigService, featureFlagService: services.featureFlagService, sessionTimingService: services.sessionTimingService, diff --git a/src/cli/server.ts b/src/cli/server.ts index d236b2ea03..e83ab690c5 100644 --- a/src/cli/server.ts +++ b/src/cli/server.ts @@ -89,6 +89,7 @@ const mockWindow: BrowserWindow = { tokenizerService: serviceContainer.tokenizerService, serverService: serviceContainer.serverService, menuEventService: serviceContainer.menuEventService, + workspaceMcpOverridesService: serviceContainer.workspaceMcpOverridesService, mcpConfigService: serviceContainer.mcpConfigService, featureFlagService: serviceContainer.featureFlagService, sessionTimingService: serviceContainer.sessionTimingService, diff --git a/src/common/orpc/schemas/mcp.ts b/src/common/orpc/schemas/mcp.ts index 5066932976..3aedef3151 100644 --- a/src/common/orpc/schemas/mcp.ts +++ b/src/common/orpc/schemas/mcp.ts @@ -3,7 +3,7 @@ import { z } from "zod"; /** * Per-workspace MCP overrides. * - * Stored in ~/.mux/config.json under each workspace entry. + * Stored per-workspace in /.mux/mcp.local.jsonc (workspace-local, intended to be gitignored). * Allows workspaces to disable servers or restrict tool allowlists * without modifying the project-level .mux/mcp.jsonc. */ diff --git a/src/common/orpc/schemas/project.ts b/src/common/orpc/schemas/project.ts index 82281a71dc..83b7b95834 100644 --- a/src/common/orpc/schemas/project.ts +++ b/src/common/orpc/schemas/project.ts @@ -58,7 +58,8 @@ export const WorkspaceConfigSchema = z.object({ "Trunk branch used to create/init this agent task workspace (used for restart-safe init on queued tasks).", }), mcp: WorkspaceMCPOverridesSchema.optional().meta({ - description: "Per-workspace MCP overrides (disabled servers, tool allowlists)", + description: + "LEGACY: Per-workspace MCP overrides (migrated to /.mux/mcp.local.jsonc)", }), archivedAt: z.string().optional().meta({ description: diff --git a/src/common/types/mcp.ts b/src/common/types/mcp.ts index 2b16192baf..9a1d831de4 100644 --- a/src/common/types/mcp.ts +++ b/src/common/types/mcp.ts @@ -53,9 +53,10 @@ export interface CachedMCPTestResult { /** * 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. + * Stored per-workspace in /.mux/mcp.local.jsonc (workspace-local and intended to be gitignored). + * + * Legacy note: older mux versions stored these overrides in ~/.mux/config.json under each workspace entry. + * Newer versions migrate those values into the workspace-local file on first read/write. */ export interface WorkspaceMCPOverrides { /** diff --git a/src/desktop/main.ts b/src/desktop/main.ts index 9aea6d825b..bfab60739a 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -339,6 +339,7 @@ async function loadServices(): Promise { serverService: services.serverService, featureFlagService: services.featureFlagService, sessionTimingService: services.sessionTimingService, + workspaceMcpOverridesService: services.workspaceMcpOverridesService, mcpConfigService: services.mcpConfigService, mcpServerManager: services.mcpServerManager, menuEventService: services.menuEventService, diff --git a/src/node/config.test.ts b/src/node/config.test.ts index 9598d6209c..6aedfa1072 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -178,119 +178,4 @@ 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 84cf38cd7b..3c2a3274ab 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -639,65 +639,6 @@ 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/context.ts b/src/node/orpc/context.ts index d04f9c86b5..c073cc1844 100644 --- a/src/node/orpc/context.ts +++ b/src/node/orpc/context.ts @@ -14,6 +14,7 @@ import type { MenuEventService } from "@/node/services/menuEventService"; import type { VoiceService } from "@/node/services/voiceService"; import type { MCPConfigService } from "@/node/services/mcpConfigService"; import type { ExperimentsService } from "@/node/services/experimentsService"; +import type { WorkspaceMcpOverridesService } from "@/node/services/workspaceMcpOverridesService"; import type { MCPServerManager } from "@/node/services/mcpServerManager"; import type { TelemetryService } from "@/node/services/telemetryService"; import type { FeatureFlagService } from "@/node/services/featureFlagService"; @@ -37,6 +38,7 @@ export interface ORPCContext { menuEventService: MenuEventService; voiceService: VoiceService; mcpConfigService: MCPConfigService; + workspaceMcpOverridesService: WorkspaceMcpOverridesService; mcpServerManager: MCPServerManager; featureFlagService: FeatureFlagService; sessionTimingService: SessionTimingService; diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index d21075d0ec..14b5f08b74 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -1234,17 +1234,25 @@ export const router = (authToken?: string) => { 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 ?? {}; + .handler(async ({ context, input }) => { + try { + return await context.workspaceMcpOverridesService.getOverridesForWorkspace( + input.workspaceId + ); + } catch { + // Defensive: overrides must never brick workspace UI. + return {}; + } }), 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); + await context.workspaceMcpOverridesService.setOverridesForWorkspace( + input.workspaceId, + input.overrides + ); return { success: true, data: undefined }; } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 125da82186..a28f427f00 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -47,7 +47,9 @@ import { buildSystemMessage, readToolInstructions } from "./systemMessage"; import { getTokenizerForModel } from "@/node/utils/main/tokenizer"; import type { TelemetryService } from "@/node/services/telemetryService"; import { getRuntimeTypeForTelemetry, roundToBase2 } from "@/common/telemetry/utils"; +import type { WorkspaceMCPOverrides } from "@/common/types/mcp"; import type { MCPServerManager, MCPWorkspaceStats } from "@/node/services/mcpServerManager"; +import { WorkspaceMcpOverridesService } from "./workspaceMcpOverridesService"; import type { TaskService } from "@/node/services/taskService"; import { buildProviderOptions } from "@/common/utils/ai/providerOptions"; import type { ThinkingLevel } from "@/common/types/thinking"; @@ -380,6 +382,7 @@ export class AIService extends EventEmitter { private readonly historyService: HistoryService; private readonly partialService: PartialService; private readonly config: Config; + private readonly workspaceMcpOverridesService: WorkspaceMcpOverridesService; private mcpServerManager?: MCPServerManager; private telemetryService?: TelemetryService; private readonly initStateManager: InitStateManager; @@ -394,12 +397,15 @@ export class AIService extends EventEmitter { partialService: PartialService, initStateManager: InitStateManager, backgroundProcessManager?: BackgroundProcessManager, - sessionUsageService?: SessionUsageService + sessionUsageService?: SessionUsageService, + workspaceMcpOverridesService?: WorkspaceMcpOverridesService ) { super(); // Increase max listeners to accommodate multiple concurrent workspace listeners // Each workspace subscribes to stream events, and we expect >10 concurrent workspaces this.setMaxListeners(50); + this.workspaceMcpOverridesService = + workspaceMcpOverridesService ?? new WorkspaceMcpOverridesService(config); this.config = config; this.historyService = historyService; this.partialService = partialService; @@ -1128,7 +1134,18 @@ export class AIService extends EventEmitter { : runtime.getWorkspacePath(metadata.projectPath, metadata.name); // Fetch workspace MCP overrides (for filtering servers and tools) - const mcpOverrides = this.config.getWorkspaceMCPOverrides(workspaceId); + // NOTE: Stored in /.mux/mcp.local.jsonc (not ~/.mux/config.json). + let mcpOverrides: WorkspaceMCPOverrides | undefined; + try { + mcpOverrides = + await this.workspaceMcpOverridesService.getOverridesForWorkspace(workspaceId); + } catch (error) { + log.warn("[MCP] Failed to load workspace MCP overrides; continuing without overrides", { + workspaceId, + error, + }); + mcpOverrides = undefined; + } // Fetch MCP server config for system prompt (before building message) // Pass overrides to filter out disabled servers diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 51f2d8c0d0..66c5ac1b81 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -35,6 +35,7 @@ import { SessionTimingService } from "@/node/services/sessionTimingService"; import { ExperimentsService } from "@/node/services/experimentsService"; import { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; import { MCPConfigService } from "@/node/services/mcpConfigService"; +import { WorkspaceMcpOverridesService } from "@/node/services/workspaceMcpOverridesService"; import { MCPServerManager } from "@/node/services/mcpServerManager"; import { SessionUsageService } from "@/node/services/sessionUsageService"; import { IdleCompactionService } from "@/node/services/idleCompactionService"; @@ -64,6 +65,7 @@ export class ServiceContainer { public readonly menuEventService: MenuEventService; public readonly voiceService: VoiceService; public readonly mcpConfigService: MCPConfigService; + public readonly workspaceMcpOverridesService: WorkspaceMcpOverridesService; public readonly mcpServerManager: MCPServerManager; public readonly telemetryService: TelemetryService; public readonly featureFlagService: FeatureFlagService; @@ -82,6 +84,7 @@ export class ServiceContainer { this.partialService = new PartialService(config, this.historyService); this.projectService = new ProjectService(config); this.initStateManager = new InitStateManager(config); + this.workspaceMcpOverridesService = new WorkspaceMcpOverridesService(config); this.mcpConfigService = new MCPConfigService(); this.extensionMetadata = new ExtensionMetadataService( path.join(config.rootDir, "extensionMetadata.json") @@ -97,7 +100,8 @@ export class ServiceContainer { this.partialService, this.initStateManager, this.backgroundProcessManager, - this.sessionUsageService + this.sessionUsageService, + this.workspaceMcpOverridesService ); this.aiService.setMCPServerManager(this.mcpServerManager); this.workspaceService = new WorkspaceService( diff --git a/src/node/services/workspaceMcpOverridesService.test.ts b/src/node/services/workspaceMcpOverridesService.test.ts new file mode 100644 index 0000000000..0f834ce015 --- /dev/null +++ b/src/node/services/workspaceMcpOverridesService.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import * as fs from "fs/promises"; +import * as os from "os"; +import * as path from "path"; +import { Config } from "@/node/config"; +import { WorkspaceMcpOverridesService } from "./workspaceMcpOverridesService"; + +function getWorkspacePath(args: { + srcDir: string; + projectName: string; + workspaceName: string; +}): string { + return path.join(args.srcDir, args.projectName, args.workspaceName); +} + +async function pathExists(filePath: string): Promise { + try { + await fs.stat(filePath); + return true; + } catch { + return false; + } +} + +describe("WorkspaceMcpOverridesService", () => { + let tempDir: string; + let config: Config; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-mcp-overrides-test-")); + config = new Config(tempDir); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it("returns empty overrides when no file and no legacy config", async () => { + const projectPath = "/fake/project"; + const workspaceId = "ws-id"; + const workspaceName = "branch"; + + const workspacePath = getWorkspacePath({ + srcDir: config.srcDir, + projectName: "project", + workspaceName, + }); + await fs.mkdir(workspacePath, { recursive: true }); + + await config.editConfig((cfg) => { + cfg.projects.set(projectPath, { + workspaces: [ + { + path: workspacePath, + id: workspaceId, + name: workspaceName, + runtimeConfig: { type: "worktree", srcBaseDir: config.srcDir }, + }, + ], + }); + return cfg; + }); + + const service = new WorkspaceMcpOverridesService(config); + const overrides = await service.getOverridesForWorkspace(workspaceId); + + expect(overrides).toEqual({}); + expect(await pathExists(path.join(workspacePath, ".mux", "mcp.local.jsonc"))).toBe(false); + }); + + it("persists overrides to .mux/mcp.local.jsonc and reads them back", async () => { + const projectPath = "/fake/project"; + const workspaceId = "ws-id"; + const workspaceName = "branch"; + + const workspacePath = getWorkspacePath({ + srcDir: config.srcDir, + projectName: "project", + workspaceName, + }); + await fs.mkdir(workspacePath, { recursive: true }); + + await config.editConfig((cfg) => { + cfg.projects.set(projectPath, { + workspaces: [ + { + path: workspacePath, + id: workspaceId, + name: workspaceName, + runtimeConfig: { type: "worktree", srcBaseDir: config.srcDir }, + }, + ], + }); + return cfg; + }); + + const service = new WorkspaceMcpOverridesService(config); + + await service.setOverridesForWorkspace(workspaceId, { + disabledServers: ["server-a", "server-a"], + toolAllowlist: { "server-b": ["tool1", "tool1", ""] }, + }); + + const filePath = path.join(workspacePath, ".mux", "mcp.local.jsonc"); + expect(await pathExists(filePath)).toBe(true); + + const roundTrip = await service.getOverridesForWorkspace(workspaceId); + expect(roundTrip).toEqual({ + disabledServers: ["server-a"], + toolAllowlist: { "server-b": ["tool1"] }, + }); + }); + + it("removes workspace-local file when overrides are set to empty", async () => { + const projectPath = "/fake/project"; + const workspaceId = "ws-id"; + const workspaceName = "branch"; + + const workspacePath = getWorkspacePath({ + srcDir: config.srcDir, + projectName: "project", + workspaceName, + }); + await fs.mkdir(workspacePath, { recursive: true }); + + await config.editConfig((cfg) => { + cfg.projects.set(projectPath, { + workspaces: [ + { + path: workspacePath, + id: workspaceId, + name: workspaceName, + runtimeConfig: { type: "worktree", srcBaseDir: config.srcDir }, + }, + ], + }); + return cfg; + }); + + const service = new WorkspaceMcpOverridesService(config); + + await service.setOverridesForWorkspace(workspaceId, { + disabledServers: ["server-a"], + }); + + const filePath = path.join(workspacePath, ".mux", "mcp.local.jsonc"); + expect(await pathExists(filePath)).toBe(true); + + await service.setOverridesForWorkspace(workspaceId, {}); + expect(await pathExists(filePath)).toBe(false); + }); + + it("migrates legacy config.json overrides into workspace-local file", async () => { + const projectPath = "/fake/project"; + const workspaceId = "ws-id"; + const workspaceName = "branch"; + + const workspacePath = getWorkspacePath({ + srcDir: config.srcDir, + projectName: "project", + workspaceName, + }); + await fs.mkdir(workspacePath, { recursive: true }); + + await config.editConfig((cfg) => { + cfg.projects.set(projectPath, { + workspaces: [ + { + path: workspacePath, + id: workspaceId, + name: workspaceName, + runtimeConfig: { type: "worktree", srcBaseDir: config.srcDir }, + mcp: { + disabledServers: ["server-a"], + toolAllowlist: { "server-b": ["tool1"] }, + }, + }, + ], + }); + return cfg; + }); + + const service = new WorkspaceMcpOverridesService(config); + const overrides = await service.getOverridesForWorkspace(workspaceId); + + expect(overrides).toEqual({ + disabledServers: ["server-a"], + toolAllowlist: { "server-b": ["tool1"] }, + }); + + // File written + const filePath = path.join(workspacePath, ".mux", "mcp.local.jsonc"); + expect(await pathExists(filePath)).toBe(true); + + // Legacy config cleared + const loaded = config.loadConfigOrDefault(); + const projectConfig = loaded.projects.get(projectPath); + expect(projectConfig).toBeDefined(); + expect(projectConfig!.workspaces[0].mcp).toBeUndefined(); + }); +}); diff --git a/src/node/services/workspaceMcpOverridesService.ts b/src/node/services/workspaceMcpOverridesService.ts new file mode 100644 index 0000000000..bc7eccc73a --- /dev/null +++ b/src/node/services/workspaceMcpOverridesService.ts @@ -0,0 +1,330 @@ +import * as path from "path"; +import * as jsonc from "jsonc-parser"; +import assert from "@/common/utils/assert"; +import type { WorkspaceMCPOverrides } from "@/common/types/mcp"; +import type { RuntimeConfig } from "@/common/types/runtime"; +import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; +import type { Config } from "@/node/config"; +import { createRuntime } from "@/node/runtime/runtimeFactory"; +import { execBuffered, readFileString, writeFileString } from "@/node/utils/runtime/helpers"; +import { log } from "@/node/services/log"; + +const MCP_OVERRIDES_DIR = ".mux"; +const MCP_OVERRIDES_JSONC = "mcp.local.jsonc"; +const MCP_OVERRIDES_JSON = "mcp.local.json"; + +function joinForRuntime(runtimeConfig: RuntimeConfig | undefined, ...parts: string[]): string { + assert(parts.length > 0, "joinForRuntime requires at least one path segment"); + // For SSH runtimes, paths must remain POSIX even on Windows. + return runtimeConfig?.type === "ssh" ? path.posix.join(...parts) : path.join(...parts); +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((v) => typeof v === "string"); +} + +function normalizeWorkspaceMcpOverrides(raw: unknown): WorkspaceMCPOverrides { + if (!raw || typeof raw !== "object") { + return {}; + } + + const obj = raw as { + disabledServers?: unknown; + enabledServers?: unknown; + toolAllowlist?: unknown; + }; + + const disabledServers = isStringArray(obj.disabledServers) + ? [...new Set(obj.disabledServers.map((s) => s.trim()).filter(Boolean))] + : undefined; + + const enabledServers = isStringArray(obj.enabledServers) + ? [...new Set(obj.enabledServers.map((s) => s.trim()).filter(Boolean))] + : undefined; + + let toolAllowlist: Record | undefined; + if ( + obj.toolAllowlist && + typeof obj.toolAllowlist === "object" && + !Array.isArray(obj.toolAllowlist) + ) { + const next: Record = {}; + for (const [serverName, value] of Object.entries( + obj.toolAllowlist as Record + )) { + if (!serverName || typeof serverName !== "string") continue; + if (!isStringArray(value)) continue; + + // Empty array is meaningful ("expose no tools"), so keep it. + next[serverName] = [...new Set(value.map((t) => t.trim()).filter((t) => t.length > 0))]; + } + + if (Object.keys(next).length > 0) { + toolAllowlist = next; + } + } + + const normalized: WorkspaceMCPOverrides = { + disabledServers: disabledServers && disabledServers.length > 0 ? disabledServers : undefined, + enabledServers: enabledServers && enabledServers.length > 0 ? enabledServers : undefined, + toolAllowlist, + }; + + // Drop empty object to keep persistence clean. + if (!normalized.disabledServers && !normalized.enabledServers && !normalized.toolAllowlist) { + return {}; + } + + return normalized; +} + +function isEmptyOverrides(overrides: WorkspaceMCPOverrides): boolean { + return ( + (!overrides.disabledServers || overrides.disabledServers.length === 0) && + (!overrides.enabledServers || overrides.enabledServers.length === 0) && + (!overrides.toolAllowlist || Object.keys(overrides.toolAllowlist).length === 0) + ); +} + +async function statIsFile( + runtime: ReturnType, + filePath: string +): Promise { + try { + const stat = await runtime.stat(filePath); + return !stat.isDirectory; + } catch { + return false; + } +} + +export class WorkspaceMcpOverridesService { + constructor(private readonly config: Config) { + assert(config, "WorkspaceMcpOverridesService requires a Config instance"); + } + + private async getWorkspaceMetadata(workspaceId: string): Promise { + assert(typeof workspaceId === "string", "workspaceId must be a string"); + const trimmed = workspaceId.trim(); + assert(trimmed.length > 0, "workspaceId must not be empty"); + + const all = await this.config.getAllWorkspaceMetadata(); + const metadata = all.find((m) => m.id === trimmed); + if (!metadata) { + throw new Error(`Workspace metadata not found for ${trimmed}`); + } + + return metadata; + } + + private getLegacyOverridesFromConfig(workspaceId: string): WorkspaceMCPOverrides | undefined { + const config = this.config.loadConfigOrDefault(); + + for (const [_projectPath, projectConfig] of config.projects) { + const workspace = projectConfig.workspaces.find((w) => w.id === workspaceId); + if (workspace) { + // NOTE: Legacy storage (PR #1180) wrote overrides into ~/.mux/config.json. + // We keep reading it here only to migrate into the workspace-local file. + return workspace.mcp; + } + } + + return undefined; + } + + private async clearLegacyOverridesInConfig(workspaceId: string): Promise { + await this.config.editConfig((config) => { + for (const [_projectPath, projectConfig] of config.projects) { + const workspace = projectConfig.workspaces.find((w) => w.id === workspaceId); + if (workspace) { + delete workspace.mcp; + return config; + } + } + return config; + }); + } + + private async getRuntimeAndWorkspacePath(workspaceId: string): Promise<{ + metadata: FrontendWorkspaceMetadata; + runtime: ReturnType; + workspacePath: string; + }> { + const metadata = await this.getWorkspaceMetadata(workspaceId); + + const runtime = createRuntime( + metadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir }, + { projectPath: metadata.projectPath } + ); + + // In-place workspaces (CLI/benchmarks) store the workspace path directly by setting + // metadata.projectPath === metadata.name. + const isInPlace = metadata.projectPath === metadata.name; + const workspacePath = isInPlace + ? metadata.projectPath + : runtime.getWorkspacePath(metadata.projectPath, metadata.name); + + assert( + typeof workspacePath === "string" && workspacePath.length > 0, + "workspacePath is required" + ); + + return { metadata, runtime, workspacePath }; + } + + private getOverridesFilePaths( + workspacePath: string, + runtimeConfig: RuntimeConfig | undefined + ): { + jsoncPath: string; + jsonPath: string; + } { + assert(typeof workspacePath === "string", "workspacePath must be a string"); + + return { + jsoncPath: joinForRuntime( + runtimeConfig, + workspacePath, + MCP_OVERRIDES_DIR, + MCP_OVERRIDES_JSONC + ), + jsonPath: joinForRuntime(runtimeConfig, workspacePath, MCP_OVERRIDES_DIR, MCP_OVERRIDES_JSON), + }; + } + + private async readOverridesFile( + runtime: ReturnType, + filePath: string + ): Promise { + try { + const raw = await readFileString(runtime, filePath); + const errors: jsonc.ParseError[] = []; + const parsed: unknown = jsonc.parse(raw, errors) as unknown; + if (errors.length > 0) { + log.warn("[MCP] Failed to parse workspace MCP overrides (JSONC parse errors)", { + filePath, + errorCount: errors.length, + }); + return {}; + } + return parsed; + } catch (error) { + // Treat any read failure as "no overrides". + log.debug("[MCP] Failed to read workspace MCP overrides file", { filePath, error }); + return {}; + } + } + + private async ensureOverridesDir( + runtime: ReturnType, + workspacePath: string + ): Promise { + const result = await execBuffered(runtime, `mkdir -p "${MCP_OVERRIDES_DIR}"`, { + cwd: workspacePath, + timeout: 10, + }); + + if (result.exitCode !== 0) { + throw new Error(`Failed to create ${MCP_OVERRIDES_DIR} directory: ${result.stderr}`); + } + } + + private async removeOverridesFile( + runtime: ReturnType, + workspacePath: string + ): Promise { + // Best-effort: remove both file names so we never leave conflicting sources behind. + await execBuffered( + runtime, + `rm -f "${MCP_OVERRIDES_DIR}/${MCP_OVERRIDES_JSONC}" "${MCP_OVERRIDES_DIR}/${MCP_OVERRIDES_JSON}"`, + { + cwd: workspacePath, + timeout: 10, + } + ); + } + + /** + * Read workspace MCP overrides from /.mux/mcp.local.jsonc. + * + * If the file doesn't exist, we fall back to legacy overrides stored in ~/.mux/config.json + * and migrate them into the workspace-local file. + */ + async getOverridesForWorkspace(workspaceId: string): Promise { + const { metadata, runtime, workspacePath } = await this.getRuntimeAndWorkspacePath(workspaceId); + const { jsoncPath, jsonPath } = this.getOverridesFilePaths( + workspacePath, + metadata.runtimeConfig + ); + + // Prefer JSONC, then JSON. + const jsoncExists = await statIsFile(runtime, jsoncPath); + if (jsoncExists) { + const parsed = await this.readOverridesFile(runtime, jsoncPath); + return normalizeWorkspaceMcpOverrides(parsed); + } + + const jsonExists = await statIsFile(runtime, jsonPath); + if (jsonExists) { + const parsed = await this.readOverridesFile(runtime, jsonPath); + return normalizeWorkspaceMcpOverrides(parsed); + } + + // No workspace-local file => try migrating legacy config.json storage. + const legacy = this.getLegacyOverridesFromConfig(workspaceId); + if (!legacy || isEmptyOverrides(legacy)) { + return {}; + } + + const normalizedLegacy = normalizeWorkspaceMcpOverrides(legacy); + if (isEmptyOverrides(normalizedLegacy)) { + return {}; + } + + try { + await this.ensureOverridesDir(runtime, workspacePath); + await writeFileString(runtime, jsoncPath, JSON.stringify(normalizedLegacy, null, 2) + "\n"); + await this.clearLegacyOverridesInConfig(workspaceId); + log.info("[MCP] Migrated workspace MCP overrides from config.json", { + workspaceId, + filePath: jsoncPath, + }); + } catch (error) { + // Migration is best-effort; if it fails, still honor legacy overrides. + log.warn("[MCP] Failed to migrate workspace MCP overrides; using legacy config.json values", { + workspaceId, + error, + }); + } + + return normalizedLegacy; + } + + /** + * Persist workspace MCP overrides to /.mux/mcp.local.jsonc. + * + * Empty overrides remove the workspace-local file. + */ + async setOverridesForWorkspace( + workspaceId: string, + overrides: WorkspaceMCPOverrides + ): Promise { + assert(overrides && typeof overrides === "object", "overrides must be an object"); + + const { metadata, runtime, workspacePath } = await this.getRuntimeAndWorkspacePath(workspaceId); + const { jsoncPath } = this.getOverridesFilePaths(workspacePath, metadata.runtimeConfig); + + const normalized = normalizeWorkspaceMcpOverrides(overrides); + + // Always clear any legacy storage so we converge on the workspace-local file. + await this.clearLegacyOverridesInConfig(workspaceId); + + if (isEmptyOverrides(normalized)) { + await this.removeOverridesFile(runtime, workspacePath); + return; + } + + await this.ensureOverridesDir(runtime, workspacePath); + await writeFileString(runtime, jsoncPath, JSON.stringify(normalized, null, 2) + "\n"); + } +} diff --git a/tests/ipc/setup.ts b/tests/ipc/setup.ts index 9237bfde27..ba62bbbf9e 100644 --- a/tests/ipc/setup.ts +++ b/tests/ipc/setup.ts @@ -82,6 +82,7 @@ export async function createTestEnvironment(): Promise { tokenizerService: services.tokenizerService, serverService: services.serverService, featureFlagService: services.featureFlagService, + workspaceMcpOverridesService: services.workspaceMcpOverridesService, sessionTimingService: services.sessionTimingService, mcpConfigService: services.mcpConfigService, mcpServerManager: services.mcpServerManager, From fed04137ec124800691a6d6bd8d78d5f14442acc Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 23 Dec 2025 11:32:26 +0100 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=A4=96=20fix:=20gitignore=20workspace?= =?UTF-8?q?=20MCP=20overrides=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: Ieca914be849b7b355b59338d15114ec4e3b8fea7 Signed-off-by: Thomas Kosiewski --- docs/mcp-servers.mdx | 1 + .../workspaceMcpOverridesService.test.ts | 60 ++++++++++++++ .../services/workspaceMcpOverridesService.ts | 81 +++++++++++++++++++ 3 files changed, 142 insertions(+) diff --git a/docs/mcp-servers.mdx b/docs/mcp-servers.mdx index 5610621cb2..3f04befd70 100644 --- a/docs/mcp-servers.mdx +++ b/docs/mcp-servers.mdx @@ -59,6 +59,7 @@ Mux supports per-workspace MCP overrides (enable/disable servers and restrict to These overrides are stored in a workspace-local file: `.mux/mcp.local.jsonc`. - This file is intended to be **gitignored** (it contains local-only workspace preferences) +- When mux writes this file, it also adds it to the workspace's local git excludes (`.git/info/exclude`) so it doesn't get accidentally committed - Older mux versions stored these overrides in `~/.mux/config.json`; mux will migrate them into `.mux/mcp.local.jsonc` on first use This means you configure servers once per project, but each workspace (branch) gets isolated server instances with independent state. diff --git a/src/node/services/workspaceMcpOverridesService.test.ts b/src/node/services/workspaceMcpOverridesService.test.ts index 0f834ce015..1a2b5be719 100644 --- a/src/node/services/workspaceMcpOverridesService.test.ts +++ b/src/node/services/workspaceMcpOverridesService.test.ts @@ -3,6 +3,8 @@ import * as fs from "fs/promises"; import * as os from "os"; import * as path from "path"; import { Config } from "@/node/config"; +import { createRuntime } from "@/node/runtime/runtimeFactory"; +import { execBuffered } from "@/node/utils/runtime/helpers"; import { WorkspaceMcpOverridesService } from "./workspaceMcpOverridesService"; function getWorkspacePath(args: { @@ -68,6 +70,64 @@ describe("WorkspaceMcpOverridesService", () => { expect(await pathExists(path.join(workspacePath, ".mux", "mcp.local.jsonc"))).toBe(false); }); + it("adds .mux/mcp.local.jsonc to git exclude when writing overrides", async () => { + const projectPath = "/fake/project"; + const workspaceId = "ws-id"; + const workspaceName = "branch"; + + const workspacePath = getWorkspacePath({ + srcDir: config.srcDir, + projectName: "project", + workspaceName, + }); + await fs.mkdir(workspacePath, { recursive: true }); + + const runtime = createRuntime({ type: "local" }, { projectPath: workspacePath }); + const gitInitResult = await execBuffered(runtime, "git init", { + cwd: workspacePath, + timeout: 10, + }); + expect(gitInitResult.exitCode).toBe(0); + + await config.editConfig((cfg) => { + cfg.projects.set(projectPath, { + workspaces: [ + { + path: workspacePath, + id: workspaceId, + name: workspaceName, + runtimeConfig: { type: "worktree", srcBaseDir: config.srcDir }, + }, + ], + }); + return cfg; + }); + + const service = new WorkspaceMcpOverridesService(config); + + const excludePathResult = await execBuffered(runtime, "git rev-parse --git-path info/exclude", { + cwd: workspacePath, + timeout: 10, + }); + expect(excludePathResult.exitCode).toBe(0); + + const excludePathRaw = excludePathResult.stdout.trim(); + expect(excludePathRaw.length).toBeGreaterThan(0); + + const excludePath = path.isAbsolute(excludePathRaw) + ? excludePathRaw + : path.join(workspacePath, excludePathRaw); + + const before = (await pathExists(excludePath)) ? await fs.readFile(excludePath, "utf-8") : ""; + expect(before).not.toContain(".mux/mcp.local.jsonc"); + + await service.setOverridesForWorkspace(workspaceId, { + disabledServers: ["server-a"], + }); + + const after = await fs.readFile(excludePath, "utf-8"); + expect(after).toContain(".mux/mcp.local.jsonc"); + }); it("persists overrides to .mux/mcp.local.jsonc and reads them back", async () => { const projectPath = "/fake/project"; const workspaceId = "ws-id"; diff --git a/src/node/services/workspaceMcpOverridesService.ts b/src/node/services/workspaceMcpOverridesService.ts index bc7eccc73a..96f07e109f 100644 --- a/src/node/services/workspaceMcpOverridesService.ts +++ b/src/node/services/workspaceMcpOverridesService.ts @@ -13,12 +13,23 @@ const MCP_OVERRIDES_DIR = ".mux"; const MCP_OVERRIDES_JSONC = "mcp.local.jsonc"; const MCP_OVERRIDES_JSON = "mcp.local.json"; +const MCP_OVERRIDES_GITIGNORE_PATTERNS = [ + `${MCP_OVERRIDES_DIR}/${MCP_OVERRIDES_JSONC}`, + `${MCP_OVERRIDES_DIR}/${MCP_OVERRIDES_JSON}`, +]; + function joinForRuntime(runtimeConfig: RuntimeConfig | undefined, ...parts: string[]): string { assert(parts.length > 0, "joinForRuntime requires at least one path segment"); // For SSH runtimes, paths must remain POSIX even on Windows. return runtimeConfig?.type === "ssh" ? path.posix.join(...parts) : path.join(...parts); } +function isAbsoluteForRuntime(runtimeConfig: RuntimeConfig | undefined, filePath: string): boolean { + return runtimeConfig?.type === "ssh" + ? path.posix.isAbsolute(filePath) + : path.isAbsolute(filePath); +} + function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((v) => typeof v === "string"); } @@ -229,6 +240,74 @@ export class WorkspaceMcpOverridesService { } } + private async ensureOverridesGitignored( + runtime: ReturnType, + workspacePath: string, + runtimeConfig: RuntimeConfig | undefined + ): Promise { + try { + const isInsideGitResult = await execBuffered(runtime, "git rev-parse --is-inside-work-tree", { + cwd: workspacePath, + timeout: 10, + }); + if (isInsideGitResult.exitCode !== 0 || isInsideGitResult.stdout.trim() !== "true") { + return; + } + + const excludePathResult = await execBuffered( + runtime, + "git rev-parse --git-path info/exclude", + { + cwd: workspacePath, + timeout: 10, + } + ); + if (excludePathResult.exitCode !== 0) { + return; + } + + const excludeFilePathRaw = excludePathResult.stdout.trim(); + if (excludeFilePathRaw.length === 0) { + return; + } + + const excludeFilePath = isAbsoluteForRuntime(runtimeConfig, excludeFilePathRaw) + ? excludeFilePathRaw + : joinForRuntime(runtimeConfig, workspacePath, excludeFilePathRaw); + + let existing = ""; + try { + existing = await readFileString(runtime, excludeFilePath); + } catch { + // Missing exclude file is OK. + } + + const existingPatterns = new Set( + existing + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + ); + const missingPatterns = MCP_OVERRIDES_GITIGNORE_PATTERNS.filter( + (pattern) => !existingPatterns.has(pattern) + ); + if (missingPatterns.length === 0) { + return; + } + + const needsNewline = existing.length > 0 && !existing.endsWith("\n"); + const updated = existing + (needsNewline ? "\n" : "") + missingPatterns.join("\n") + "\n"; + + await writeFileString(runtime, excludeFilePath, updated); + } catch (error) { + // Best-effort only; never fail a workspace operation because git ignore couldn't be updated. + log.debug("[MCP] Failed to add workspace MCP overrides file to git exclude", { + workspacePath, + error, + }); + } + } + private async removeOverridesFile( runtime: ReturnType, workspacePath: string @@ -284,6 +363,7 @@ export class WorkspaceMcpOverridesService { try { await this.ensureOverridesDir(runtime, workspacePath); await writeFileString(runtime, jsoncPath, JSON.stringify(normalizedLegacy, null, 2) + "\n"); + await this.ensureOverridesGitignored(runtime, workspacePath, metadata.runtimeConfig); await this.clearLegacyOverridesInConfig(workspaceId); log.info("[MCP] Migrated workspace MCP overrides from config.json", { workspaceId, @@ -326,5 +406,6 @@ export class WorkspaceMcpOverridesService { await this.ensureOverridesDir(runtime, workspacePath); await writeFileString(runtime, jsoncPath, JSON.stringify(normalized, null, 2) + "\n"); + await this.ensureOverridesGitignored(runtime, workspacePath, metadata.runtimeConfig); } }