diff options
| author | Taylor Mullen <[email protected]> | 2025-05-28 00:43:23 -0700 |
|---|---|---|
| committer | N. Taylor Mullen <[email protected]> | 2025-05-28 17:28:45 -0700 |
| commit | d74c0f581bf5ba0c74a7b7874f6638db6897f907 (patch) | |
| tree | 77165172f69a1b8d718b93b74967c726b64a17a0 /packages/server/src/tools/mcp-client.ts | |
| parent | c413988ae00f4a01003748c5e6fbf511da07ab06 (diff) | |
refactor: Extract MCP discovery from ToolRegistry
- Moves MCP tool discovery logic from ToolRegistry into a new, dedicated MCP client (mcp-client.ts and mcp-tool.ts).
- Updates ToolRegistry to utilize the new MCP client.
- Adds comprehensive tests for the new MCP client and its integration with ToolRegistry.
Part of https://github.com/google-gemini/gemini-cli/issues/577
Diffstat (limited to 'packages/server/src/tools/mcp-client.ts')
| -rw-r--r-- | packages/server/src/tools/mcp-client.ts | 138 |
1 files changed, 138 insertions, 0 deletions
diff --git a/packages/server/src/tools/mcp-client.ts b/packages/server/src/tools/mcp-client.ts new file mode 100644 index 00000000..8c2b4879 --- /dev/null +++ b/packages/server/src/tools/mcp-client.ts @@ -0,0 +1,138 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import { parse } from 'shell-quote'; +import { Config, MCPServerConfig } from '../config/config.js'; +import { DiscoveredMCPTool } from './mcp-tool.js'; +import { ToolRegistry } from './tool-registry.js'; + +export async function discoverMcpTools( + config: Config, + toolRegistry: ToolRegistry, +): Promise<void> { + const mcpServers = config.getMcpServers() || {}; + + if (config.getMcpServerCommand()) { + const cmd = 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), + }; + } + + const discoveryPromises = Object.entries(mcpServers).map( + ([mcpServerName, mcpServerConfig]) => + connectAndDiscover( + mcpServerName, + mcpServerConfig, + toolRegistry, + mcpServers, + ), + ); + await Promise.all(discoveryPromises); +} + +async function connectAndDiscover( + mcpServerName: string, + mcpServerConfig: MCPServerConfig, + toolRegistry: ToolRegistry, + mcpServers: Record<string, MCPServerConfig>, +): Promise<void> { + let transport; + if (mcpServerConfig.url) { + transport = new SSEClientTransport(new URL(mcpServerConfig.url)); + } else if (mcpServerConfig.command) { + transport = new StdioClientTransport({ + command: mcpServerConfig.command, + args: mcpServerConfig.args || [], + env: { + ...process.env, + ...(mcpServerConfig.env || {}), + } as Record<string, string>, + cwd: mcpServerConfig.cwd, + stderr: 'pipe', + }); + } else { + console.error( + `MCP server '${mcpServerName}' has invalid configuration: missing both url (for SSE) and command (for stdio). Skipping.`, + ); + return; // Return a resolved promise as this path doesn't throw. + } + + const mcpClient = new Client({ + name: 'gemini-cli-mcp-client', + version: '0.0.1', + }); + + try { + await mcpClient.connect(transport); + } catch (error) { + console.error( + `failed to start or connect to MCP server '${mcpServerName}' ` + + `${JSON.stringify(mcpServerConfig)}; \n${error}`, + ); + return; // Return a resolved promise, let other MCP servers be discovered. + } + + mcpClient.onerror = (error) => { + console.error('MCP ERROR', error.toString()); + }; + + if (transport instanceof StdioClientTransport && transport.stderr) { + transport.stderr.on('data', (data) => { + if (!data.toString().includes('] INFO')) { + console.debug('MCP STDERR', data.toString()); + } + }); + } + + try { + const result = await mcpClient.listTools(); + for (const tool of result.tools) { + // Recursively remove additionalProperties and $schema from the inputSchema + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- This function recursively navigates a deeply nested and potentially heterogeneous JSON schema object. Using 'any' is a pragmatic choice here to avoid overly complex type definitions for all possible schema variations. + const removeSchemaProps = (obj: any) => { + if (typeof obj !== 'object' || obj === null) { + return; + } + if (Array.isArray(obj)) { + obj.forEach(removeSchemaProps); + } else { + delete obj.additionalProperties; + delete obj.$schema; + Object.values(obj).forEach(removeSchemaProps); + } + }; + removeSchemaProps(tool.inputSchema); + + toolRegistry.registerTool( + new DiscoveredMCPTool( + mcpClient, + Object.keys(mcpServers).length > 1 + ? mcpServerName + '__' + tool.name + : tool.name, + tool.description ?? '', + tool.inputSchema, + tool.name, + mcpServerConfig.timeout, + ), + ); + } + } catch (error) { + console.error( + `Failed to list or register tools for MCP server '${mcpServerName}': ${error}`, + ); + // Do not re-throw, allow other servers to proceed. + } +} |
