feat(cli): add repeatable --disable-mcp-server to exclude servers per invocation (#14003)

* feat(cli): add repeatable --disable-mcp-server to exclude servers per invocation

Adds a repeatable `--disable-mcp-server <name>` flag that excludes exactly the
named MCP server(s) for a single invocation without mutating settings.json.

The union into `excludedMcpServers` lives in its OWN block after the existing
`if (argv.allowedMcpServerNames) / else if (!bareMode && !safeMode)` chain, not
inside the settings branch. Folded in there the flag would be silently dropped
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. This keeps the flag aligned with the reasoning already documented at
that chain -- an explicit per-invocation argument, not local state, so it still
applies under safe mode. Precedence is allow-then-deny: a server named by both
flags ends up disabled.

Exclusion is a disabled-status flag, not removal from the server map, matching
the existing `settings.mcp.excluded` contract (`isMcpServerDisabled()`).

Also registers the option in DEFAULT_COMMAND_OPTIONS so it reaches `qwen --help`
(TOP_LEVEL_HELP_OPTIONS flattens that object), and adds the new required CliArgs
field to the five full CliArgs literals in llm.test.tsx.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-issue-patrol/jmv5mg6lo0v

* test(cli): pin the lossy hot-reload behaviour of --disable-mcp-server

The flag unions into the exclude set at boot only. recomputeMcpGating
derives `excluded` from settings alone and the settings-watcher
subscriber overwrites the boot-populated set with it, so the first
settings.json edit drops the entry and reconnects the server the user
disabled. Because boot's set differs from the recomputed one,
gatingChanged is true for any settings.json edit.

Hot-reload durability is explicitly out of scope for this PR, so this
pins the current behaviour as a characterization test and documents the
caveat at the boot union site. The durable fix (mirroring
cliAllowedMcpServerNames on the exclude side) is tracked in #14009.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmv5vqjmz0q

---------

Co-authored-by: yiliang114 <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-13 09:33:09 +00:00
1 parent 2eee9b7186
commit afe4dd7113
5 files changed
+159

No files matched your search

+102
View File
@@ -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();
+26
View File
@@ -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<CliArgs> {
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) ||
@@ -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);
@@ -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:
+5
View File
@@ -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,