diff --git a/docs/tools/mcp-server.md b/docs/tools/mcp-server.md index d9d8835c8ce..1dbfdf11f08 100644 --- a/docs/tools/mcp-server.md +++ b/docs/tools/mcp-server.md @@ -1204,6 +1204,29 @@ provide tools. Enablement state is stored in The same commands are available as slash commands during an active session: `/mcp enable ` and `/mcp disable `. +### Removing a server (`/mcp remove`) + +During an interactive session you can permanently delete an MCP server entry +from your settings without leaving the CLI: + +```text +/mcp remove [--scope user|workspace|all] +``` + +Aliases: `/mcp rm `. + +If the server is only defined in one scope (workspace or user) it is removed +automatically. If it is defined in **both** scopes, the command refuses and asks +you to pass `--scope user`, `--scope workspace`, or `--scope all` to choose. +This avoids the surprise where deleting the workspace copy leaves a forgotten +"zombie" copy in your user settings still active after the next restart. + +Servers contributed by extensions cannot be removed this way — disable the +extension instead. + +After removal the MCP client manager is restarted so the server is disconnected +immediately and its tools are no longer available. + ## Instructions Gemini CLI supports diff --git a/packages/cli/src/ui/commands/mcpCommand.test.ts b/packages/cli/src/ui/commands/mcpCommand.test.ts index d082c4ed09c..e8abf7d739c 100644 --- a/packages/cli/src/ui/commands/mcpCommand.test.ts +++ b/packages/cli/src/ui/commands/mcpCommand.test.ts @@ -15,6 +15,16 @@ import { DiscoveredMCPTool, type MessageBus, } from '@google/gemini-cli-core'; +import { loadSettings, SettingScope } from '../../config/settings.js'; + +vi.mock('../../config/settings.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadSettings: vi.fn(), + }; +}); import type { CallableTool } from '@google/genai'; import { MessageType, type HistoryItemMcpStatus } from '../types.js'; @@ -317,4 +327,189 @@ describe('mcpCommand', () => { expect(Object.keys(call.servers)).toEqual(['server2']); }); }); + + describe('remove subcommand', () => { + const findRemove = () => + mcpCommand.subCommands!.find((c) => c.name === 'remove')!; + + let setValueMock: ReturnType; + let restartMock: ReturnType; + let scopeContents: Record>; + + const installSettingsMock = () => { + vi.mocked(loadSettings).mockReturnValue({ + forScope: (scope: SettingScope) => ({ + settings: { mcpServers: scopeContents[scope] ?? {} }, + }), + setValue: setValueMock, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + }; + + beforeEach(() => { + setValueMock = vi.fn(); + restartMock = vi.fn().mockResolvedValue(undefined); + + const mockMcpServers = { + server1: { command: 'cmd1' }, + ext1: { command: 'cmd2', extension: { name: 'my-ext' } }, + }; + mockConfig.getMcpClientManager = vi.fn().mockReturnValue({ + getMcpServers: vi.fn().mockReturnValue(mockMcpServers), + getBlockedMcpServers: vi.fn().mockReturnValue([]), + getLastError: vi.fn().mockReturnValue(undefined), + restart: restartMock, + }); + + // Default: server1 only in workspace + scopeContents = { + [SettingScope.Workspace]: { server1: { command: 'cmd1' } }, + [SettingScope.User]: {}, + }; + installSettingsMock(); + }); + + it('errors when no server name is provided', async () => { + const result = await findRemove().action!(mockContext, ''); + expect(result).toMatchObject({ + messageType: 'error', + content: expect.stringContaining('Server name required'), + }); + }); + + it('errors when server is not configured', async () => { + const result = await findRemove().action!(mockContext, 'unknown'); + expect(result).toMatchObject({ + messageType: 'error', + content: expect.stringContaining("'unknown' not found"), + }); + }); + + it('errors when server is provided by an extension', async () => { + const result = await findRemove().action!(mockContext, 'ext1'); + expect(result).toMatchObject({ + messageType: 'error', + content: expect.stringContaining('extension'), + }); + expect(setValueMock).not.toHaveBeenCalled(); + }); + + it('removes the server from workspace settings and reloads', async () => { + const result = await findRemove().action!(mockContext, 'server1'); + expect(setValueMock).toHaveBeenCalledWith( + SettingScope.Workspace, + 'mcpServers', + {}, + ); + expect(restartMock).toHaveBeenCalled(); + expect(mockContext.ui.reloadCommands).toHaveBeenCalled(); + expect(result).toMatchObject({ + messageType: 'info', + content: expect.stringContaining("'server1' removed"), + }); + }); + + it('errors when server is not in user or workspace settings (e.g. system scope only)', async () => { + scopeContents = { + [SettingScope.Workspace]: {}, + [SettingScope.User]: {}, + }; + installSettingsMock(); + const result = await findRemove().action!(mockContext, 'server1'); + expect(result).toMatchObject({ + messageType: 'error', + content: expect.stringContaining('not defined in user or workspace'), + }); + expect(setValueMock).not.toHaveBeenCalled(); + }); + + it('refuses to delete when server exists in both scopes without explicit --scope', async () => { + scopeContents = { + [SettingScope.Workspace]: { server1: { command: 'cmd1' } }, + [SettingScope.User]: { server1: { command: 'cmd1-user' } }, + }; + installSettingsMock(); + const result = await findRemove().action!(mockContext, 'server1'); + expect(result).toMatchObject({ + messageType: 'error', + content: expect.stringContaining('BOTH workspace and user'), + }); + expect(setValueMock).not.toHaveBeenCalled(); + }); + + it('removes from user scope when --scope user is given', async () => { + scopeContents = { + [SettingScope.Workspace]: { server1: { command: 'cmd1' } }, + [SettingScope.User]: { server1: { command: 'cmd1-user' } }, + }; + installSettingsMock(); + const result = await findRemove().action!( + mockContext, + 'server1 --scope user', + ); + expect(setValueMock).toHaveBeenCalledTimes(1); + expect(setValueMock).toHaveBeenCalledWith( + SettingScope.User, + 'mcpServers', + {}, + ); + expect(result).toMatchObject({ messageType: 'info' }); + }); + + it('removes from both scopes when --scope all is given', async () => { + scopeContents = { + [SettingScope.Workspace]: { server1: { command: 'cmd1' } }, + [SettingScope.User]: { server1: { command: 'cmd1-user' } }, + }; + installSettingsMock(); + const result = await findRemove().action!( + mockContext, + 'server1 --scope all', + ); + expect(setValueMock).toHaveBeenCalledTimes(2); + expect(setValueMock).toHaveBeenCalledWith( + SettingScope.Workspace, + 'mcpServers', + {}, + ); + expect(setValueMock).toHaveBeenCalledWith( + SettingScope.User, + 'mcpServers', + {}, + ); + expect(result).toMatchObject({ + messageType: 'info', + content: expect.stringContaining('workspace and user'), + }); + }); + + it('errors when --scope user is given but server is not in user scope', async () => { + // Default scopeContents: only in workspace + const result = await findRemove().action!( + mockContext, + 'server1 --scope user', + ); + expect(result).toMatchObject({ + messageType: 'error', + content: expect.stringContaining('not defined in user settings'), + }); + expect(setValueMock).not.toHaveBeenCalled(); + }); + + it('errors on invalid --scope value', async () => { + const result = await findRemove().action!( + mockContext, + 'server1 --scope global', + ); + expect(result).toMatchObject({ + messageType: 'error', + content: expect.stringContaining('Invalid --scope value'), + }); + }); + + it('completion returns servers without extension origin matching prefix', async () => { + const out = await findRemove().completion!(mockContext, 'ser'); + expect(out).toEqual(['server1']); + }); + }); }); diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index 3fd214152e4..3a544a97609 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -30,7 +30,7 @@ import { normalizeServerId, canLoadServer, } from '../../config/mcp/mcpServerEnablement.js'; -import { loadSettings } from '../../config/settings.js'; +import { loadSettings, SettingScope } from '../../config/settings.js'; import { parseSlashCommand } from '../../utils/commands.js'; const authCommand: SlashCommand = { @@ -533,6 +533,192 @@ const disableCommand: SlashCommand = { completion: (ctx, arg) => getEnablementCompletion(ctx, arg, true), }; +async function handleRemove( + context: CommandContext, + args: string, +): Promise { + const agentContext = context.services.agentContext; + const config = agentContext?.config; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }; + } + + config.setUserInteractedWithMcp(); + + // Parse args: [--scope user|workspace|all] + const tokens = args.trim().split(/\s+/).filter(Boolean); + let explicitScope: 'user' | 'workspace' | 'all' | null = null; + const positional: string[] = []; + for (let i = 0; i < tokens.length; i++) { + const t = tokens[i]; + if (t === '--scope' || t === '-s') { + const v = tokens[++i]; + if (v !== 'user' && v !== 'workspace' && v !== 'all') { + return { + type: 'message', + messageType: 'error', + content: `Invalid --scope value '${v ?? ''}'. Expected: user, workspace, or all.`, + }; + } + explicitScope = v; + } else if (t.startsWith('--scope=')) { + const v = t.slice('--scope='.length); + if (v !== 'user' && v !== 'workspace' && v !== 'all') { + return { + type: 'message', + messageType: 'error', + content: `Invalid --scope value '${v}'. Expected: user, workspace, or all.`, + }; + } + explicitScope = v; + } else { + positional.push(t); + } + } + + const serverName = positional[0]; + if (!serverName) { + return { + type: 'message', + messageType: 'error', + content: + 'Server name required. Usage: /mcp remove [--scope user|workspace|all]', + }; + } + + const name = normalizeServerId(serverName); + const servers = config.getMcpClientManager()?.getMcpServers() ?? {}; + const matchedKey = Object.keys(servers).find( + (n) => normalizeServerId(n) === name, + ); + if (!matchedKey) { + return { + type: 'message', + messageType: 'error', + content: `Server '${serverName}' not found. Use /mcp list to see available servers.`, + }; + } + + // Servers contributed by extensions live in the extension manifest, not in + // the user/workspace settings file, so they cannot be removed via /mcp. + const extensionInfo = servers[matchedKey]?.extension; + if (extensionInfo) { + return { + type: 'message', + messageType: 'error', + content: `'${matchedKey}' is provided by extension '${extensionInfo.name}'. Use /extensions to manage it.`, + }; + } + + // Detect which physical settings files actually contain this entry. The + // entry can live in workspace, user, or both (in which case workspace + // overrides user via shallow merge but the user copy stays as a "zombie"). + const settings = loadSettings(); + type RemovableScope = SettingScope.Workspace | SettingScope.User; + const scopeOrder: readonly RemovableScope[] = [ + SettingScope.Workspace, + SettingScope.User, + ]; + const foundIn: RemovableScope[] = []; + for (const scope of scopeOrder) { + const file = settings.forScope(scope); + const scoped = (file.settings.mcpServers ?? {}) as Record; + if (matchedKey in scoped) foundIn.push(scope); + } + + if (foundIn.length === 0) { + return { + type: 'message', + messageType: 'error', + content: `'${matchedKey}' is not defined in user or workspace settings and cannot be removed.`, + }; + } + + // Resolve which scope(s) to actually delete from. + let targetScopes: RemovableScope[]; + if (explicitScope === 'all') { + targetScopes = foundIn; + } else if (explicitScope === 'user') { + if (!foundIn.includes(SettingScope.User)) { + return { + type: 'message', + messageType: 'error', + content: `'${matchedKey}' is not defined in user settings.`, + }; + } + targetScopes = [SettingScope.User]; + } else if (explicitScope === 'workspace') { + if (!foundIn.includes(SettingScope.Workspace)) { + return { + type: 'message', + messageType: 'error', + content: `'${matchedKey}' is not defined in workspace settings.`, + }; + } + targetScopes = [SettingScope.Workspace]; + } else if (foundIn.length > 1) { + // Ambiguous and no explicit choice — refuse rather than silently delete + // only one copy and leave the user wondering why /mcp list still shows it. + return { + type: 'message', + messageType: 'error', + content: `'${matchedKey}' is defined in BOTH workspace and user settings. Use \`/mcp remove ${matchedKey} --scope workspace\`, \`--scope user\`, or \`--scope all\` to choose.`, + }; + } else { + targetScopes = foundIn; + } + + for (const scope of targetScopes) { + const file = settings.forScope(scope); + const scoped = (file.settings.mcpServers ?? {}) as Record; + const next = { ...scoped }; + delete next[matchedKey]; + settings.setValue(scope, 'mcpServers', next); + } + + const mcpClientManager = config.getMcpClientManager(); + if (mcpClientManager) { + context.ui.addItem( + { type: 'info', text: 'Reloading MCP servers...' }, + Date.now(), + ); + await mcpClientManager.restart(); + } + if (agentContext.geminiClient?.isInitialized()) { + await agentContext.geminiClient.setTools(); + } + context.ui.reloadCommands(); + + const where = targetScopes.map((s) => s.toLowerCase()).join(' and '); + return { + type: 'message', + messageType: 'info', + content: `MCP server '${matchedKey}' removed from ${where} settings.`, + }; +} + +const removeCommand: SlashCommand = { + name: 'remove', + altNames: ['rm'], + description: 'Remove an MCP server from your settings', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: handleRemove, + completion: async (context: CommandContext, partialArg: string) => { + const config = context.services.agentContext?.config; + if (!config) return []; + const servers = config.getMcpClientManager()?.getMcpServers() ?? {}; + return Object.entries(servers) + .filter(([, s]) => !s.extension) + .map(([n]) => n) + .filter((n) => n.startsWith(partialArg)); + }, +}; + export const mcpCommand: SlashCommand = { name: 'mcp', description: 'Manage configured Model Context Protocol (MCP) servers', @@ -546,6 +732,7 @@ export const mcpCommand: SlashCommand = { reloadCommand, enableCommand, disableCommand, + removeCommand, ], action: async ( context: CommandContext,