Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit d7a2ba9

Browse files
authored
fix(agent): reuse mcp connections to fetch tools (#1067)
1 parent 34562de commit d7a2ba9

4 files changed

Lines changed: 58 additions & 472 deletions

File tree

‎packages/agent/package.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,6 @@
8888
"@anthropic-ai/claude-agent-sdk": "0.2.59",
8989
"@anthropic-ai/sdk": "^0.71.0",
9090
"@hono/node-server": "^1.19.9",
91-
"@modelcontextprotocol/sdk": "^1.25.3",
9291
"@opentelemetry/api-logs": "^0.208.0",
9392
"@opentelemetry/exporter-logs-otlp-http": "^0.208.0",
9493
"@opentelemetry/resources": "^2.0.0",

‎packages/agent/src/adapters/claude/claude-agent.ts‎

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
192192
const modelOptions = await this.getModelConfigOptions();
193193

194194
// Deferred: slash commands + MCP metadata (not needed to return configOptions)
195-
this.deferBackgroundFetches(q, sessionId, mcpServers);
195+
this.deferBackgroundFetches(q, sessionId);
196196

197197
session.modelId = modelOptions.currentModelId;
198198
// Only call setModel if the resolved model differs from the default we
@@ -260,7 +260,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
260260
}
261261

262262
// Deferred: slash commands + MCP metadata (not needed to return configOptions)
263-
this.deferBackgroundFetches(q, sessionId, mcpServers);
263+
this.deferBackgroundFetches(q, sessionId);
264264

265265
const configOptions = await this.buildConfigOptions();
266266

