diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index ad4dbde687c..b52b6ceb43f 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -4916,6 +4916,108 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { expect(config.isMcpServerDisabled('server3')).toBe(false); }); + it('should exclude exactly the named server via --disable-mcp-server', async () => { + process.argv = ['node', 'script.js', '--disable-mcp-server', 'server1']; + const argv = await parseArguments(); + const config = await loadCliConfig(baseSettings, argv, undefined, []); + // Exclusion is a disabled-status flag, not removal from the server map — + // the same contract as settings.mcp.excluded above. + expect(config.getMcpServers()).toEqual({ + server1: { url: 'http://localhost:8080' }, + server2: { url: 'http://localhost:8081' }, + server3: { url: 'http://localhost:8082' }, + }); + expect(config.isMcpServerDisabled('server1')).toBe(true); + expect(config.isMcpServerDisabled('server2')).toBe(false); + expect(config.isMcpServerDisabled('server3')).toBe(false); + }); + + it('should exclude multiple servers with repeated --disable-mcp-server flags', async () => { + process.argv = [ + 'node', + 'script.js', + '--disable-mcp-server', + 'server1', + '--disable-mcp-server', + 'server2', + ]; + const argv = await parseArguments(); + const config = await loadCliConfig(baseSettings, argv, undefined, []); + expect(config.isMcpServerDisabled('server1')).toBe(true); + expect(config.isMcpServerDisabled('server2')).toBe(true); + expect(config.isMcpServerDisabled('server3')).toBe(false); + }); + + it('should union --disable-mcp-server with settings mcp.excluded', async () => { + process.argv = ['node', 'script.js', '--disable-mcp-server', 'server2']; + const argv = await parseArguments(); + const settings: Settings = { + ...baseSettings, + mcp: { excluded: ['server1'] }, + }; + const config = await loadCliConfig(settings, argv, undefined, []); + expect(config.isMcpServerDisabled('server1')).toBe(true); + expect(config.isMcpServerDisabled('server2')).toBe(true); + expect(config.isMcpServerDisabled('server3')).toBe(false); + }); + + it('should disable nothing when --disable-mcp-server is absent', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const config = await loadCliConfig(baseSettings, argv, undefined, []); + expect(config.isMcpServerDisabled('server1')).toBe(false); + expect(config.isMcpServerDisabled('server2')).toBe(false); + expect(config.isMcpServerDisabled('server3')).toBe(false); + }); + + it('should apply --disable-mcp-server alongside an allow-list (allow-then-deny)', async () => { + process.argv = [ + 'node', + 'script.js', + '--allowed-mcp-server-names', + 'server1,server2', + '--disable-mcp-server', + 'server1', + ]; + const argv = await parseArguments(); + const config = await loadCliConfig(baseSettings, argv, undefined, []); + // The allow-list narrows the returned map; the disable flag still applies + // on top of it, so a server named by both ends up disabled. + expect(config.getMcpServers()).toEqual({ + server1: { url: 'http://localhost:8080' }, + server2: { url: 'http://localhost:8081' }, + }); + expect(config.isMcpServerDisabled('server1')).toBe(true); + expect(config.isMcpServerDisabled('server2')).toBe(false); + }); + + it('should still apply --disable-mcp-server under --safe-mode', async () => { + // Safe mode drops settings.mcpServers entirely (only top-tier session and + // `--mcp-config` servers survive), so these servers come from --mcp-config. + process.argv = [ + 'node', + 'script.js', + '--safe-mode', + '--disable-mcp-server', + 'cli-server-1', + '--mcp-config', + JSON.stringify({ + mcpServers: { + 'cli-server-1': { command: 'cmd-1' }, + 'cli-server-2': { command: 'cmd-2' }, + }, + }), + ]; + const argv = await parseArguments(); + const config = await loadCliConfig(baseSettings, argv, undefined, []); + expect(config.getMcpServers()).toEqual({ + 'cli-server-1': { command: 'cmd-1' }, + 'cli-server-2': { command: 'cmd-2' }, + }); + expect(config.isMcpServerDisabled('cli-server-1')).toBe(true); + expect(config.isMcpServerDisabled('cli-server-2')).toBe(false); + }); + it('should apply allowedMcpServers filter but excluded servers are still returned', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 115d57559a8..664a2cc76fa 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -189,6 +189,7 @@ export interface CliArgs { telemetryLogPrompts: boolean | undefined; telemetryOutfile: string | undefined; allowedMcpServerNames: string[] | undefined; + disableMcpServer: string[] | undefined; mcpConfig: string | undefined; allowedTools: string[] | undefined; acp: boolean | undefined; @@ -679,6 +680,15 @@ export async function parseArguments(): Promise { mcpServerName.split(',').map((m) => m.trim()), ), }) + .option('disable-mcp-server', { + ...DEFAULT_COMMAND_OPTIONS['disable-mcp-server'], + string: true, + coerce: (disabledServerNames: string[]) => + // Handle comma-separated values + disabledServerNames.flatMap((disabledServerName) => + disabledServerName.split(',').map((m) => m.trim()), + ), + }) .option('mcp-config', DEFAULT_COMMAND_OPTIONS['mcp-config']) .option('allowed-tools', { ...DEFAULT_COMMAND_OPTIONS['allowed-tools'], @@ -2191,6 +2201,22 @@ export async function loadCliConfig( ? new Set(settings.mcp.excluded.filter(Boolean)) : undefined; } + // `--disable-mcp-server` is an explicit per-invocation argument, not local + // state, so it unions into the exclude set in its OWN block after the + // if/else-if chain above. Folding it into the `else if (!bareMode && + // !safeMode)` branch would silently drop it twice over: under `--safe-mode` + // that branch does not run at all, and next to `--allowed-mcp-server-names` + // the first branch wins and leaves the set undefined. Precedence is + // allow-then-deny — a server named by both flags ends up disabled. + // Boot-time only: the settings-watcher and ACP reload paths rebuild the + // exclude set from settings alone, so a later settings.json edit drops these + // entries. Durable fix tracked in #14009. + if (argv.disableMcpServer) { + excludedMcpServers = new Set([ + ...(excludedMcpServers ?? []), + ...argv.disableMcpServer.filter(Boolean), + ]); + } const selectedAuthType = (argv.authType as AuthType | undefined) || diff --git a/packages/cli/src/config/hot-reload.test.ts b/packages/cli/src/config/hot-reload.test.ts index eec540c1ae3..54a65491e7d 100644 --- a/packages/cli/src/config/hot-reload.test.ts +++ b/packages/cli/src/config/hot-reload.test.ts @@ -439,6 +439,28 @@ describe('registerMcpHotReload', () => { expect(fc.reinitializeMcpServers).toHaveBeenCalledOnce(); }); + // CHARACTERIZATION, not a contract: `--disable-mcp-server` populates the + // exclude set at boot only, and `recomputeMcpGating` has no launch-flag + // input for it — unlike `cliAllowedMcpServerNames` on the allow side above. + // Pinned so the gap stays visible; the durable fix is #14009. + it('CHARACTERIZATION: a settings edit drops a boot --disable-mcp-server exclusion', async () => { + // Pre-image gating mimics `qwen --disable-mcp-server a`: boot populated the + // exclude set while settings carry no `mcp.excluded` at all. + const fc = makeFakeConfig(cwd, { + settingsMcp: { a: { command: 'a' } }, + gating: { excluded: ['a'] }, + }); + registerMcpHotReload(watcher, settings, fc.config, undefined); + + // Nothing MCP-relevant changed, yet the edit still reconciles — purely + // because the boot exclusion is not preserved. + merged.mcpServers = { a: { command: 'a' } }; + await listener([]); + + expect(fc.setExcludedMcpServers).toHaveBeenCalledWith([]); + expect(fc.reinitializeMcpServers).toHaveBeenCalledOnce(); + }); + it('emits McpPendingApprovalChanged when a gated server becomes newly pending', async () => { const fc = makeFakeConfig(cwd, { settingsMcp: {}, gating: {} }); registerMcpHotReload(watcher, settings, fc.config, undefined); diff --git a/packages/cli/src/config/top-level-options.ts b/packages/cli/src/config/top-level-options.ts index eef9236c262..84e4d4b7d0e 100644 --- a/packages/cli/src/config/top-level-options.ts +++ b/packages/cli/src/config/top-level-options.ts @@ -279,6 +279,10 @@ export const DEFAULT_COMMAND_OPTIONS = { type: 'array' as const, description: 'Allowed MCP server names', }, + 'disable-mcp-server': { + type: 'array' as const, + description: 'MCP server names to disable for this invocation', + }, 'mcp-config': { type: 'string' as const, description: diff --git a/packages/cli/src/llm.test.tsx b/packages/cli/src/llm.test.tsx index 2eb742e9c38..3ef1316476f 100644 --- a/packages/cli/src/llm.test.tsx +++ b/packages/cli/src/llm.test.tsx @@ -2859,6 +2859,7 @@ describe('llm.tsx OpenTUI renderer dispatch', () => { telemetryLogPrompts: undefined, telemetryOutfile: undefined, allowedMcpServerNames: undefined, + disableMcpServer: undefined, mcpConfig: undefined, allowedTools: undefined, acp: undefined, @@ -3088,6 +3089,7 @@ describe('llm.tsx main function kitty protocol', () => { telemetryLogPrompts: undefined, telemetryOutfile: undefined, allowedMcpServerNames: undefined, + disableMcpServer: undefined, mcpConfig: undefined, allowedTools: undefined, acp: undefined, @@ -3218,6 +3220,7 @@ describe('llm.tsx main function kitty protocol', () => { telemetryLogPrompts: undefined, telemetryOutfile: undefined, allowedMcpServerNames: undefined, + disableMcpServer: undefined, mcpConfig: undefined, allowedTools: undefined, acp: undefined, @@ -3347,6 +3350,7 @@ describe('llm.tsx main function kitty protocol', () => { telemetryLogPrompts: undefined, telemetryOutfile: undefined, allowedMcpServerNames: undefined, + disableMcpServer: undefined, mcpConfig: undefined, allowedTools: undefined, acp: undefined, @@ -3472,6 +3476,7 @@ describe('llm.tsx main function kitty protocol', () => { telemetryLogPrompts: undefined, telemetryOutfile: undefined, allowedMcpServerNames: undefined, + disableMcpServer: undefined, mcpConfig: undefined, allowedTools: undefined, acp: undefined,