summaryrefslogtreecommitdiff
path: root/packages/cli/src
diff options
context:
space:
mode:
authorJack Wotherspoon <[email protected]>2025-08-06 11:52:29 -0400
committerGitHub <[email protected]>2025-08-06 15:52:29 +0000
commitca4c745e3b620e3ac4eca24b610cc7b936c0a50d (patch)
treeeb1b9cfb46bd4fa2e7f9631828b0704df1050eb7 /packages/cli/src
parentb38f377c9a2672e88dd15119b38d908c0f00b54a (diff)
feat(mcp): add `gemini mcp` commands for `add`, `remove` and `list` (#5481)
Diffstat (limited to 'packages/cli/src')
-rw-r--r--packages/cli/src/commands/mcp.test.ts55
-rw-r--r--packages/cli/src/commands/mcp.ts27
-rw-r--r--packages/cli/src/commands/mcp/add.test.ts88
-rw-r--r--packages/cli/src/commands/mcp/add.ts211
-rw-r--r--packages/cli/src/commands/mcp/list.test.ts154
-rw-r--r--packages/cli/src/commands/mcp/list.ts139
-rw-r--r--packages/cli/src/commands/mcp/remove.test.ts69
-rw-r--r--packages/cli/src/commands/mcp/remove.ts60
-rw-r--r--packages/cli/src/config/config.ts323
9 files changed, 971 insertions, 155 deletions
diff --git a/packages/cli/src/commands/mcp.test.ts b/packages/cli/src/commands/mcp.test.ts
new file mode 100644
index 00000000..b4e9980c
--- /dev/null
+++ b/packages/cli/src/commands/mcp.test.ts
@@ -0,0 +1,55 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { mcpCommand } from './mcp.js';
+import { type Argv } from 'yargs';
+import yargs from 'yargs';
+
+describe('mcp command', () => {
+ it('should have correct command definition', () => {
+ expect(mcpCommand.command).toBe('mcp');
+ expect(mcpCommand.describe).toBe('Manage MCP servers');
+ expect(typeof mcpCommand.builder).toBe('function');
+ expect(typeof mcpCommand.handler).toBe('function');
+ });
+
+ it('should have exactly one option (help flag)', () => {
+ // Test to ensure that the global 'gemini' flags are not added to the mcp command
+ const yargsInstance = yargs();
+ const builtYargs = mcpCommand.builder(yargsInstance);
+ const options = builtYargs.getOptions();
+
+ // Should have exactly 1 option (help flag)
+ expect(Object.keys(options.key).length).toBe(1);
+ expect(options.key).toHaveProperty('help');
+ });
+
+ it('should register add, remove, and list subcommands', () => {
+ const mockYargs = {
+ command: vi.fn().mockReturnThis(),
+ demandCommand: vi.fn().mockReturnThis(),
+ version: vi.fn().mockReturnThis(),
+ };
+
+ mcpCommand.builder(mockYargs as unknown as Argv);
+
+ expect(mockYargs.command).toHaveBeenCalledTimes(3);
+
+ // 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 <name> <commandOrUrl> [args...]');
+ expect(commandNames).toContain('remove <name>');
+ expect(commandNames).toContain('list');
+
+ expect(mockYargs.demandCommand).toHaveBeenCalledWith(
+ 1,
+ 'You need at least one command before continuing.',
+ );
+ });
+});
diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts
new file mode 100644
index 00000000..5e55286c
--- /dev/null
+++ b/packages/cli/src/commands/mcp.ts
@@ -0,0 +1,27 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+// File for 'gemini mcp' command
+import type { CommandModule, Argv } from 'yargs';
+import { addCommand } from './mcp/add.js';
+import { removeCommand } from './mcp/remove.js';
+import { listCommand } from './mcp/list.js';
+
+export const mcpCommand: CommandModule = {
+ command: 'mcp',
+ describe: 'Manage MCP servers',
+ builder: (yargs: Argv) =>
+ yargs
+ .command(addCommand)
+ .command(removeCommand)
+ .command(listCommand)
+ .demandCommand(1, 'You need at least one command before continuing.')
+ .version(false),
+ handler: () => {
+ // yargs will automatically show help if no subcommand is provided
+ // thanks to demandCommand(1) in the builder.
+ },
+};
diff --git a/packages/cli/src/commands/mcp/add.test.ts b/packages/cli/src/commands/mcp/add.test.ts
new file mode 100644
index 00000000..1d431c48
--- /dev/null
+++ b/packages/cli/src/commands/mcp/add.test.ts
@@ -0,0 +1,88 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import yargs from 'yargs';
+import { addCommand } from './add.js';
+import { loadSettings, SettingScope } from '../../config/settings.js';
+
+vi.mock('fs/promises', () => ({
+ 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 vi.Mock;
+
+describe('mcp add command', () => {
+ let parser: yargs.Argv;
+ let mockSetValue: vi.Mock;
+
+ beforeEach(() => {
+ vi.resetAllMocks();
+ const yargsInstance = yargs([]).command(addCommand);
+ parser = yargsInstance;
+ mockSetValue = vi.fn();
+ mockedLoadSettings.mockReturnValue({
+ forScope: () => ({ settings: {} }),
+ setValue: mockSetValue,
+ });
+ });
+
+ it('should add a stdio server to project settings', async () => {
+ await parser.parseAsync(
+ 'add my-server /path/to/server arg1 arg2 -e FOO=bar',
+ );
+
+ expect(mockSetValue).toHaveBeenCalledWith(
+ SettingScope.Workspace,
+ 'mcpServers',
+ {
+ 'my-server': {
+ command: '/path/to/server',
+ args: ['arg1', 'arg2'],
+ env: { FOO: 'bar' },
+ },
+ },
+ );
+ });
+
+ it('should add an sse server to user settings', async () => {
+ await parser.parseAsync(
+ 'add --transport sse sse-server https://example.com/sse-endpoint --scope user -H "X-API-Key: your-key"',
+ );
+
+ expect(mockSetValue).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', {
+ 'sse-server': {
+ url: 'https://example.com/sse-endpoint',
+ headers: { 'X-API-Key': 'your-key' },
+ },
+ });
+ });
+
+ it('should add an http server to project settings', async () => {
+ await parser.parseAsync(
+ 'add --transport http http-server https://example.com/mcp -H "Authorization: Bearer your-token"',
+ );
+
+ expect(mockSetValue).toHaveBeenCalledWith(
+ SettingScope.Workspace,
+ 'mcpServers',
+ {
+ 'http-server': {
+ httpUrl: 'https://example.com/mcp',
+ headers: { Authorization: 'Bearer your-token' },
+ },
+ },
+ );
+ });
+});
diff --git a/packages/cli/src/commands/mcp/add.ts b/packages/cli/src/commands/mcp/add.ts
new file mode 100644
index 00000000..9537e131
--- /dev/null
+++ b/packages/cli/src/commands/mcp/add.ts
@@ -0,0 +1,211 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+// File for 'gemini mcp add' command
+import type { CommandModule } from 'yargs';
+import { loadSettings, SettingScope } from '../../config/settings.js';
+import { MCPServerConfig } from '@google/gemini-cli-core';
+
+async function addMcpServer(
+ name: string,
+ commandOrUrl: string,
+ args: Array<string | number> | undefined,
+ options: {
+ scope: string;
+ transport: string;
+ env: string[] | undefined;
+ header: string[] | undefined;
+ timeout?: number;
+ trust?: boolean;
+ description?: string;
+ includeTools?: string[];
+ excludeTools?: string[];
+ },
+) {
+ const {
+ scope,
+ transport,
+ env,
+ header,
+ timeout,
+ trust,
+ description,
+ includeTools,
+ excludeTools,
+ } = options;
+ const settingsScope =
+ scope === 'user' ? SettingScope.User : SettingScope.Workspace;
+ const settings = loadSettings(process.cwd());
+
+ let newServer: Partial<MCPServerConfig> = {};
+
+ const headers = header?.reduce(
+ (acc, curr) => {
+ const [key, ...valueParts] = curr.split(':');
+ const value = valueParts.join(':').trim();
+ if (key.trim() && value) {
+ acc[key.trim()] = value;
+ }
+ return acc;
+ },
+ {} as Record<string, string>,
+ );
+
+ switch (transport) {
+ case 'sse':
+ newServer = {
+ url: commandOrUrl,
+ headers,
+ timeout,
+ trust,
+ description,
+ includeTools,
+ excludeTools,
+ };
+ break;
+ case 'http':
+ newServer = {
+ httpUrl: commandOrUrl,
+ headers,
+ timeout,
+ trust,
+ description,
+ includeTools,
+ excludeTools,
+ };
+ break;
+ case 'stdio':
+ default:
+ newServer = {
+ command: commandOrUrl,
+ args: args?.map(String),
+ env: env?.reduce(
+ (acc, curr) => {
+ const [key, value] = curr.split('=');
+ if (key && value) {
+ acc[key] = value;
+ }
+ return acc;
+ },
+ {} as Record<string, string>,
+ ),
+ timeout,
+ trust,
+ description,
+ includeTools,
+ excludeTools,
+ };
+ break;
+ }
+
+ const existingSettings = settings.forScope(settingsScope).settings;
+ const mcpServers = existingSettings.mcpServers || {};
+
+ const isExistingServer = !!mcpServers[name];
+ if (isExistingServer) {
+ console.log(
+ `MCP server "${name}" is already configured within ${scope} settings.`,
+ );
+ }
+
+ mcpServers[name] = newServer as MCPServerConfig;
+
+ settings.setValue(settingsScope, 'mcpServers', mcpServers);
+
+ if (isExistingServer) {
+ console.log(`MCP server "${name}" updated in ${scope} settings.`);
+ } else {
+ console.log(
+ `MCP server "${name}" added to ${scope} settings. (${transport})`,
+ );
+ }
+}
+
+export const addCommand: CommandModule = {
+ command: 'add <name> <commandOrUrl> [args...]',
+ describe: 'Add a server',
+ builder: (yargs) =>
+ yargs
+ .usage('Usage: gemini mcp add [options] <name> <commandOrUrl> [args...]')
+ .positional('name', {
+ describe: 'Name of the server',
+ type: 'string',
+ demandOption: true,
+ })
+ .positional('commandOrUrl', {
+ describe: 'Command (stdio) or URL (sse, http)',
+ type: 'string',
+ demandOption: true,
+ })
+ .option('scope', {
+ alias: 's',
+ describe: 'Configuration scope (user or project)',
+ type: 'string',
+ default: 'project',
+ choices: ['user', 'project'],
+ })
+ .option('transport', {
+ alias: 't',
+ describe: 'Transport type (stdio, sse, http)',
+ type: 'string',
+ default: 'stdio',
+ choices: ['stdio', 'sse', 'http'],
+ })
+ .option('env', {
+ alias: 'e',
+ describe: 'Set environment variables (e.g. -e KEY=value)',
+ type: 'array',
+ string: true,
+ })
+ .option('header', {
+ alias: 'H',
+ describe:
+ 'Set HTTP headers for SSE and HTTP transports (e.g. -H "X-Api-Key: abc123" -H "Authorization: Bearer abc123")',
+ type: 'array',
+ string: true,
+ })
+ .option('timeout', {
+ describe: 'Set connection timeout in milliseconds',
+ type: 'number',
+ })
+ .option('trust', {
+ describe:
+ 'Trust the server (bypass all tool call confirmation prompts)',
+ type: 'boolean',
+ })
+ .option('description', {
+ describe: 'Set the description for the server',
+ type: 'string',
+ })
+ .option('include-tools', {
+ describe: 'A comma-separated list of tools to include',
+ type: 'array',
+ string: true,
+ })
+ .option('exclude-tools', {
+ describe: 'A comma-separated list of tools to exclude',
+ type: 'array',
+ string: true,
+ }),
+ handler: async (argv) => {
+ await addMcpServer(
+ argv.name as string,
+ argv.commandOrUrl as string,
+ argv.args as Array<string | number>,
+ {
+ scope: argv.scope as string,
+ transport: argv.transport as string,
+ env: argv.env as string[],
+ header: argv.header as string[],
+ timeout: argv.timeout as number | undefined,
+ trust: argv.trust as boolean | undefined,
+ description: argv.description as string | undefined,
+ includeTools: argv.includeTools as string[] | undefined,
+ excludeTools: argv.excludeTools as string[] | undefined,
+ },
+ );
+ },
+};
diff --git a/packages/cli/src/commands/mcp/list.test.ts b/packages/cli/src/commands/mcp/list.test.ts
new file mode 100644
index 00000000..daf2e3d7
--- /dev/null
+++ b/packages/cli/src/commands/mcp/list.test.ts
@@ -0,0 +1,154 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { listMcpServers } from './list.js';
+import { loadSettings } from '../../config/settings.js';
+import { loadExtensions } from '../../config/extension.js';
+import { createTransport } from '@google/gemini-cli-core';
+import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+
+vi.mock('../../config/settings.js');
+vi.mock('../../config/extension.js');
+vi.mock('@google/gemini-cli-core');
+vi.mock('@modelcontextprotocol/sdk/client/index.js');
+
+const mockedLoadSettings = loadSettings as vi.Mock;
+const mockedLoadExtensions = loadExtensions as vi.Mock;
+const mockedCreateTransport = createTransport as vi.Mock;
+const MockedClient = Client as vi.Mock;
+
+interface MockClient {
+ connect: vi.Mock;
+ ping: vi.Mock;
+ close: vi.Mock;
+}
+
+interface MockTransport {
+ close: vi.Mock;
+}
+
+describe('mcp list command', () => {
+ let consoleSpy: vi.SpyInstance;
+ let mockClient: MockClient;
+ let mockTransport: MockTransport;
+
+ beforeEach(() => {
+ vi.resetAllMocks();
+
+ consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
+
+ mockTransport = { close: vi.fn() };
+ mockClient = {
+ connect: vi.fn(),
+ ping: vi.fn(),
+ close: vi.fn(),
+ };
+
+ MockedClient.mockImplementation(() => mockClient);
+ mockedCreateTransport.mockResolvedValue(mockTransport);
+ mockedLoadExtensions.mockReturnValue([]);
+ });
+
+ afterEach(() => {
+ consoleSpy.mockRestore();
+ });
+
+ it('should display message when no servers configured', async () => {
+ mockedLoadSettings.mockReturnValue({ merged: { mcpServers: {} } });
+
+ await listMcpServers();
+
+ expect(consoleSpy).toHaveBeenCalledWith('No MCP servers configured.');
+ });
+
+ it('should display different server types with connected status', async () => {
+ mockedLoadSettings.mockReturnValue({
+ merged: {
+ mcpServers: {
+ 'stdio-server': { command: '/path/to/server', args: ['arg1'] },
+ 'sse-server': { url: 'https://example.com/sse' },
+ 'http-server': { httpUrl: 'https://example.com/http' },
+ },
+ },
+ });
+
+ mockClient.connect.mockResolvedValue(undefined);
+ mockClient.ping.mockResolvedValue(undefined);
+
+ await listMcpServers();
+
+ expect(consoleSpy).toHaveBeenCalledWith('Configured MCP servers:\n');
+ expect(consoleSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'stdio-server: /path/to/server arg1 (stdio) - Connected',
+ ),
+ );
+ expect(consoleSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'sse-server: https://example.com/sse (sse) - Connected',
+ ),
+ );
+ expect(consoleSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'http-server: https://example.com/http (http) - Connected',
+ ),
+ );
+ });
+
+ it('should display disconnected status when connection fails', async () => {
+ mockedLoadSettings.mockReturnValue({
+ merged: {
+ mcpServers: {
+ 'test-server': { command: '/test/server' },
+ },
+ },
+ });
+
+ mockClient.connect.mockRejectedValue(new Error('Connection failed'));
+
+ await listMcpServers();
+
+ expect(consoleSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'test-server: /test/server (stdio) - Disconnected',
+ ),
+ );
+ });
+
+ it('should merge extension servers with config servers', async () => {
+ mockedLoadSettings.mockReturnValue({
+ merged: {
+ mcpServers: { 'config-server': { command: '/config/server' } },
+ },
+ });
+
+ mockedLoadExtensions.mockReturnValue([
+ {
+ config: {
+ name: 'test-extension',
+ mcpServers: { 'extension-server': { command: '/ext/server' } },
+ },
+ },
+ ]);
+
+ mockClient.connect.mockResolvedValue(undefined);
+ mockClient.ping.mockResolvedValue(undefined);
+
+ await listMcpServers();
+
+ expect(consoleSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'config-server: /config/server (stdio) - Connected',
+ ),
+ );
+ expect(consoleSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'extension-server: /ext/server (stdio) - Connected',
+ ),
+ );
+ });
+});
diff --git a/packages/cli/src/commands/mcp/list.ts b/packages/cli/src/commands/mcp/list.ts
new file mode 100644
index 00000000..48ea912e
--- /dev/null
+++ b/packages/cli/src/commands/mcp/list.ts
@@ -0,0 +1,139 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+// File for 'gemini mcp list' command
+import type { CommandModule } from 'yargs';
+import { loadSettings } from '../../config/settings.js';
+import {
+ MCPServerConfig,
+ MCPServerStatus,
+ createTransport,
+} from '@google/gemini-cli-core';
+import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import { loadExtensions } from '../../config/extension.js';
+
+const COLOR_GREEN = '\u001b[32m';
+const COLOR_YELLOW = '\u001b[33m';
+const COLOR_RED = '\u001b[31m';
+const RESET_COLOR = '\u001b[0m';
+
+async function getMcpServersFromConfig(): Promise<
+ Record<string, MCPServerConfig>
+> {
+ const settings = loadSettings(process.cwd());
+ const extensions = loadExtensions(process.cwd());
+ const mcpServers = { ...(settings.merged.mcpServers || {}) };
+ for (const extension of extensions) {
+ Object.entries(extension.config.mcpServers || {}).forEach(
+ ([key, server]) => {
+ if (mcpServers[key]) {
+ return;
+ }
+ mcpServers[key] = {
+ ...server,
+ extensionName: extension.config.name,
+ };
+ },
+ );
+ }
+ return mcpServers;
+}
+
+async function testMCPConnection(
+ serverName: string,
+ config: MCPServerConfig,
+): Promise<MCPServerStatus> {
+ const client = new Client({
+ name: 'mcp-test-client',
+ version: '0.0.1',
+ });
+
+ let transport;
+ try {
+ // Use the same transport creation logic as core
+ transport = await createTransport(serverName, config, false);
+ } catch (_error) {
+ await client.close();
+ return MCPServerStatus.DISCONNECTED;
+ }
+
+ try {
+ // Attempt actual MCP connection with short timeout
+ await client.connect(transport, { timeout: 5000 }); // 5s timeout
+
+ // Test basic MCP protocol by pinging the server
+ await client.ping();
+
+ await client.close();
+ return MCPServerStatus.CONNECTED;
+ } catch (_error) {
+ await transport.close();
+ return MCPServerStatus.DISCONNECTED;
+ }
+}
+
+async function getServerStatus(
+ serverName: string,
+ server: MCPServerConfig,
+): Promise<MCPServerStatus> {
+ // Test all server types by attempting actual connection
+ return await testMCPConnection(serverName, server);
+}
+
+export async function listMcpServers(): Promise<void> {
+ const mcpServers = await getMcpServersFromConfig();
+ const serverNames = Object.keys(mcpServers);
+
+ if (serverNames.length === 0) {
+ console.log('No MCP servers configured.');
+ return;
+ }
+
+ console.log('Configured MCP servers:\n');
+
+ for (const serverName of serverNames) {
+ const server = mcpServers[serverName];
+
+ const status = await getServerStatus(serverName, server);
+
+ let statusIndicator = '';
+ let statusText = '';
+ switch (status) {
+ case MCPServerStatus.CONNECTED:
+ statusIndicator = COLOR_GREEN + '✓' + RESET_COLOR;
+ statusText = 'Connected';
+ break;
+ case MCPServerStatus.CONNECTING:
+ statusIndicator = COLOR_YELLOW + '…' + RESET_COLOR;
+ statusText = 'Connecting';
+ break;
+ case MCPServerStatus.DISCONNECTED:
+ default:
+ statusIndicator = COLOR_RED + '✗' + RESET_COLOR;
+ statusText = 'Disconnected';
+ break;
+ }
+
+ let serverInfo = `${serverName}: `;
+ if (server.httpUrl) {
+ serverInfo += `${server.httpUrl} (http)`;
+ } else if (server.url) {
+ serverInfo += `${server.url} (sse)`;
+ } else if (server.command) {
+ serverInfo += `${server.command} ${server.args?.join(' ') || ''} (stdio)`;
+ }
+
+ console.log(`${statusIndicator} ${serverInfo} - ${statusText}`);
+ }
+}
+
+export const listCommand: CommandModule = {
+ command: 'list',
+ describe: 'List all configured MCP servers',
+ handler: async () => {
+ await listMcpServers();
+ },
+};
diff --git a/packages/cli/src/commands/mcp/remove.test.ts b/packages/cli/src/commands/mcp/remove.test.ts
new file mode 100644
index 00000000..eb7dedce
--- /dev/null
+++ b/packages/cli/src/commands/mcp/remove.test.ts
@@ -0,0 +1,69 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { vi, describe, it, expect, beforeEach } from 'vitest';
+import yargs from 'yargs';
+import { loadSettings, SettingScope } from '../../config/settings.js';
+import { removeCommand } from './remove.js';
+
+vi.mock('fs/promises', () => ({
+ 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 vi.Mock;
+
+describe('mcp remove command', () => {
+ let parser: yargs.Argv;
+ let mockSetValue: vi.Mock;
+ let mockSettings: Record<string, unknown>;
+
+ beforeEach(() => {
+ vi.resetAllMocks();
+ const yargsInstance = yargs([]).command(removeCommand);
+ parser = yargsInstance;
+ mockSetValue = vi.fn();
+ mockSettings = {
+ mcpServers: {
+ 'test-server': {
+ command: 'echo "hello"',
+ },
+ },
+ };
+ mockedLoadSettings.mockReturnValue({
+ forScope: () => ({ settings: mockSettings }),
+ setValue: mockSetValue,
+ });
+ });
+
+ it('should remove a server from project settings', async () => {
+ await parser.parseAsync('remove test-server');
+
+ expect(mockSetValue).toHaveBeenCalledWith(
+ SettingScope.Workspace,
+ 'mcpServers',
+ {},
+ );
+ });
+
+ it('should show a message if server not found', async () => {
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
+ await parser.parseAsync('remove non-existent-server');
+
+ expect(mockSetValue).not.toHaveBeenCalled();
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Server "non-existent-server" not found in project settings.',
+ );
+ });
+});
diff --git a/packages/cli/src/commands/mcp/remove.ts b/packages/cli/src/commands/mcp/remove.ts
new file mode 100644
index 00000000..80d66234
--- /dev/null
+++ b/packages/cli/src/commands/mcp/remove.ts
@@ -0,0 +1,60 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+// File for 'gemini mcp remove' command
+import type { CommandModule } from 'yargs';
+import { loadSettings, SettingScope } from '../../config/settings.js';
+
+async function removeMcpServer(
+ name: string,
+ options: {
+ scope: string;
+ },
+) {
+ const { scope } = options;
+ const settingsScope =
+ scope === 'user' ? SettingScope.User : SettingScope.Workspace;
+ const settings = loadSettings(process.cwd());
+
+ const existingSettings = settings.forScope(settingsScope).settings;
+ const mcpServers = existingSettings.mcpServers || {};
+
+ if (!mcpServers[name]) {
+ console.log(`Server "${name}" not found in ${scope} settings.`);
+ return;
+ }
+
+ delete mcpServers[name];
+
+ settings.setValue(settingsScope, 'mcpServers', mcpServers);
+
+ console.log(`Server "${name}" removed from ${scope} settings.`);
+}
+
+export const removeCommand: CommandModule = {
+ command: 'remove <name>',
+ describe: 'Remove a server',
+ builder: (yargs) =>
+ yargs
+ .usage('Usage: gemini mcp remove [options] <name>')
+ .positional('name', {
+ describe: 'Name of the server',
+ type: 'string',
+ demandOption: true,
+ })
+ .option('scope', {
+ alias: 's',
+ describe: 'Configuration scope (user or project)',
+ type: 'string',
+ default: 'project',
+ choices: ['user', 'project'],
+ }),
+ handler: async (argv) => {
+ await removeMcpServer(argv.name as string, {
+ scope: argv.scope as string,
+ });
+ },
+};
diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts
index beba9602..7175c033 100644
--- a/packages/cli/src/config/config.ts
+++ b/packages/cli/src/config/config.ts
@@ -10,6 +10,7 @@ import { homedir } from 'node:os';
import yargs from 'yargs/yargs';
import { hideBin } from 'yargs/helpers';
import process from 'node:process';
+import { mcpCommand } from '../commands/mcp.js';
import {
Config,
loadServerHierarchicalMemory,
@@ -72,173 +73,185 @@ export async function parseArguments(): Promise<CliArgs> {
const yargsInstance = yargs(hideBin(process.argv))
.scriptName('gemini')
.usage(
- '$0 [options]',
- 'Gemini CLI - Launch an interactive CLI, use -p/--prompt for non-interactive mode',
+ 'Usage: gemini [options] [command]\n\nGemini CLI - Launch an interactive CLI, use -p/--prompt for non-interactive mode',
)
- .option('model', {
- alias: 'm',
- type: 'string',
- description: `Model`,
- default: process.env.GEMINI_MODEL,
- })
- .option('prompt', {
- alias: 'p',
- type: 'string',
- description: 'Prompt. Appended to input on stdin (if any).',
- })
- .option('prompt-interactive', {
- alias: 'i',
- type: 'string',
- description:
- 'Execute the provided prompt and continue in interactive mode',
- })
- .option('sandbox', {
- alias: 's',
- type: 'boolean',
- description: 'Run in sandbox?',
- })
- .option('sandbox-image', {
- type: 'string',
- description: 'Sandbox image URI.',
- })
- .option('debug', {
- alias: 'd',
- type: 'boolean',
- description: 'Run in debug mode?',
- default: false,
- })
- .option('all-files', {
- alias: ['a'],
- type: 'boolean',
- description: 'Include ALL files in context?',
- default: false,
- })
- .option('all_files', {
- type: 'boolean',
- description: 'Include ALL files in context?',
- default: false,
- })
- .deprecateOption(
- 'all_files',
- 'Use --all-files instead. We will be removing --all_files in the coming weeks.',
- )
- .option('show-memory-usage', {
- type: 'boolean',
- description: 'Show memory usage in status bar',
- default: false,
- })
- .option('show_memory_usage', {
- type: 'boolean',
- description: 'Show memory usage in status bar',
- default: false,
- })
- .deprecateOption(
- 'show_memory_usage',
- 'Use --show-memory-usage instead. We will be removing --show_memory_usage in the coming weeks.',
+ .command('$0', 'Launch Gemini CLI', (yargsInstance) =>
+ yargsInstance
+ .option('model', {
+ alias: 'm',
+ type: 'string',
+ description: `Model`,
+ default: process.env.GEMINI_MODEL,
+ })
+ .option('prompt', {
+ alias: 'p',
+ type: 'string',
+ description: 'Prompt. Appended to input on stdin (if any).',
+ })
+ .option('prompt-interactive', {
+ alias: 'i',
+ type: 'string',
+ description:
+ 'Execute the provided prompt and continue in interactive mode',
+ })
+ .option('sandbox', {
+ alias: 's',
+ type: 'boolean',
+ description: 'Run in sandbox?',
+ })
+ .option('sandbox-image', {
+ type: 'string',
+ description: 'Sandbox image URI.',
+ })
+ .option('debug', {
+ alias: 'd',
+ type: 'boolean',
+ description: 'Run in debug mode?',
+ default: false,
+ })
+ .option('all-files', {
+ alias: ['a'],
+ type: 'boolean',
+ description: 'Include ALL files in context?',
+ default: false,
+ })
+ .option('all_files', {
+ type: 'boolean',
+ description: 'Include ALL files in context?',
+ default: false,
+ })
+ .deprecateOption(
+ 'all_files',
+ 'Use --all-files instead. We will be removing --all_files in the coming weeks.',
+ )
+ .option('show-memory-usage', {
+ type: 'boolean',
+ description: 'Show memory usage in status bar',
+ default: false,
+ })
+ .option('show_memory_usage', {
+ type: 'boolean',
+ description: 'Show memory usage in status bar',
+ default: false,
+ })
+ .deprecateOption(
+ 'show_memory_usage',
+ 'Use --show-memory-usage instead. We will be removing --show_memory_usage in the coming weeks.',
+ )
+ .option('yolo', {
+ alias: 'y',
+ type: 'boolean',
+ description:
+ 'Automatically accept all actions (aka YOLO mode, see https://www.youtube.com/watch?v=xvFZjo5PgG0 for more details)?',
+ default: false,
+ })
+ .option('telemetry', {
+ type: 'boolean',
+ description:
+ 'Enable telemetry? This flag specifically controls if telemetry is sent. Other --telemetry-* flags set specific values but do not enable telemetry on their own.',
+ })
+ .option('telemetry-target', {
+ type: 'string',
+ choices: ['local', 'gcp'],
+ description:
+ 'Set the telemetry target (local or gcp). Overrides settings files.',
+ })
+ .option('telemetry-otlp-endpoint', {
+ type: 'string',
+ description:
+ 'Set the OTLP endpoint for telemetry. Overrides environment variables and settings files.',
+ })
+ .option('telemetry-log-prompts', {
+ type: 'boolean',
+ description:
+ 'Enable or disable logging of user prompts for telemetry. Overrides settings files.',
+ })
+ .option('telemetry-outfile', {
+ type: 'string',
+ description: 'Redirect all telemetry output to the specified file.',
+ })
+ .option('checkpointing', {
+ alias: 'c',
+ type: 'boolean',
+ description: 'Enables checkpointing of file edits',
+ default: false,
+ })
+ .option('experimental-acp', {
+ type: 'boolean',
+ description: 'Starts the agent in ACP mode',
+ })
+ .option('allowed-mcp-server-names', {
+ type: 'array',
+ string: true,
+ description: 'Allowed MCP server names',
+ })
+ .option('extensions', {
+ alias: 'e',
+ type: 'array',
+ string: true,
+ description:
+ 'A list of extensions to use. If not provided, all extensions are used.',
+ })
+ .option('list-extensions', {
+ alias: 'l',
+ type: 'boolean',
+ description: 'List all available extensions and exit.',
+ })
+ .option('ide-mode-feature', {
+ type: 'boolean',
+ description: 'Run in IDE mode?',
+ })
+ .option('proxy', {
+ type: 'string',
+ description:
+ 'Proxy for gemini client, like schema://user:password@host:port',
+ })
+ .option('include-directories', {
+ type: 'array',
+ string: true,
+ description:
+ 'Additional directories to include in the workspace (comma-separated or multiple --include-directories)',
+ coerce: (dirs: string[]) =>
+ // Handle comma-separated values
+ dirs.flatMap((dir) => dir.split(',').map((d) => d.trim())),
+ })
+ .option('load-memory-from-include-directories', {
+ type: 'boolean',
+ description:
+ 'If true, when refreshing memory, GEMINI.md files should be loaded from all directories that are added. If false, GEMINI.md files should only be loaded from the primary working directory.',
+ default: false,
+ })
+ .check((argv) => {
+ if (argv.prompt && argv.promptInteractive) {
+ throw new Error(
+ 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',
+ );
+ }
+ return true;
+ }),
)
- .option('yolo', {
- alias: 'y',
- type: 'boolean',
- description:
- 'Automatically accept all actions (aka YOLO mode, see https://www.youtube.com/watch?v=xvFZjo5PgG0 for more details)?',
- default: false,
- })
- .option('telemetry', {
- type: 'boolean',
- description:
- 'Enable telemetry? This flag specifically controls if telemetry is sent. Other --telemetry-* flags set specific values but do not enable telemetry on their own.',
- })
- .option('telemetry-target', {
- type: 'string',
- choices: ['local', 'gcp'],
- description:
- 'Set the telemetry target (local or gcp). Overrides settings files.',
- })
- .option('telemetry-otlp-endpoint', {
- type: 'string',
- description:
- 'Set the OTLP endpoint for telemetry. Overrides environment variables and settings files.',
- })
- .option('telemetry-log-prompts', {
- type: 'boolean',
- description:
- 'Enable or disable logging of user prompts for telemetry. Overrides settings files.',
- })
- .option('telemetry-outfile', {
- type: 'string',
- description: 'Redirect all telemetry output to the specified file.',
- })
- .option('checkpointing', {
- alias: 'c',
- type: 'boolean',
- description: 'Enables checkpointing of file edits',
- default: false,
- })
- .option('experimental-acp', {
- type: 'boolean',
- description: 'Starts the agent in ACP mode',
- })
- .option('allowed-mcp-server-names', {
- type: 'array',
- string: true,
- description: 'Allowed MCP server names',
- })
- .option('extensions', {
- alias: 'e',
- type: 'array',
- string: true,
- description:
- 'A list of extensions to use. If not provided, all extensions are used.',
- })
- .option('list-extensions', {
- alias: 'l',
- type: 'boolean',
- description: 'List all available extensions and exit.',
- })
- .option('ide-mode-feature', {
- type: 'boolean',
- description: 'Run in IDE mode?',
- })
- .option('proxy', {
- type: 'string',
- description:
- 'Proxy for gemini client, like schema://user:password@host:port',
- })
- .option('include-directories', {
- type: 'array',
- string: true,
- description:
- 'Additional directories to include in the workspace (comma-separated or multiple --include-directories)',
- coerce: (dirs: string[]) =>
- // Handle comma-separated values
- dirs.flatMap((dir) => dir.split(',').map((d) => d.trim())),
- })
- .option('load-memory-from-include-directories', {
- type: 'boolean',
- description:
- 'If true, when refreshing memory, GEMINI.md files should be loaded from all directories that are added. If false, GEMINI.md files should only be loaded from the primary working directory.',
- default: false,
- })
+ // Register MCP subcommands
+ .command(mcpCommand)
.version(await getCliVersion()) // This will enable the --version flag based on package.json
.alias('v', 'version')
.help()
.alias('h', 'help')
.strict()
- .check((argv) => {
- if (argv.prompt && argv.promptInteractive) {
- throw new Error(
- 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',
- );
- }
- return true;
- });
+ .demandCommand(0, 0); // Allow base command to run with no subcommands
yargsInstance.wrap(yargsInstance.terminalWidth());
- const result = yargsInstance.parseSync();
+ const result = await yargsInstance.parse();
+
+ // Handle case where MCP subcommands are executed - they should exit the process
+ // and not return to main CLI logic
+ if (result._.length > 0 && result._[0] === 'mcp') {
+ // MCP commands handle their own execution and process exit
+ process.exit(0);
+ }
// The import format is now only controlled by settings.memoryImportFormat
// We no longer accept it as a CLI argument
- return result as CliArgs;
+ return result as unknown as CliArgs;
}
// This function is now a thin wrapper around the server's implementation.