Add support for MCP dynamic tool update by notifications/tools/list_changed (#14375)

This commit is contained in:
Adib234
2025-12-04 13:04:38 -08:00
committed by GitHub
parent 0c7ae22f5d
commit 5f60281d25
3 changed files with 557 additions and 3 deletions
+103 -2
View File
@@ -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 {