diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 12a65440..c0ec38af 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -402,6 +402,7 @@ export async function loadCliConfig(settings: Settings): Promise { settings.toolDiscoveryCommand, settings.toolCallCommand, settings.mcpServerCommand, + settings.mcpServers, userAgent, memoryContent, fileCount, diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index ab65160c..fa8a545d 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -7,6 +7,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { homedir } from 'os'; +import { StdioServerParameters } from '@modelcontextprotocol/sdk/client/stdio.js'; export const SETTINGS_DIRECTORY_NAME = '.gemini'; export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME); @@ -23,6 +24,7 @@ export interface Settings { toolDiscoveryCommand?: string; toolCallCommand?: string; mcpServerCommand?: string; + mcpServers?: Record; // Add other settings here. } @@ -67,9 +69,10 @@ export class LoadedSettings { setValue( scope: SettingScope, key: keyof Settings, - value: string | undefined, + value: string | Record | undefined, ): void { const settingsFile = this.forScope(scope); + // @ts-expect-error - value can be string | Record settingsFile.settings[key] = value; this._merged = this.computeMergedSettings(); saveSettings(settingsFile); diff --git a/packages/server/src/config/config.test.ts b/packages/server/src/config/config.test.ts index b999b7fb..ade27a87 100644 --- a/packages/server/src/config/config.test.ts +++ b/packages/server/src/config/config.test.ts @@ -59,6 +59,7 @@ describe('Server Config (config.ts)', () => { undefined, // toolDiscoveryCommand undefined, // toolCallCommand undefined, // mcpServerCommand + undefined, // mcpServers USER_AGENT, USER_MEMORY, // Pass memory here ); @@ -83,6 +84,7 @@ describe('Server Config (config.ts)', () => { undefined, undefined, undefined, + undefined, USER_AGENT, // No userMemory argument ); @@ -102,6 +104,7 @@ describe('Server Config (config.ts)', () => { undefined, undefined, undefined, + undefined, USER_AGENT, USER_MEMORY, // Pass memory here ); @@ -125,6 +128,7 @@ describe('Server Config (config.ts)', () => { undefined, undefined, undefined, + undefined, USER_AGENT, // No userMemory argument ); @@ -147,6 +151,7 @@ describe('Server Config (config.ts)', () => { undefined, undefined, undefined, + undefined, USER_AGENT, USER_MEMORY, ); diff --git a/packages/server/src/config/config.ts b/packages/server/src/config/config.ts index e104eaaa..4221b71e 100644 --- a/packages/server/src/config/config.ts +++ b/packages/server/src/config/config.ts @@ -20,6 +20,7 @@ import { WriteFileTool } from '../tools/write-file.js'; import { WebFetchTool } from '../tools/web-fetch.js'; import { ReadManyFilesTool } from '../tools/read-many-files.js'; import { BaseTool, ToolResult } from '../tools/tools.js'; +import { StdioServerParameters } from '@modelcontextprotocol/sdk/client/stdio.js'; export class Config { private toolRegistry: ToolRegistry; @@ -35,6 +36,9 @@ export class Config { private readonly toolDiscoveryCommand: string | undefined, private readonly toolCallCommand: string | undefined, private readonly mcpServerCommand: string | undefined, + private readonly mcpServers: + | Record + | undefined, private readonly userAgent: string, private userMemory: string = '', // Made mutable for refresh private geminiMdFileCount: number = 0, @@ -86,6 +90,10 @@ export class Config { return this.mcpServerCommand; } + getMcpServers(): Record | undefined { + return this.mcpServers; + } + getUserAgent(): string { return this.userAgent; } @@ -146,6 +154,7 @@ export function createServerConfig( toolDiscoveryCommand?: string, toolCallCommand?: string, mcpServerCommand?: string, + mcpServers?: Record, userAgent?: string, userMemory?: string, geminiMdFileCount?: number, @@ -161,6 +170,7 @@ export function createServerConfig( toolDiscoveryCommand, toolCallCommand, mcpServerCommand, + mcpServers, userAgent ?? 'GeminiCLI/unknown', // Default user agent userMemory ?? '', geminiMdFileCount ?? 0, diff --git a/packages/server/src/tools/tool-registry.ts b/packages/server/src/tools/tool-registry.ts index f62a3256..67f47af0 100644 --- a/packages/server/src/tools/tool-registry.ts +++ b/packages/server/src/tools/tool-registry.ts @@ -7,6 +7,7 @@ import { FunctionDeclaration } from '@google/genai'; import { Tool, ToolResult, BaseTool } from './tools.js'; import { Config } from '../config/config.js'; +import { parse } from 'shell-quote'; import { spawn, execSync } from 'node:child_process'; // TODO: remove this dependency once MCP support is built into genai SDK import { Client } from '@modelcontextprotocol/sdk/client/index.js'; @@ -26,7 +27,7 @@ export class DiscoveredTool extends BaseTool { This tool was discovered from the project by executing the command \`${discoveryCmd}\` on project root. When called, this tool will execute the command \`${callCommand} ${name}\` on project root. -Tool discovery and call commands can be configured in project settings. +Tool discovery and call commands can be configured in project or user settings. When called, the tool call command is executed as a subprocess. On success, tool output is returned as a json string. @@ -99,13 +100,11 @@ export class DiscoveredMCPTool extends BaseTool { readonly description: string, readonly parameterSchema: Record, ) { - const mcpServerCmd = config.getMcpServerCommand()!; description += ` This MCP tool was discovered from a local MCP server using JSON RPC 2.0 over stdio transport protocol. -The MCP server was started by executing the command \`${mcpServerCmd}\` on project root. When called, this tool will invoke the \`tools/call\` method for tool name \`${name}\`. -MCP server command can be configured in project settings. +MCP servers can be configured in project or user settings. Returns the MCP server response as a json string. `; super(name, name, description, parameterSchema); @@ -125,7 +124,6 @@ Returns the MCP server response as a json string. export class ToolRegistry { private tools: Map = new Map(); - private mcpClient: Client | null = null; constructor(private readonly config: Config) {} @@ -174,48 +172,66 @@ export class ToolRegistry { ); } } - // discover tools using MCP server command, if configured - const mcpServerCmd = this.config.getMcpServerCommand(); - if (mcpServerCmd) { + // discover tools using MCP servers, if configured + // convert mcpServerCommand (if any) to StdioServerParameters + const mcpServers = this.config.getMcpServers() || {}; + + if (this.config.getMcpServerCommand()) { + const cmd = this.config.getMcpServerCommand()!; + const args = parse(cmd, process.env) as string[]; + if (args.some((arg) => typeof arg !== 'string')) { + throw new Error('failed to parse mcpServerCommand: ' + cmd); + } + // use generic server name 'mcp' + mcpServers['mcp'] = { + command: args[0], + args: args.slice(1), + }; + } + for (const [mcpServerName, mcpServer] of Object.entries(mcpServers)) { (async () => { - if (!this.mcpClient) { - this.mcpClient = new Client({ - name: 'mcp-client', - version: '0.0.1', - }); - const transport = new StdioClientTransport({ - command: mcpServerCmd, - stderr: 'pipe', - }); - try { - await this.mcpClient.connect(transport); - } catch (error) { - console.error( - 'failed to start or connect to MCP server using ' + - `command '${mcpServerCmd}'; \n${error}`, - ); - throw error; - } - this.mcpClient.onerror = (error) => { - console.error('MCP ERROR', error.toString()); - }; - if (!transport.stderr) { - throw new Error('transport missing stderr stream'); - } - transport.stderr.on('data', (data) => { - // filter out INFO messages logged for each request received - if (!data.toString().includes('] INFO')) { - console.log('MCP STDERR', data.toString()); - } - }); + const mcpClient = new Client({ + name: 'mcp-client', + version: '0.0.1', + }); + const transport = new StdioClientTransport({ + ...mcpServer, + env: { + ...process.env, + ...(mcpServer.env || {}), + } as Record, + stderr: 'pipe', + }); + try { + await mcpClient.connect(transport); + } catch (error) { + console.error( + `failed to start or connect to MCP server '${mcpServerName}' ` + + `${JSON.stringify(mcpServer)}; \n${error}`, + ); + throw error; } - const result = await this.mcpClient.listTools(); + mcpClient.onerror = (error) => { + console.error('MCP ERROR', error.toString()); + }; + if (!transport.stderr) { + throw new Error('transport missing stderr stream'); + } + transport.stderr.on('data', (data) => { + // filter out INFO messages logged for each request received + if (!data.toString().includes('] INFO')) { + console.log('MCP STDERR', data.toString()); + } + }); + const result = await mcpClient.listTools(); for (const tool of result.tools) { this.registerTool( new DiscoveredMCPTool( - this.mcpClient, + mcpClient, this.config, - tool.name, + Object.keys(mcpServers).length > 1 + ? mcpServerName + '__' + tool.name + : tool.name, tool.description ?? '', tool.inputSchema, ),