Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/tools/mcp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` and `/mcp disable <name>`.

### 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 <name> [--scope user|workspace|all]
```

Aliases: `/mcp rm <name>`.

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
Expand Down
195 changes: 195 additions & 0 deletions packages/cli/src/ui/commands/mcpCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('../../config/settings.js')>();
return {
...actual,
loadSettings: vi.fn(),
};
});

import type { CallableTool } from '@google/genai';
import { MessageType, type HistoryItemMcpStatus } from '../types.js';
Expand Down Expand Up @@ -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<typeof vi.fn>;
let restartMock: ReturnType<typeof vi.fn>;
let scopeContents: Record<string, Record<string, unknown>>;

const installSettingsMock = () => {
vi.mocked(loadSettings).mockReturnValue({
forScope: (scope: SettingScope) => ({
settings: { mcpServers: scopeContents[scope] ?? {} },
}),
Comment on lines +341 to +343

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The mock should include originalSettings to align with the implementation changes needed to avoid environment variable leakage. The handleRemove function should operate on the raw settings to preserve placeholders. Redundant nullish coalescing operators have been removed.

Suggested change
forScope: (scope: SettingScope) => ({
settings: { mcpServers: scopeContents[scope] ?? {} },
}),
forScope: (scope: SettingScope) => ({
settings: { mcpServers: scopeContents[scope] },
originalSettings: { mcpServers: scopeContents[scope] },
}),
References
  1. Rely on the schema as the single source of truth for configuration defaults, avoiding redundant nullish coalescing operators.

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']);
});
});
});
Loading