@@ -514,14 +514,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
514514
* Fire-and-forget: fetch slash commands and MCP tool metadata in parallel.
515515
* Both populate caches used later — neither is needed to return configOptions.
516516
*/
517-
private deferBackgroundFetches(
518-
q: Query,
519-
sessionId: string,
520-
mcpServers: ReturnType<typeof parseMcpServers>,
521-
): void {
517+
private deferBackgroundFetches(q: Query, sessionId: string): void {
522518
Promise.all([
523519
getAvailableSlashCommands(q),
524-
fetchMcpToolMetadata(mcpServers, this.logger),
520+
fetchMcpToolMetadata(q, this.logger),
525521
])
526522
.then(([slashCommands]) => {
527523
this.sendAvailableCommandsUpdate(sessionId, slashCommands);

‎packages/agent/src/adapters/claude/mcp/tool-metadata.ts‎

Lines changed: 54 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,4 @@
1-
import type {
2-
McpHttpServerConfig,
3-
McpServerConfig,
4-
} from "@anthropic-ai/claude-agent-sdk";
5-
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6-
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
7-
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
1+
import type { McpServerStatus, Query } from "@anthropic-ai/claude-agent-sdk";
82
import { Logger } from "../../../utils/logger.js";
93

104
export interface McpToolMetadata {
@@ -13,86 +7,74 @@ export interface McpToolMetadata {
137

148
const mcpToolMetadataCache: Map<string, McpToolMetadata> = new Map();
159

10+
const PENDING_RETRY_INTERVAL_MS = 1_000;
11+
const PENDING_MAX_RETRIES = 10;
12+
1613
function buildToolKey(serverName: string, toolName: string): string {
1714
return `mcp__${serverName}__${toolName}`;
1815
}
1916

20-
function isHttpMcpServer(
21-
config: McpServerConfig,
22-
): config is McpHttpServerConfig {
23-
return config.type === "http" && typeof config.url === "string";
24-
}
25-
26-
async function fetchToolsFromHttpServer(
27-
_serverName: string,
28-
config: McpHttpServerConfig,
29-
): Promise<Tool[]> {
30-
const transport = new StreamableHTTPClientTransport(new URL(config.url), {
31-
requestInit: {
32-
headers: config.headers ?? {},
33-
},
34-
});
35-
36-
const client = new Client({
37-
name: "twig-metadata-fetcher",
38-
version: "1.0.0",
39-
});
40-
41-
try {
42-
await client.connect(transport);
43-
const result = await client.listTools();
44-
return result.tools;
45-
} finally {
46-
await client.close().catch(() => {});
47-
}
48-
}
49-
50-
function extractToolMetadata(tool: Tool): McpToolMetadata {
51-
return {
52-
readOnly: tool.annotations?.readOnlyHint === true,
53-
};
17+
function delay(ms: number): Promise<void> {
18+
return new Promise((resolve) => setTimeout(resolve, ms));
5419
}
5520

5621
export async function fetchMcpToolMetadata(
57-
mcpServers: Record<string, McpServerConfig>,
22+
q: Query,
5823
logger: Logger = new Logger({ debug: false, prefix: "[McpToolMetadata]" }),
5924
): Promise<void> {
60-
const fetchPromises: Promise<void>[] = [];
25+
let retries = 0;
26+
27+
while (retries <= PENDING_MAX_RETRIES) {
28+
let statuses: McpServerStatus[];
29+
try {
30+
statuses = await q.mcpServerStatus();
31+
} catch (error) {
32+
logger.error("Failed to fetch MCP server status", {
33+
error: error instanceof Error ? error.message : String(error),
34+
});
35+
return;
36+
}
37+
38+
const pendingServers = statuses.filter((s) => s.status === "pending");
39+
40+
for (const server of statuses) {
41+
if (server.status !== "connected" || !server.tools) {
42+
continue;
43+
}
44+
45+
let readOnlyCount = 0;
46+
for (const tool of server.tools) {
47+
const toolKey = buildToolKey(server.name, tool.name);
48+
const readOnly = tool.annotations?.readOnly === true;
49+
mcpToolMetadataCache.set(toolKey, { readOnly });
50+
if (readOnly) readOnlyCount++;
51+
}
52+
53+
logger.info("Fetched MCP tool metadata", {
54+
serverName: server.name,
55+
toolCount: server.tools.length,
56+
readOnlyCount,
57+
});
58+
}
6159

62-
for (const [serverName, config] of Object.entries(mcpServers)) {
63-
if (!isHttpMcpServer(config)) {
64-
continue;
60+
if (pendingServers.length === 0) {
61+
return;
6562
}
6663

67-
const fetchPromise = fetchToolsFromHttpServer(serverName, config)
68-
.then((tools) => {
69-
const toolCount = tools.length;
70-
const readOnlyCount = tools.filter(
71-
(t) => t.annotations?.readOnlyHint === true,
72-
).length;
73-
74-
for (const tool of tools) {
75-
const toolKey = buildToolKey(serverName, tool.name);
76-
mcpToolMetadataCache.set(toolKey, extractToolMetadata(tool));
77-
}
78-
79-
logger.info("Fetched MCP tool metadata", {
80-
serverName,
81-
toolCount,
82-
readOnlyCount,
83-
});
84-
})
85-
.catch((error) => {
86-
logger.error("Failed to fetch MCP tool metadata", {
87-
serverName,
88-
error: error instanceof Error ? error.message : String(error),
89-
});
64+
retries++;
65+
if (retries > PENDING_MAX_RETRIES) {
66+
logger.warn("Gave up waiting for pending MCP servers", {
67+
pendingServers: pendingServers.map((s) => s.name),
9068
});
69+
return;
70+
}
9171

92-
fetchPromises.push(fetchPromise);
72+
logger.info("Waiting for pending MCP servers", {
73+
pendingServers: pendingServers.map((s) => s.name),
74+
retry: retries,
75+
});
76+
await delay(PENDING_RETRY_INTERVAL_MS);
9377
}
94-
95-
await Promise.all(fetchPromises);
9678
}
9779

9880
export function isMcpToolReadOnly(toolName: string): boolean {

0 commit comments

Comments
 (0)