diff --git a/docs/developers/tools/mcp-server.md b/docs/developers/tools/mcp-server.md index 8d48970a78..866c2f4c9c 100644 --- a/docs/developers/tools/mcp-server.md +++ b/docs/developers/tools/mcp-server.md @@ -869,3 +869,39 @@ qwen mcp remove my-server ``` This will find and delete the "my-server" entry from the `mcpServers` object in the appropriate `settings.json` file based on the scope (`-s, --scope`). + +### Enabling a Server (`qwen mcp enable`) + +To re-enable a server that was previously excluded, use `enable`. This removes the server name from `mcp.excluded` in either user or project settings. + +**Command:** + +```bash +qwen mcp enable [options] +``` + +- `-s, --scope`: Configuration scope (user or project). [default: "user"] + +**Example:** + +```bash +qwen mcp enable github --scope user +``` + +### Disabling a Server (`qwen mcp disable`) + +To disable a configured server without removing its configuration, use `disable`. This adds the server name to `mcp.excluded` in either user or project settings. + +**Command:** + +```bash +qwen mcp disable [options] +``` + +- `-s, --scope`: Configuration scope (user or project). [default: "user"] + +**Example:** + +```bash +qwen mcp disable github --scope user +``` diff --git a/packages/cli/src/commands/mcp.test.ts b/packages/cli/src/commands/mcp.test.ts index b4e9980cb9..ee267d9c20 100644 --- a/packages/cli/src/commands/mcp.test.ts +++ b/packages/cli/src/commands/mcp.test.ts @@ -28,7 +28,7 @@ describe('mcp command', () => { expect(options.key).toHaveProperty('help'); }); - it('should register add, remove, and list subcommands', () => { + it('should register add, enable, disable, remove, and list subcommands', () => { const mockYargs = { command: vi.fn().mockReturnThis(), demandCommand: vi.fn().mockReturnThis(), @@ -37,13 +37,15 @@ describe('mcp command', () => { mcpCommand.builder(mockYargs as unknown as Argv); - expect(mockYargs.command).toHaveBeenCalledTimes(3); + expect(mockYargs.command).toHaveBeenCalledTimes(5); // Verify that the specific subcommands are registered const commandCalls = mockYargs.command.mock.calls; const commandNames = commandCalls.map((call) => call[0].command); expect(commandNames).toContain('add [args...]'); + expect(commandNames).toContain('enable '); + expect(commandNames).toContain('disable '); expect(commandNames).toContain('remove '); expect(commandNames).toContain('list'); diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts index 5e55286c1d..9269774df6 100644 --- a/packages/cli/src/commands/mcp.ts +++ b/packages/cli/src/commands/mcp.ts @@ -7,6 +7,8 @@ // File for 'gemini mcp' command import type { CommandModule, Argv } from 'yargs'; import { addCommand } from './mcp/add.js'; +import { disableCommand } from './mcp/disable.js'; +import { enableCommand } from './mcp/enable.js'; import { removeCommand } from './mcp/remove.js'; import { listCommand } from './mcp/list.js'; @@ -16,6 +18,8 @@ export const mcpCommand: CommandModule = { builder: (yargs: Argv) => yargs .command(addCommand) + .command(enableCommand) + .command(disableCommand) .command(removeCommand) .command(listCommand) .demandCommand(1, 'You need at least one command before continuing.') diff --git a/packages/cli/src/commands/mcp/disable.test.ts b/packages/cli/src/commands/mcp/disable.test.ts new file mode 100644 index 0000000000..d62545cbd5 --- /dev/null +++ b/packages/cli/src/commands/mcp/disable.test.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; +import yargs from 'yargs'; +import type { Argv } from 'yargs'; +import { loadSettings, SettingScope } from '../../config/settings.js'; +import { disableCommand } from './disable.js'; + +const mockWriteStdoutLine = vi.hoisted(() => vi.fn()); +const mockWriteStderrLine = vi.hoisted(() => vi.fn()); + +vi.mock('../../utils/stdioHelpers.js', () => ({ + writeStdoutLine: mockWriteStdoutLine, + writeStderrLine: mockWriteStderrLine, + clearScreen: vi.fn(), +})); + +vi.mock('fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readFile: vi.fn(), + writeFile: vi.fn(), + }; +}); + +vi.mock('../../config/settings.js', async () => { + const actual = await vi.importActual('../../config/settings.js'); + return { + ...actual, + loadSettings: vi.fn(), + }; +}); + +const mockedLoadSettings = loadSettings as Mock; + +describe('mcp disable command', () => { + let parser: Argv; + let mockSetValue: Mock; + let mockSettings: Record; + + beforeEach(() => { + vi.resetAllMocks(); + const yargsInstance = yargs([]).command(disableCommand); + parser = yargsInstance; + mockSetValue = vi.fn(); + mockSettings = { + mcp: { + excluded: ['other-server'], + }, + }; + mockedLoadSettings.mockReturnValue({ + forScope: () => ({ settings: mockSettings }), + setValue: mockSetValue, + }); + mockWriteStdoutLine.mockClear(); + }); + + it('should disable a server in user settings by default', async () => { + await parser.parseAsync('disable test-server'); + + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.User, + 'mcp.excluded', + ['other-server', 'test-server'], + ); + expect(mockWriteStdoutLine).toHaveBeenCalledWith( + 'Server "test-server" disabled in user settings.', + ); + }); + + it('should disable a server in project settings when --scope project is provided', async () => { + await parser.parseAsync('disable test-server --scope project'); + + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'mcp.excluded', + ['other-server', 'test-server'], + ); + expect(mockWriteStdoutLine).toHaveBeenCalledWith( + 'Server "test-server" disabled in project settings.', + ); + }); + + it('should be a no-op if server is already disabled', async () => { + await parser.parseAsync('disable other-server'); + + expect(mockSetValue).not.toHaveBeenCalled(); + expect(mockWriteStdoutLine).toHaveBeenCalledWith( + 'Server "other-server" is already disabled in user settings.', + ); + }); +}); diff --git a/packages/cli/src/commands/mcp/disable.ts b/packages/cli/src/commands/mcp/disable.ts new file mode 100644 index 0000000000..0926d8a5e7 --- /dev/null +++ b/packages/cli/src/commands/mcp/disable.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// File for 'qwen mcp disable' command +import type { CommandModule } from 'yargs'; +import { loadSettings, SettingScope } from '../../config/settings.js'; +import { writeStdoutLine } from '../../utils/stdioHelpers.js'; + +async function disableMcpServer( + name: string, + options: { + scope: string; + }, +) { + const { scope } = options; + const settingsScope = + scope === 'user' ? SettingScope.User : SettingScope.Workspace; + const settings = loadSettings(); + + const existingSettings = settings.forScope(settingsScope).settings; + const excluded = existingSettings.mcp?.excluded || []; + + if (excluded.includes(name)) { + writeStdoutLine( + `Server "${name}" is already disabled in ${scope} settings.`, + ); + return; + } + + settings.setValue(settingsScope, 'mcp.excluded', [...excluded, name]); + writeStdoutLine(`Server "${name}" disabled in ${scope} settings.`); +} + +export const disableCommand: CommandModule = { + command: 'disable ', + describe: 'Disable a server by adding it to mcp.excluded', + builder: (yargs) => + yargs + .usage('Usage: qwen mcp disable [options] ') + .positional('name', { + describe: 'Name of the server', + type: 'string', + demandOption: true, + }) + .option('scope', { + alias: 's', + describe: 'Configuration scope (user or project)', + type: 'string', + default: 'user', + choices: ['user', 'project'], + }), + handler: async (argv) => { + await disableMcpServer(argv['name'] as string, { + scope: argv['scope'] as string, + }); + }, +}; diff --git a/packages/cli/src/commands/mcp/enable.test.ts b/packages/cli/src/commands/mcp/enable.test.ts new file mode 100644 index 0000000000..b30626bafb --- /dev/null +++ b/packages/cli/src/commands/mcp/enable.test.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; +import yargs from 'yargs'; +import type { Argv } from 'yargs'; +import { loadSettings, SettingScope } from '../../config/settings.js'; +import { enableCommand } from './enable.js'; + +const mockWriteStdoutLine = vi.hoisted(() => vi.fn()); +const mockWriteStderrLine = vi.hoisted(() => vi.fn()); + +vi.mock('../../utils/stdioHelpers.js', () => ({ + writeStdoutLine: mockWriteStdoutLine, + writeStderrLine: mockWriteStderrLine, + clearScreen: vi.fn(), +})); + +vi.mock('fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readFile: vi.fn(), + writeFile: vi.fn(), + }; +}); + +vi.mock('../../config/settings.js', async () => { + const actual = await vi.importActual('../../config/settings.js'); + return { + ...actual, + loadSettings: vi.fn(), + }; +}); + +const mockedLoadSettings = loadSettings as Mock; + +describe('mcp enable command', () => { + let parser: Argv; + let mockSetValue: Mock; + let mockSettings: Record; + + beforeEach(() => { + vi.resetAllMocks(); + const yargsInstance = yargs([]).command(enableCommand); + parser = yargsInstance; + mockSetValue = vi.fn(); + mockSettings = { + mcp: { + excluded: ['test-server', 'other-server'], + }, + }; + mockedLoadSettings.mockReturnValue({ + forScope: () => ({ settings: mockSettings }), + setValue: mockSetValue, + }); + mockWriteStdoutLine.mockClear(); + }); + + it('should enable a server in user settings by default', async () => { + await parser.parseAsync('enable test-server'); + + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.User, + 'mcp.excluded', + ['other-server'], + ); + expect(mockWriteStdoutLine).toHaveBeenCalledWith( + 'Server "test-server" enabled in user settings.', + ); + }); + + it('should enable a server in project settings when --scope project is provided', async () => { + await parser.parseAsync('enable test-server --scope project'); + + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'mcp.excluded', + ['other-server'], + ); + expect(mockWriteStdoutLine).toHaveBeenCalledWith( + 'Server "test-server" enabled in project settings.', + ); + }); + + it('should be a no-op if server is already enabled', async () => { + await parser.parseAsync('enable missing-server'); + + expect(mockSetValue).not.toHaveBeenCalled(); + expect(mockWriteStdoutLine).toHaveBeenCalledWith( + 'Server "missing-server" is already enabled in user settings.', + ); + }); +}); diff --git a/packages/cli/src/commands/mcp/enable.ts b/packages/cli/src/commands/mcp/enable.ts new file mode 100644 index 0000000000..ad517589ce --- /dev/null +++ b/packages/cli/src/commands/mcp/enable.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// File for 'qwen mcp enable' command +import type { CommandModule } from 'yargs'; +import { loadSettings, SettingScope } from '../../config/settings.js'; +import { writeStdoutLine } from '../../utils/stdioHelpers.js'; + +async function enableMcpServer( + name: string, + options: { + scope: string; + }, +) { + const { scope } = options; + const settingsScope = + scope === 'user' ? SettingScope.User : SettingScope.Workspace; + const settings = loadSettings(); + + const existingSettings = settings.forScope(settingsScope).settings; + const excluded = existingSettings.mcp?.excluded || []; + + if (!excluded.includes(name)) { + writeStdoutLine(`Server "${name}" is already enabled in ${scope} settings.`); + return; + } + + settings.setValue( + settingsScope, + 'mcp.excluded', + excluded.filter((serverName) => serverName !== name), + ); + writeStdoutLine(`Server "${name}" enabled in ${scope} settings.`); +} + +export const enableCommand: CommandModule = { + command: 'enable ', + describe: 'Enable a server by removing it from mcp.excluded', + builder: (yargs) => + yargs + .usage('Usage: qwen mcp enable [options] ') + .positional('name', { + describe: 'Name of the server', + type: 'string', + demandOption: true, + }) + .option('scope', { + alias: 's', + describe: 'Configuration scope (user or project)', + type: 'string', + default: 'user', + choices: ['user', 'project'], + }), + handler: async (argv) => { + await enableMcpServer(argv['name'] as string, { + scope: argv['scope'] as string, + }); + }, +};