mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-27 13:34:15 -07:00
Add support for MCP dynamic tool update by notifications/tools/list_changed (#14375)
This commit is contained in:
@@ -23,6 +23,7 @@ import type {
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import {
|
||||
ListRootsRequestSchema,
|
||||
ToolListChangedNotificationSchema,
|
||||
type Tool as McpTool,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { parse } from 'shell-quote';
|
||||
@@ -97,6 +98,8 @@ export class McpClient {
|
||||
private client: Client | undefined;
|
||||
private transport: Transport | undefined;
|
||||
private status: MCPServerStatus = MCPServerStatus.DISCONNECTED;
|
||||
private isRefreshing: boolean = false;
|
||||
private pendingRefresh: boolean = false;
|
||||
|
||||
constructor(
|
||||
private readonly serverName: string,
|
||||
@@ -104,7 +107,9 @@ export class McpClient {
|
||||
private readonly toolRegistry: ToolRegistry,
|
||||
private readonly promptRegistry: PromptRegistry,
|
||||
private readonly workspaceContext: WorkspaceContext,
|
||||
private readonly cliConfig: Config,
|
||||
private readonly debugMode: boolean,
|
||||
private readonly onToolsUpdated?: (signal?: AbortSignal) => Promise<void>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -124,6 +129,25 @@ export class McpClient {
|
||||
this.debugMode,
|
||||
this.workspaceContext,
|
||||
);
|
||||
|
||||
// setup dynamic tool listener
|
||||
const capabilities = this.client.getServerCapabilities();
|
||||
|
||||
if (capabilities?.tools?.listChanged) {
|
||||
debugLogger.log(
|
||||
`Server '${this.serverName}' supports tool updates. Listening for changes...`,
|
||||
);
|
||||
|
||||
this.client.setNotificationHandler(
|
||||
ToolListChangedNotificationSchema,
|
||||
async () => {
|
||||
debugLogger.log(
|
||||
`🔔 Received tool update notification from '${this.serverName}'`,
|
||||
);
|
||||
await this.refreshTools();
|
||||
},
|
||||
);
|
||||
}
|
||||
const originalOnError = this.client.onerror;
|
||||
this.client.onerror = (error) => {
|
||||
if (this.status !== MCPServerStatus.CONNECTED) {
|
||||
@@ -204,7 +228,10 @@ export class McpClient {
|
||||
}
|
||||
}
|
||||
|
||||
private async discoverTools(cliConfig: Config): Promise<DiscoveredMCPTool[]> {
|
||||
private async discoverTools(
|
||||
cliConfig: Config,
|
||||
options?: { timeout?: number; signal?: AbortSignal },
|
||||
): Promise<DiscoveredMCPTool[]> {
|
||||
this.assertConnected();
|
||||
return discoverTools(
|
||||
this.serverName,
|
||||
@@ -212,6 +239,9 @@ export class McpClient {
|
||||
this.client!,
|
||||
cliConfig,
|
||||
this.toolRegistry.getMessageBus(),
|
||||
options ?? {
|
||||
timeout: this.serverConfig.timeout ?? MCP_DEFAULT_TIMEOUT_MSEC,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -227,6 +257,75 @@ export class McpClient {
|
||||
getInstructions(): string | undefined {
|
||||
return this.client?.getInstructions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the tools for this server by re-querying the MCP `tools/list` endpoint.
|
||||
*
|
||||
* This method implements a **Coalescing Pattern** to handle rapid bursts of notifications
|
||||
* (e.g., during server startup or bulk updates) without overwhelming the server or
|
||||
* creating race conditions in the global ToolRegistry.
|
||||
*/
|
||||
private async refreshTools(): Promise<void> {
|
||||
if (this.isRefreshing) {
|
||||
debugLogger.log(
|
||||
`Tool refresh for '${this.serverName}' is already in progress. Pending update.`,
|
||||
);
|
||||
this.pendingRefresh = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRefreshing = true;
|
||||
|
||||
try {
|
||||
do {
|
||||
this.pendingRefresh = false;
|
||||
|
||||
if (this.status !== MCPServerStatus.CONNECTED || !this.client) break;
|
||||
|
||||
const timeoutMs = this.serverConfig.timeout ?? MCP_DEFAULT_TIMEOUT_MSEC;
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
|
||||
|
||||
let newTools;
|
||||
try {
|
||||
newTools = await this.discoverTools(this.cliConfig, {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
debugLogger.error(
|
||||
`Discovery failed during refresh: ${getErrorMessage(err)}`,
|
||||
);
|
||||
clearTimeout(timeoutId);
|
||||
break;
|
||||
}
|
||||
|
||||
this.toolRegistry.removeMcpToolsByServer(this.serverName);
|
||||
|
||||
for (const tool of newTools) {
|
||||
this.toolRegistry.registerTool(tool);
|
||||
}
|
||||
this.toolRegistry.sortTools();
|
||||
|
||||
if (this.onToolsUpdated) {
|
||||
await this.onToolsUpdated(abortController.signal);
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
coreEvents.emitFeedback(
|
||||
'info',
|
||||
`Tools updated for server: ${this.serverName}`,
|
||||
);
|
||||
} while (this.pendingRefresh);
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
`Critical error in refresh loop for ${this.serverName}: ${getErrorMessage(error)}`,
|
||||
);
|
||||
} finally {
|
||||
this.isRefreshing = false;
|
||||
this.pendingRefresh = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -622,6 +721,7 @@ export async function connectAndDiscover(
|
||||
mcpClient,
|
||||
cliConfig,
|
||||
toolRegistry.getMessageBus(),
|
||||
{ timeout: mcpServerConfig.timeout ?? MCP_DEFAULT_TIMEOUT_MSEC },
|
||||
);
|
||||
|
||||
// If we have neither prompts nor tools, it's a failed discovery
|
||||
@@ -671,12 +771,13 @@ export async function discoverTools(
|
||||
mcpClient: Client,
|
||||
cliConfig: Config,
|
||||
messageBus?: MessageBus,
|
||||
options?: { timeout?: number; signal?: AbortSignal },
|
||||
): Promise<DiscoveredMCPTool[]> {
|
||||
try {
|
||||
// Only request tools if the server supports them.
|
||||
if (mcpClient.getServerCapabilities()?.tools == null) return [];
|
||||
|
||||
const response = await mcpClient.listTools({});
|
||||
const response = await mcpClient.listTools({}, options);
|
||||
const discoveredTools: DiscoveredMCPTool[] = [];
|
||||
for (const toolDef of response.tools) {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user