summaryrefslogtreecommitdiff
path: root/packages/server/src/tools/mcp-client.ts
diff options
context:
space:
mode:
authorTaylor Mullen <[email protected]>2025-05-28 00:43:23 -0700
committerN. Taylor Mullen <[email protected]>2025-05-28 17:28:45 -0700
commitd74c0f581bf5ba0c74a7b7874f6638db6897f907 (patch)
tree77165172f69a1b8d718b93b74967c726b64a17a0 /packages/server/src/tools/mcp-client.ts
parentc413988ae00f4a01003748c5e6fbf511da07ab06 (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.ts138
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.
+ }
+}