mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 08:47:44 +00:00
feat(mcp): add gemini mcp commands for add, remove and list (#5481)
This commit is contained in:
55
packages/cli/src/commands/mcp.test.ts
Normal file
55
packages/cli/src/commands/mcp.test.ts
Normal file
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
27
packages/cli/src/commands/mcp.ts
Normal file
27
packages/cli/src/commands/mcp.ts
Normal file
@@ -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.
|
||||
},
|
||||
};
|
||||
88
packages/cli/src/commands/mcp/add.test.ts
Normal file
88
packages/cli/src/commands/mcp/add.test.ts
Normal file
@@ -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' },
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
211
packages/cli/src/commands/mcp/add.ts
Normal file
211
packages/cli/src/commands/mcp/add.ts
Normal file
@@ -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,
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
154
packages/cli/src/commands/mcp/list.test.ts
Normal file
154
packages/cli/src/commands/mcp/list.test.ts
Normal file
@@ -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',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
139
packages/cli/src/commands/mcp/list.ts
Normal file
139
packages/cli/src/commands/mcp/list.ts
Normal file
@@ -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();
|
||||
},
|
||||
};
|
||||
69
packages/cli/src/commands/mcp/remove.test.ts
Normal file
69
packages/cli/src/commands/mcp/remove.test.ts
Normal file
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
60
packages/cli/src/commands/mcp/remove.ts
Normal file
60
packages/cli/src/commands/mcp/remove.ts
Normal file
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user