pi-mcp-adapter 1.5.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +91 -33
- package/CHANGELOG.md +38 -0
- package/README.md +60 -16
- package/cli.js +2 -0
- package/config.ts +1 -4
- package/index.ts +601 -255
- package/lifecycle.ts +34 -0
- package/metadata-cache.ts +175 -0
- package/npx-resolver.ts +419 -0
- package/package.json +7 -2
- package/resource-tools.ts +1 -29
- package/server-manager.ts +49 -5
- package/tool-registrar.ts +2 -33
- package/types.ts +29 -14
package/index.ts
CHANGED
|
@@ -1,27 +1,48 @@
|
|
|
1
1
|
// index.ts - Full extension entry point with commands
|
|
2
|
-
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext, ToolInfo } from "@mariozechner/pi-coding-agent";
|
|
3
3
|
import { Type } from "@sinclair/typebox";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
4
5
|
import { loadMcpConfig } from "./config.js";
|
|
5
|
-
import { formatToolName, type McpConfig, type McpContent } from "./types.js";
|
|
6
|
+
import { formatToolName, getServerPrefix, type McpConfig, type McpContent, type ToolMetadata, type McpTool, type McpResource, type ServerEntry } from "./types.js";
|
|
6
7
|
import { McpServerManager } from "./server-manager.js";
|
|
7
8
|
import { McpLifecycleManager } from "./lifecycle.js";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
9
|
+
import { transformMcpContent } from "./tool-registrar.js";
|
|
10
|
+
import { resourceNameToToolName } from "./resource-tools.js";
|
|
11
|
+
import {
|
|
12
|
+
computeServerHash,
|
|
13
|
+
getMetadataCachePath,
|
|
14
|
+
isServerCacheValid,
|
|
15
|
+
loadMetadataCache,
|
|
16
|
+
reconstructToolMetadata,
|
|
17
|
+
saveMetadataCache,
|
|
18
|
+
serializeResources,
|
|
19
|
+
serializeTools,
|
|
20
|
+
type ServerCacheEntry,
|
|
21
|
+
} from "./metadata-cache.js";
|
|
18
22
|
|
|
19
23
|
interface McpExtensionState {
|
|
20
24
|
manager: McpServerManager;
|
|
21
25
|
lifecycle: McpLifecycleManager;
|
|
22
|
-
registeredTools: Map<string, string[]>;
|
|
23
26
|
toolMetadata: Map<string, ToolMetadata[]>; // server -> tool metadata for searching
|
|
24
27
|
config: McpConfig;
|
|
28
|
+
failureTracker: Map<string, number>;
|
|
29
|
+
ui?: ExtensionContext["ui"];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const FAILURE_BACKOFF_MS = 60 * 1000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Find a tool by name with hyphen/underscore normalization fallback.
|
|
36
|
+
* MCP tools often use hyphens (resolve-library-id) but the prefix separator
|
|
37
|
+
* is underscore, so LLMs naturally guess all-underscores. Try exact match
|
|
38
|
+
* first, then normalized match.
|
|
39
|
+
*/
|
|
40
|
+
function findToolByName(metadata: ToolMetadata[] | undefined, toolName: string): ToolMetadata | undefined {
|
|
41
|
+
if (!metadata) return undefined;
|
|
42
|
+
const exact = metadata.find(m => m.name === toolName);
|
|
43
|
+
if (exact) return exact;
|
|
44
|
+
const normalized = toolName.replace(/-/g, "_");
|
|
45
|
+
return metadata.find(m => m.name.replace(/-/g, "_") === normalized);
|
|
25
46
|
}
|
|
26
47
|
|
|
27
48
|
/** Run async tasks with concurrency limit */
|
|
@@ -49,6 +70,9 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
49
70
|
let state: McpExtensionState | null = null;
|
|
50
71
|
let initPromise: Promise<McpExtensionState> | null = null;
|
|
51
72
|
|
|
73
|
+
// Capture pi tool accessor (closure) for unified search
|
|
74
|
+
const getPiTools = (): ToolInfo[] => pi.getAllTools();
|
|
75
|
+
|
|
52
76
|
pi.registerFlag("mcp-config", {
|
|
53
77
|
description: "Path to MCP config file",
|
|
54
78
|
type: "string",
|
|
@@ -61,24 +85,7 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
61
85
|
initPromise.then(s => {
|
|
62
86
|
state = s;
|
|
63
87
|
initPromise = null;
|
|
64
|
-
|
|
65
|
-
// Set up callback for auto-reconnect to update metadata
|
|
66
|
-
s.lifecycle.setReconnectCallback((serverName) => {
|
|
67
|
-
if (state) {
|
|
68
|
-
updateServerMetadata(state, serverName);
|
|
69
|
-
}
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
// Update status bar when ready
|
|
73
|
-
if (ctx.hasUI) {
|
|
74
|
-
const serverCount = s.registeredTools.size;
|
|
75
|
-
if (serverCount > 0) {
|
|
76
|
-
const label = serverCount === 1 ? "server" : "servers";
|
|
77
|
-
ctx.ui.setStatus("mcp", ctx.ui.theme.fg("accent", `MCP: ${serverCount} ${label}`));
|
|
78
|
-
} else {
|
|
79
|
-
ctx.ui.setStatus("mcp", "");
|
|
80
|
-
}
|
|
81
|
-
}
|
|
88
|
+
updateStatusBar(s);
|
|
82
89
|
}).catch(err => {
|
|
83
90
|
console.error("MCP initialization failed:", err);
|
|
84
91
|
initPromise = null;
|
|
@@ -95,6 +102,7 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
95
102
|
}
|
|
96
103
|
|
|
97
104
|
if (state) {
|
|
105
|
+
flushMetadataCache(state);
|
|
98
106
|
await state.lifecycle.gracefulShutdown();
|
|
99
107
|
state = null;
|
|
100
108
|
}
|
|
@@ -118,11 +126,13 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
118
126
|
return;
|
|
119
127
|
}
|
|
120
128
|
|
|
121
|
-
const
|
|
129
|
+
const parts = args?.trim()?.split(/\s+/) ?? [];
|
|
130
|
+
const subcommand = parts[0] ?? "";
|
|
131
|
+
const targetServer = parts[1];
|
|
122
132
|
|
|
123
133
|
switch (subcommand) {
|
|
124
134
|
case "reconnect":
|
|
125
|
-
await reconnectServers(state, ctx);
|
|
135
|
+
await reconnectServers(state, ctx, targetServer);
|
|
126
136
|
break;
|
|
127
137
|
case "tools":
|
|
128
138
|
await showTools(state, ctx);
|
|
@@ -173,15 +183,17 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
173
183
|
Usage:
|
|
174
184
|
mcp({ }) → Show server status
|
|
175
185
|
mcp({ server: "name" }) → List tools from server
|
|
176
|
-
mcp({ search: "query" }) → Search for tools (
|
|
186
|
+
mcp({ search: "query" }) → Search for tools (MCP + pi, space-separated words OR'd)
|
|
177
187
|
mcp({ describe: "tool_name" }) → Show tool details and parameters
|
|
188
|
+
mcp({ connect: "server-name" }) → Connect to a server and refresh metadata
|
|
178
189
|
mcp({ tool: "name", args: '{"key": "value"}' }) → Call a tool (args is JSON string)
|
|
179
190
|
|
|
180
|
-
Mode: tool (call) > describe > search > server (list) > nothing (status)`,
|
|
191
|
+
Mode: tool (call) > connect > describe > search > server (list) > nothing (status)`,
|
|
181
192
|
parameters: Type.Object({
|
|
182
193
|
// Call mode
|
|
183
194
|
tool: Type.Optional(Type.String({ description: "Tool name to call (e.g., 'xcodebuild_list_sims')" })),
|
|
184
195
|
args: Type.Optional(Type.String({ description: "Arguments as JSON string (e.g., '{\"key\": \"value\"}')" })),
|
|
196
|
+
connect: Type.Optional(Type.String({ description: "Server name to connect (lazy connect + metadata refresh)" })),
|
|
185
197
|
// Describe mode
|
|
186
198
|
describe: Type.Optional(Type.String({ description: "Tool name to describe (shows parameters)" })),
|
|
187
199
|
// Search mode
|
|
@@ -189,11 +201,12 @@ Mode: tool (call) > describe > search > server (list) > nothing (status)`,
|
|
|
189
201
|
regex: Type.Optional(Type.Boolean({ description: "Treat search as regex (default: substring match)" })),
|
|
190
202
|
includeSchemas: Type.Optional(Type.Boolean({ description: "Include parameter schemas in search results (default: true)" })),
|
|
191
203
|
// Filter (works with search or list)
|
|
192
|
-
server: Type.Optional(Type.String({ description: "Filter to specific server" })),
|
|
204
|
+
server: Type.Optional(Type.String({ description: "Filter to specific server (also disambiguates tool calls)" })),
|
|
193
205
|
}),
|
|
194
206
|
async execute(_toolCallId, params: {
|
|
195
207
|
tool?: string;
|
|
196
208
|
args?: string;
|
|
209
|
+
connect?: string;
|
|
197
210
|
describe?: string;
|
|
198
211
|
search?: string;
|
|
199
212
|
regex?: boolean;
|
|
@@ -240,15 +253,18 @@ Mode: tool (call) > describe > search > server (list) > nothing (status)`,
|
|
|
240
253
|
};
|
|
241
254
|
}
|
|
242
255
|
|
|
243
|
-
// Mode resolution: tool > describe > search > server > status
|
|
256
|
+
// Mode resolution: tool > connect > describe > search > server > status
|
|
244
257
|
if (params.tool) {
|
|
245
|
-
return executeCall(state, params.tool, parsedArgs);
|
|
258
|
+
return executeCall(state, params.tool, parsedArgs, params.server);
|
|
259
|
+
}
|
|
260
|
+
if (params.connect) {
|
|
261
|
+
return executeConnect(state, params.connect);
|
|
246
262
|
}
|
|
247
263
|
if (params.describe) {
|
|
248
264
|
return executeDescribe(state, params.describe);
|
|
249
265
|
}
|
|
250
266
|
if (params.search) {
|
|
251
|
-
return executeSearch(state, params.search, params.regex, params.server, params.includeSchemas);
|
|
267
|
+
return executeSearch(state, params.search, params.regex, params.server, params.includeSchemas, getPiTools);
|
|
252
268
|
}
|
|
253
269
|
if (params.server) {
|
|
254
270
|
return executeList(state, params.server);
|
|
@@ -262,30 +278,48 @@ Mode: tool (call) > describe > search > server (list) > nothing (status)`,
|
|
|
262
278
|
|
|
263
279
|
function executeStatus(state: McpExtensionState) {
|
|
264
280
|
const servers: Array<{ name: string; status: string; toolCount: number }> = [];
|
|
265
|
-
|
|
281
|
+
|
|
266
282
|
for (const name of Object.keys(state.config.mcpServers)) {
|
|
267
283
|
const connection = state.manager.getConnection(name);
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
})
|
|
284
|
+
const toolCount = getToolNames(state, name).length;
|
|
285
|
+
const failedAgo = getFailureAgeSeconds(state, name);
|
|
286
|
+
let status = "not connected";
|
|
287
|
+
if (connection?.status === "connected") {
|
|
288
|
+
status = "connected";
|
|
289
|
+
} else if (failedAgo !== null) {
|
|
290
|
+
status = "failed";
|
|
291
|
+
} else if (state.toolMetadata.has(name)) {
|
|
292
|
+
status = "cached";
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
servers.push({ name, status, toolCount });
|
|
274
296
|
}
|
|
275
|
-
|
|
297
|
+
|
|
276
298
|
const totalTools = servers.reduce((sum, s) => sum + s.toolCount, 0);
|
|
277
299
|
const connectedCount = servers.filter(s => s.status === "connected").length;
|
|
278
|
-
|
|
300
|
+
|
|
279
301
|
let text = `MCP: ${connectedCount}/${servers.length} servers, ${totalTools} tools\n\n`;
|
|
280
302
|
for (const server of servers) {
|
|
281
|
-
|
|
282
|
-
|
|
303
|
+
if (server.status === "connected") {
|
|
304
|
+
text += `✓ ${server.name} (${server.toolCount} tools)\n`;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (server.status === "cached") {
|
|
308
|
+
text += `○ ${server.name} (${server.toolCount} tools, cached)\n`;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (server.status === "failed") {
|
|
312
|
+
const failedAgo = getFailureAgeSeconds(state, server.name) ?? 0;
|
|
313
|
+
text += `✗ ${server.name} (failed ${failedAgo}s ago)\n`;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
text += `○ ${server.name} (not connected)\n`;
|
|
283
317
|
}
|
|
284
|
-
|
|
318
|
+
|
|
285
319
|
if (servers.length > 0) {
|
|
286
320
|
text += `\nmcp({ server: "name" }) to list tools, mcp({ search: "..." }) to search`;
|
|
287
321
|
}
|
|
288
|
-
|
|
322
|
+
|
|
289
323
|
return {
|
|
290
324
|
content: [{ type: "text" as const, text: text.trim() }],
|
|
291
325
|
details: { mode: "status", servers, totalTools, connectedCount },
|
|
@@ -298,7 +332,7 @@ function executeDescribe(state: McpExtensionState, toolName: string) {
|
|
|
298
332
|
let toolMeta: ToolMetadata | undefined;
|
|
299
333
|
|
|
300
334
|
for (const [server, metadata] of state.toolMetadata.entries()) {
|
|
301
|
-
const found = metadata
|
|
335
|
+
const found = findToolByName(metadata, toolName);
|
|
302
336
|
if (found) {
|
|
303
337
|
serverName = server;
|
|
304
338
|
toolMeta = found;
|
|
@@ -425,7 +459,8 @@ function executeSearch(
|
|
|
425
459
|
query: string,
|
|
426
460
|
regex?: boolean,
|
|
427
461
|
server?: string,
|
|
428
|
-
includeSchemas?: boolean
|
|
462
|
+
includeSchemas?: boolean,
|
|
463
|
+
getPiTools?: () => ToolInfo[]
|
|
429
464
|
) {
|
|
430
465
|
// Default to including schemas
|
|
431
466
|
const showSchemas = includeSchemas !== false;
|
|
@@ -455,6 +490,24 @@ function executeSearch(
|
|
|
455
490
|
};
|
|
456
491
|
}
|
|
457
492
|
|
|
493
|
+
// Search pi tools (unless server filter is specified)
|
|
494
|
+
const piMatches: Array<{ name: string; description: string }> = [];
|
|
495
|
+
if (!server && getPiTools) {
|
|
496
|
+
const piTools = getPiTools();
|
|
497
|
+
for (const tool of piTools) {
|
|
498
|
+
// Skip the mcp tool itself to avoid confusion
|
|
499
|
+
if (tool.name === "mcp") continue;
|
|
500
|
+
|
|
501
|
+
if (pattern.test(tool.name) || pattern.test(tool.description ?? "")) {
|
|
502
|
+
piMatches.push({
|
|
503
|
+
name: tool.name,
|
|
504
|
+
description: tool.description ?? "",
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Search MCP tools (existing logic)
|
|
458
511
|
for (const [serverName, metadata] of state.toolMetadata.entries()) {
|
|
459
512
|
if (server && serverName !== server) continue;
|
|
460
513
|
for (const tool of metadata) {
|
|
@@ -467,7 +520,10 @@ function executeSearch(
|
|
|
467
520
|
}
|
|
468
521
|
}
|
|
469
522
|
|
|
470
|
-
|
|
523
|
+
// Combine counts
|
|
524
|
+
const totalCount = piMatches.length + matches.length;
|
|
525
|
+
|
|
526
|
+
if (totalCount === 0) {
|
|
471
527
|
const msg = server
|
|
472
528
|
? `No tools matching "${query}" in "${server}"`
|
|
473
529
|
: `No tools matching "${query}"`;
|
|
@@ -477,8 +533,27 @@ function executeSearch(
|
|
|
477
533
|
};
|
|
478
534
|
}
|
|
479
535
|
|
|
480
|
-
let text = `Found ${
|
|
536
|
+
let text = `Found ${totalCount} tool${totalCount === 1 ? "" : "s"} matching "${query}":\n\n`;
|
|
537
|
+
|
|
538
|
+
// Pi tools first (with [pi tool] prefix)
|
|
539
|
+
for (const match of piMatches) {
|
|
540
|
+
if (showSchemas) {
|
|
541
|
+
// Full format (consistent with MCP tools)
|
|
542
|
+
text += `[pi tool] ${match.name}\n`;
|
|
543
|
+
text += ` ${match.description || "(no description)"}\n`;
|
|
544
|
+
text += ` No parameters (call directly).\n`;
|
|
545
|
+
text += "\n";
|
|
546
|
+
} else {
|
|
547
|
+
// Compact format
|
|
548
|
+
text += `[pi tool] ${match.name}`;
|
|
549
|
+
if (match.description) {
|
|
550
|
+
text += ` - ${truncateAtWord(match.description, 50)}`;
|
|
551
|
+
}
|
|
552
|
+
text += "\n";
|
|
553
|
+
}
|
|
554
|
+
}
|
|
481
555
|
|
|
556
|
+
// MCP tools (existing format, no prefix change for backwards compat)
|
|
482
557
|
for (const match of matches) {
|
|
483
558
|
if (showSchemas) {
|
|
484
559
|
// Full format with schema
|
|
@@ -502,38 +577,53 @@ function executeSearch(
|
|
|
502
577
|
|
|
503
578
|
return {
|
|
504
579
|
content: [{ type: "text" as const, text: text.trim() }],
|
|
505
|
-
details: {
|
|
580
|
+
details: {
|
|
581
|
+
mode: "search",
|
|
582
|
+
matches: [
|
|
583
|
+
...piMatches.map(m => ({ server: "pi", tool: m.name })),
|
|
584
|
+
...matches.map(m => ({ server: m.server, tool: m.tool.name })),
|
|
585
|
+
],
|
|
586
|
+
count: totalCount,
|
|
587
|
+
query,
|
|
588
|
+
},
|
|
506
589
|
};
|
|
507
590
|
}
|
|
508
591
|
|
|
509
592
|
function executeList(state: McpExtensionState, server: string) {
|
|
510
|
-
|
|
593
|
+
if (!state.config.mcpServers[server]) {
|
|
594
|
+
return {
|
|
595
|
+
content: [{ type: "text" as const, text: `Server "${server}" not found. Use mcp({}) to see available servers.` }],
|
|
596
|
+
details: { mode: "list", server, tools: [], count: 0, error: "not_found" },
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
511
600
|
const metadata = state.toolMetadata.get(server);
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
601
|
+
const toolNames = getToolNames(state, server);
|
|
602
|
+
const hasMetadata = state.toolMetadata.has(server);
|
|
603
|
+
const connection = state.manager.getConnection(server);
|
|
604
|
+
|
|
605
|
+
if (toolNames.length === 0) {
|
|
606
|
+
if (connection?.status === "connected") {
|
|
516
607
|
return {
|
|
517
608
|
content: [{ type: "text" as const, text: `Server "${server}" has no tools.` }],
|
|
518
609
|
details: { mode: "list", server, tools: [], count: 0 },
|
|
519
610
|
};
|
|
520
611
|
}
|
|
521
|
-
|
|
522
|
-
if (state.config.mcpServers[server]) {
|
|
612
|
+
if (hasMetadata) {
|
|
523
613
|
return {
|
|
524
|
-
content: [{ type: "text" as const, text: `Server "${server}"
|
|
525
|
-
details: { mode: "list", server, tools: [], count: 0,
|
|
614
|
+
content: [{ type: "text" as const, text: `Server "${server}" has no cached tools (not connected).` }],
|
|
615
|
+
details: { mode: "list", server, tools: [], count: 0, cached: true },
|
|
526
616
|
};
|
|
527
617
|
}
|
|
528
|
-
// Server not in config at all
|
|
529
618
|
return {
|
|
530
|
-
content: [{ type: "text" as const, text: `Server "${server}" not
|
|
531
|
-
details: { mode: "list", server, tools: [], count: 0, error: "
|
|
619
|
+
content: [{ type: "text" as const, text: `Server "${server}" is configured but not connected. Use mcp({ connect: "${server}" }) or /mcp reconnect ${server} to retry.` }],
|
|
620
|
+
details: { mode: "list", server, tools: [], count: 0, error: "not_connected" },
|
|
532
621
|
};
|
|
533
622
|
}
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
623
|
+
|
|
624
|
+
const cachedNote = connection?.status === "connected" ? "" : " (not connected, cached)";
|
|
625
|
+
let text = `${server} (${toolNames.length} tools${cachedNote}):\n\n`;
|
|
626
|
+
|
|
537
627
|
// Build a map of tool name -> description for quick lookup
|
|
538
628
|
const descMap = new Map<string, string>();
|
|
539
629
|
if (metadata) {
|
|
@@ -541,7 +631,7 @@ function executeList(state: McpExtensionState, server: string) {
|
|
|
541
631
|
descMap.set(m.name, m.description);
|
|
542
632
|
}
|
|
543
633
|
}
|
|
544
|
-
|
|
634
|
+
|
|
545
635
|
for (const tool of toolNames) {
|
|
546
636
|
const desc = descMap.get(tool) ?? "";
|
|
547
637
|
const truncated = truncateAtWord(desc, 50);
|
|
@@ -549,47 +639,181 @@ function executeList(state: McpExtensionState, server: string) {
|
|
|
549
639
|
if (truncated) text += ` - ${truncated}`;
|
|
550
640
|
text += "\n";
|
|
551
641
|
}
|
|
552
|
-
|
|
642
|
+
|
|
553
643
|
return {
|
|
554
644
|
content: [{ type: "text" as const, text: text.trim() }],
|
|
555
645
|
details: { mode: "list", server, tools: toolNames, count: toolNames.length },
|
|
556
646
|
};
|
|
557
647
|
}
|
|
558
648
|
|
|
649
|
+
async function executeConnect(state: McpExtensionState, serverName: string) {
|
|
650
|
+
const definition = state.config.mcpServers[serverName];
|
|
651
|
+
if (!definition) {
|
|
652
|
+
return {
|
|
653
|
+
content: [{ type: "text" as const, text: `Server "${serverName}" not found. Use mcp({}) to see available servers.` }],
|
|
654
|
+
details: { mode: "connect", error: "not_found", server: serverName },
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
try {
|
|
659
|
+
if (state.ui) {
|
|
660
|
+
state.ui.setStatus("mcp", `MCP: connecting to ${serverName}...`);
|
|
661
|
+
}
|
|
662
|
+
const connection = await state.manager.connect(serverName, definition);
|
|
663
|
+
const prefix = state.config.settings?.toolPrefix ?? "server";
|
|
664
|
+
const { metadata } = buildToolMetadata(connection.tools, connection.resources, definition, serverName, prefix);
|
|
665
|
+
state.toolMetadata.set(serverName, metadata);
|
|
666
|
+
updateMetadataCache(state, serverName);
|
|
667
|
+
state.failureTracker.delete(serverName);
|
|
668
|
+
updateStatusBar(state);
|
|
669
|
+
return executeList(state, serverName);
|
|
670
|
+
} catch (error) {
|
|
671
|
+
state.failureTracker.set(serverName, Date.now());
|
|
672
|
+
updateStatusBar(state);
|
|
673
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
674
|
+
return {
|
|
675
|
+
content: [{ type: "text" as const, text: `Failed to connect to "${serverName}": ${message}` }],
|
|
676
|
+
details: { mode: "connect", error: "connect_failed", server: serverName, message },
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
559
681
|
async function executeCall(
|
|
560
682
|
state: McpExtensionState,
|
|
561
683
|
toolName: string,
|
|
562
|
-
args?: Record<string, unknown
|
|
684
|
+
args?: Record<string, unknown>,
|
|
685
|
+
serverOverride?: string
|
|
563
686
|
) {
|
|
564
687
|
// Find the tool in metadata
|
|
565
|
-
let serverName: string | undefined;
|
|
688
|
+
let serverName: string | undefined = serverOverride;
|
|
566
689
|
let toolMeta: ToolMetadata | undefined;
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
serverName
|
|
572
|
-
|
|
573
|
-
|
|
690
|
+
const prefixMode = state.config.settings?.toolPrefix ?? "server";
|
|
691
|
+
|
|
692
|
+
if (serverName && !state.config.mcpServers[serverName]) {
|
|
693
|
+
return {
|
|
694
|
+
content: [{ type: "text" as const, text: `Server "${serverName}" not found. Use mcp({}) to see available servers.` }],
|
|
695
|
+
details: { mode: "call", error: "server_not_found", server: serverName },
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (serverName) {
|
|
700
|
+
toolMeta = findToolByName(state.toolMetadata.get(serverName), toolName);
|
|
701
|
+
} else {
|
|
702
|
+
for (const [server, metadata] of state.toolMetadata.entries()) {
|
|
703
|
+
const found = findToolByName(metadata, toolName);
|
|
704
|
+
if (found) {
|
|
705
|
+
serverName = server;
|
|
706
|
+
toolMeta = found;
|
|
707
|
+
break;
|
|
708
|
+
}
|
|
574
709
|
}
|
|
575
710
|
}
|
|
576
|
-
|
|
711
|
+
|
|
712
|
+
if (serverName && !toolMeta) {
|
|
713
|
+
const connected = await lazyConnect(state, serverName);
|
|
714
|
+
if (connected) {
|
|
715
|
+
toolMeta = findToolByName(state.toolMetadata.get(serverName), toolName);
|
|
716
|
+
} else {
|
|
717
|
+
const failedAgo = getFailureAgeSeconds(state, serverName);
|
|
718
|
+
if (failedAgo !== null) {
|
|
719
|
+
return {
|
|
720
|
+
content: [{ type: "text" as const, text: `Server "${serverName}" not available (last failed ${failedAgo}s ago)` }],
|
|
721
|
+
details: { mode: "call", error: "server_backoff", server: serverName },
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
let prefixMatchedServer: string | undefined;
|
|
728
|
+
|
|
729
|
+
if (!serverName && !toolMeta && prefixMode !== "none") {
|
|
730
|
+
const candidates = Object.keys(state.config.mcpServers)
|
|
731
|
+
.map(name => ({ name, prefix: getServerPrefix(name, prefixMode) }))
|
|
732
|
+
.filter(c => c.prefix && toolName.startsWith(c.prefix + "_"))
|
|
733
|
+
.sort((a, b) => b.prefix.length - a.prefix.length);
|
|
734
|
+
|
|
735
|
+
for (const { name: configuredServer } of candidates) {
|
|
736
|
+
const failedAgo = getFailureAgeSeconds(state, configuredServer);
|
|
737
|
+
if (failedAgo !== null) continue;
|
|
738
|
+
const connected = await lazyConnect(state, configuredServer);
|
|
739
|
+
if (!connected) continue;
|
|
740
|
+
if (!prefixMatchedServer) prefixMatchedServer = configuredServer;
|
|
741
|
+
toolMeta = findToolByName(state.toolMetadata.get(configuredServer), toolName);
|
|
742
|
+
if (toolMeta) {
|
|
743
|
+
serverName = configuredServer;
|
|
744
|
+
break;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
577
749
|
if (!serverName || !toolMeta) {
|
|
750
|
+
const hintServer = serverName ?? prefixMatchedServer;
|
|
751
|
+
const available = hintServer ? getToolNames(state, hintServer) : [];
|
|
752
|
+
let msg = `Tool "${toolName}" not found.`;
|
|
753
|
+
if (available.length > 0) {
|
|
754
|
+
msg += ` Server "${hintServer}" has: ${available.join(", ")}`;
|
|
755
|
+
} else {
|
|
756
|
+
msg += ` Use mcp({ search: "..." }) to search.`;
|
|
757
|
+
}
|
|
578
758
|
return {
|
|
579
|
-
content: [{ type: "text" as const, text:
|
|
580
|
-
details: { mode: "call", error: "tool_not_found", requestedTool: toolName },
|
|
759
|
+
content: [{ type: "text" as const, text: msg }],
|
|
760
|
+
details: { mode: "call", error: "tool_not_found", requestedTool: toolName, hintServer },
|
|
581
761
|
};
|
|
582
762
|
}
|
|
583
|
-
|
|
584
|
-
|
|
763
|
+
|
|
764
|
+
let connection = state.manager.getConnection(serverName);
|
|
585
765
|
if (!connection || connection.status !== "connected") {
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
766
|
+
const failedAgo = getFailureAgeSeconds(state, serverName);
|
|
767
|
+
if (failedAgo !== null) {
|
|
768
|
+
return {
|
|
769
|
+
content: [{ type: "text" as const, text: `Server "${serverName}" not available (last failed ${failedAgo}s ago)` }],
|
|
770
|
+
details: { mode: "call", error: "server_backoff", server: serverName },
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const definition = state.config.mcpServers[serverName];
|
|
775
|
+
if (!definition) {
|
|
776
|
+
return {
|
|
777
|
+
content: [{ type: "text" as const, text: `Server "${serverName}" not connected` }],
|
|
778
|
+
details: { mode: "call", error: "server_not_connected", server: serverName },
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
try {
|
|
783
|
+
if (state.ui) {
|
|
784
|
+
state.ui.setStatus("mcp", `MCP: connecting to ${serverName}...`);
|
|
785
|
+
}
|
|
786
|
+
connection = await state.manager.connect(serverName, definition);
|
|
787
|
+
state.failureTracker.delete(serverName);
|
|
788
|
+
updateServerMetadata(state, serverName);
|
|
789
|
+
updateMetadataCache(state, serverName);
|
|
790
|
+
updateStatusBar(state);
|
|
791
|
+
toolMeta = findToolByName(state.toolMetadata.get(serverName), toolName);
|
|
792
|
+
if (!toolMeta) {
|
|
793
|
+
const available = getToolNames(state, serverName);
|
|
794
|
+
const hint = available.length > 0
|
|
795
|
+
? `Available tools on "${serverName}": ${available.join(", ")}`
|
|
796
|
+
: `Server "${serverName}" has no tools.`;
|
|
797
|
+
return {
|
|
798
|
+
content: [{ type: "text" as const, text: `Tool "${toolName}" not found on "${serverName}" after reconnect. ${hint}` }],
|
|
799
|
+
details: { mode: "call", error: "tool_not_found_after_reconnect", requestedTool: toolName },
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
} catch (error) {
|
|
803
|
+
state.failureTracker.set(serverName, Date.now());
|
|
804
|
+
updateStatusBar(state);
|
|
805
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
806
|
+
return {
|
|
807
|
+
content: [{ type: "text" as const, text: `Failed to connect to "${serverName}": ${message}` }],
|
|
808
|
+
details: { mode: "call", error: "connect_failed", message },
|
|
809
|
+
};
|
|
810
|
+
}
|
|
590
811
|
}
|
|
591
|
-
|
|
812
|
+
|
|
592
813
|
try {
|
|
814
|
+
state.manager.touch(serverName);
|
|
815
|
+
state.manager.incrementInFlight(serverName);
|
|
816
|
+
|
|
593
817
|
// Resource tools use readResource, regular tools use callTool
|
|
594
818
|
if (toolMeta.resourceUri) {
|
|
595
819
|
const result = await connection.client.readResource({ uri: toolMeta.resourceUri });
|
|
@@ -602,51 +826,54 @@ async function executeCall(
|
|
|
602
826
|
details: { mode: "call", resourceUri: toolMeta.resourceUri, server: serverName },
|
|
603
827
|
};
|
|
604
828
|
}
|
|
605
|
-
|
|
829
|
+
|
|
606
830
|
// Regular tool call
|
|
607
831
|
const result = await connection.client.callTool({
|
|
608
832
|
name: toolMeta.originalName,
|
|
609
833
|
arguments: args ?? {},
|
|
610
834
|
});
|
|
611
|
-
|
|
835
|
+
|
|
612
836
|
const mcpContent = (result.content ?? []) as McpContent[];
|
|
613
837
|
const content = transformMcpContent(mcpContent);
|
|
614
|
-
|
|
838
|
+
|
|
615
839
|
if (result.isError) {
|
|
616
840
|
const errorText = content
|
|
617
841
|
.filter((c) => c.type === "text")
|
|
618
842
|
.map((c) => (c as { text: string }).text)
|
|
619
843
|
.join("\n") || "Tool execution failed";
|
|
620
|
-
|
|
844
|
+
|
|
621
845
|
// Include schema in error to help LLM self-correct
|
|
622
846
|
let errorWithSchema = `Error: ${errorText}`;
|
|
623
847
|
if (toolMeta.inputSchema) {
|
|
624
848
|
errorWithSchema += `\n\nExpected parameters:\n${formatSchema(toolMeta.inputSchema)}`;
|
|
625
849
|
}
|
|
626
|
-
|
|
850
|
+
|
|
627
851
|
return {
|
|
628
852
|
content: [{ type: "text" as const, text: errorWithSchema }],
|
|
629
853
|
details: { mode: "call", error: "tool_error", mcpResult: result },
|
|
630
854
|
};
|
|
631
855
|
}
|
|
632
|
-
|
|
856
|
+
|
|
633
857
|
return {
|
|
634
858
|
content: content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }],
|
|
635
859
|
details: { mode: "call", mcpResult: result, server: serverName, tool: toolMeta.originalName },
|
|
636
860
|
};
|
|
637
861
|
} catch (error) {
|
|
638
862
|
const message = error instanceof Error ? error.message : String(error);
|
|
639
|
-
|
|
863
|
+
|
|
640
864
|
// Include schema in error to help LLM self-correct
|
|
641
865
|
let errorWithSchema = `Failed to call tool: ${message}`;
|
|
642
866
|
if (toolMeta.inputSchema) {
|
|
643
867
|
errorWithSchema += `\n\nExpected parameters:\n${formatSchema(toolMeta.inputSchema)}`;
|
|
644
868
|
}
|
|
645
|
-
|
|
869
|
+
|
|
646
870
|
return {
|
|
647
871
|
content: [{ type: "text" as const, text: errorWithSchema }],
|
|
648
872
|
details: { mode: "call", error: "call_failed", message },
|
|
649
873
|
};
|
|
874
|
+
} finally {
|
|
875
|
+
state.manager.decrementInFlight(serverName);
|
|
876
|
+
state.manager.touch(serverName);
|
|
650
877
|
}
|
|
651
878
|
}
|
|
652
879
|
|
|
@@ -659,20 +886,66 @@ async function initializeMcp(
|
|
|
659
886
|
|
|
660
887
|
const manager = new McpServerManager();
|
|
661
888
|
const lifecycle = new McpLifecycleManager(manager);
|
|
662
|
-
const registeredTools = new Map<string, string[]>();
|
|
663
889
|
const toolMetadata = new Map<string, ToolMetadata[]>();
|
|
890
|
+
const failureTracker = new Map<string, number>();
|
|
891
|
+
const ui = ctx.hasUI ? ctx.ui : undefined;
|
|
892
|
+
const state: McpExtensionState = { manager, lifecycle, toolMetadata, config, failureTracker, ui };
|
|
664
893
|
|
|
665
894
|
const serverEntries = Object.entries(config.mcpServers);
|
|
666
895
|
if (serverEntries.length === 0) {
|
|
667
|
-
return
|
|
896
|
+
return state;
|
|
668
897
|
}
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
898
|
+
|
|
899
|
+
const idleSetting = typeof config.settings?.idleTimeout === "number" ? config.settings.idleTimeout : 10;
|
|
900
|
+
lifecycle.setGlobalIdleTimeout(idleSetting);
|
|
901
|
+
|
|
902
|
+
const cachePath = getMetadataCachePath();
|
|
903
|
+
const cacheFileExists = existsSync(cachePath);
|
|
904
|
+
let cache = loadMetadataCache();
|
|
905
|
+
let bootstrapAll = false;
|
|
906
|
+
|
|
907
|
+
if (!cacheFileExists) {
|
|
908
|
+
bootstrapAll = true;
|
|
909
|
+
saveMetadataCache({ version: 1, servers: {} });
|
|
910
|
+
} else if (!cache) {
|
|
911
|
+
cache = { version: 1, servers: {} };
|
|
912
|
+
saveMetadataCache(cache);
|
|
672
913
|
}
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
914
|
+
|
|
915
|
+
const prefix = config.settings?.toolPrefix ?? "server";
|
|
916
|
+
|
|
917
|
+
// Register servers and hydrate metadata from cache if valid
|
|
918
|
+
for (const [name, definition] of serverEntries) {
|
|
919
|
+
const lifecycleMode = definition.lifecycle ?? "lazy";
|
|
920
|
+
const idleOverride = definition.idleTimeout ?? (lifecycleMode === "eager" ? 0 : undefined);
|
|
921
|
+
lifecycle.registerServer(
|
|
922
|
+
name,
|
|
923
|
+
definition,
|
|
924
|
+
idleOverride !== undefined ? { idleTimeout: idleOverride } : undefined
|
|
925
|
+
);
|
|
926
|
+
if (lifecycleMode === "keep-alive") {
|
|
927
|
+
lifecycle.markKeepAlive(name, definition);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
if (cache?.servers?.[name] && isServerCacheValid(cache.servers[name], definition)) {
|
|
931
|
+
const metadata = reconstructToolMetadata(name, cache.servers[name], prefix, definition.exposeResources);
|
|
932
|
+
toolMetadata.set(name, metadata);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
const startupServers = bootstrapAll
|
|
937
|
+
? serverEntries
|
|
938
|
+
: serverEntries.filter(([, definition]) => {
|
|
939
|
+
const mode = definition.lifecycle ?? "lazy";
|
|
940
|
+
return mode === "keep-alive" || mode === "eager";
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
if (ctx.hasUI && startupServers.length > 0) {
|
|
944
|
+
ctx.ui.setStatus("mcp", `MCP: connecting to ${startupServers.length} servers...`);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Connect selected servers in parallel (max 10 concurrent)
|
|
948
|
+
const results = await parallelLimit(startupServers, 10, async ([name, definition]) => {
|
|
676
949
|
try {
|
|
677
950
|
const connection = await manager.connect(name, definition);
|
|
678
951
|
return { name, definition, connection, error: null };
|
|
@@ -681,8 +954,7 @@ async function initializeMcp(
|
|
|
681
954
|
return { name, definition, connection: null, error: message };
|
|
682
955
|
}
|
|
683
956
|
});
|
|
684
|
-
|
|
685
|
-
|
|
957
|
+
|
|
686
958
|
// Process results
|
|
687
959
|
for (const { name, definition, connection, error } of results) {
|
|
688
960
|
if (error || !connection) {
|
|
@@ -692,50 +964,11 @@ async function initializeMcp(
|
|
|
692
964
|
console.error(`MCP: Failed to connect to ${name}: ${error}`);
|
|
693
965
|
continue;
|
|
694
966
|
}
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
const { collected: toolNames, failed: failedTools } = collectToolNames(
|
|
698
|
-
connection.tools,
|
|
699
|
-
{ serverName: name, prefix }
|
|
700
|
-
);
|
|
701
|
-
|
|
702
|
-
// Collect resource tool names (if enabled)
|
|
703
|
-
if (definition.exposeResources !== false && connection.resources.length > 0) {
|
|
704
|
-
const resourceToolNames = collectResourceToolNames(
|
|
705
|
-
connection.resources,
|
|
706
|
-
{ serverName: name, prefix }
|
|
707
|
-
);
|
|
708
|
-
toolNames.push(...resourceToolNames);
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
registeredTools.set(name, toolNames);
|
|
712
|
-
|
|
713
|
-
// Build tool metadata for searching (include inputSchema for describe/errors)
|
|
714
|
-
const metadata: ToolMetadata[] = connection.tools.map(tool => ({
|
|
715
|
-
name: formatToolName(tool.name, name, prefix),
|
|
716
|
-
originalName: tool.name,
|
|
717
|
-
description: tool.description ?? "",
|
|
718
|
-
inputSchema: tool.inputSchema,
|
|
719
|
-
}));
|
|
720
|
-
// Add resource tools to metadata
|
|
721
|
-
for (const resource of connection.resources) {
|
|
722
|
-
if (definition.exposeResources !== false) {
|
|
723
|
-
const baseName = `get_${resourceNameToToolName(resource.name)}`;
|
|
724
|
-
metadata.push({
|
|
725
|
-
name: formatToolName(baseName, name, prefix),
|
|
726
|
-
originalName: baseName,
|
|
727
|
-
description: resource.description ?? `Read resource: ${resource.uri}`,
|
|
728
|
-
resourceUri: resource.uri,
|
|
729
|
-
});
|
|
730
|
-
}
|
|
731
|
-
}
|
|
967
|
+
|
|
968
|
+
const { metadata, failedTools } = buildToolMetadata(connection.tools, connection.resources, definition, name, prefix);
|
|
732
969
|
toolMetadata.set(name, metadata);
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
if (definition.lifecycle === "keep-alive") {
|
|
736
|
-
lifecycle.markKeepAlive(name, definition);
|
|
737
|
-
}
|
|
738
|
-
|
|
970
|
+
updateMetadataCache(state, name);
|
|
971
|
+
|
|
739
972
|
if (failedTools.length > 0 && ctx.hasUI) {
|
|
740
973
|
ctx.ui.notify(
|
|
741
974
|
`MCP: ${name} - ${failedTools.length} tools skipped`,
|
|
@@ -743,22 +976,34 @@ async function initializeMcp(
|
|
|
743
976
|
);
|
|
744
977
|
}
|
|
745
978
|
}
|
|
746
|
-
|
|
979
|
+
|
|
747
980
|
// Summary notification
|
|
748
981
|
const connectedCount = results.filter(r => r.connection).length;
|
|
749
982
|
const failedCount = results.filter(r => r.error).length;
|
|
750
983
|
if (ctx.hasUI && connectedCount > 0) {
|
|
751
|
-
const totalTools =
|
|
752
|
-
const msg = failedCount > 0
|
|
753
|
-
? `MCP: ${connectedCount}/${
|
|
984
|
+
const totalTools = totalToolCount(state);
|
|
985
|
+
const msg = failedCount > 0
|
|
986
|
+
? `MCP: ${connectedCount}/${startupServers.length} servers connected (${totalTools} tools)`
|
|
754
987
|
: `MCP: ${connectedCount} servers connected (${totalTools} tools)`;
|
|
755
988
|
ctx.ui.notify(msg, "info");
|
|
756
989
|
}
|
|
757
|
-
|
|
758
|
-
|
|
990
|
+
|
|
991
|
+
lifecycle.setReconnectCallback((serverName) => {
|
|
992
|
+
updateServerMetadata(state, serverName);
|
|
993
|
+
updateMetadataCache(state, serverName);
|
|
994
|
+
state.failureTracker.delete(serverName);
|
|
995
|
+
updateStatusBar(state);
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
lifecycle.setIdleShutdownCallback((serverName) => {
|
|
999
|
+
const idleMinutes = getEffectiveIdleTimeoutMinutes(state, serverName);
|
|
1000
|
+
console.log(`MCP: ${serverName} shut down (idle ${idleMinutes}m)`);
|
|
1001
|
+
updateStatusBar(state);
|
|
1002
|
+
});
|
|
1003
|
+
|
|
759
1004
|
lifecycle.startHealthChecks();
|
|
760
|
-
|
|
761
|
-
return
|
|
1005
|
+
|
|
1006
|
+
return state;
|
|
762
1007
|
}
|
|
763
1008
|
|
|
764
1009
|
/**
|
|
@@ -773,42 +1018,8 @@ function updateServerMetadata(state: McpExtensionState, serverName: string): voi
|
|
|
773
1018
|
if (!definition) return;
|
|
774
1019
|
|
|
775
1020
|
const prefix = state.config.settings?.toolPrefix ?? "server";
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
const { collected: toolNames } = collectToolNames(
|
|
779
|
-
connection.tools,
|
|
780
|
-
{ serverName, prefix }
|
|
781
|
-
);
|
|
782
|
-
|
|
783
|
-
// Collect resource tool names if enabled
|
|
784
|
-
if (definition.exposeResources !== false && connection.resources.length > 0) {
|
|
785
|
-
const resourceToolNames = collectResourceToolNames(
|
|
786
|
-
connection.resources,
|
|
787
|
-
{ serverName, prefix }
|
|
788
|
-
);
|
|
789
|
-
toolNames.push(...resourceToolNames);
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
state.registeredTools.set(serverName, toolNames);
|
|
793
|
-
|
|
794
|
-
// Update tool metadata (include inputSchema for describe/errors)
|
|
795
|
-
const metadata: ToolMetadata[] = connection.tools.map(tool => ({
|
|
796
|
-
name: formatToolName(tool.name, serverName, prefix),
|
|
797
|
-
originalName: tool.name,
|
|
798
|
-
description: tool.description ?? "",
|
|
799
|
-
inputSchema: tool.inputSchema,
|
|
800
|
-
}));
|
|
801
|
-
for (const resource of connection.resources) {
|
|
802
|
-
if (definition.exposeResources !== false) {
|
|
803
|
-
const baseName = `get_${resourceNameToToolName(resource.name)}`;
|
|
804
|
-
metadata.push({
|
|
805
|
-
name: formatToolName(baseName, serverName, prefix),
|
|
806
|
-
originalName: baseName,
|
|
807
|
-
description: resource.description ?? `Read resource: ${resource.uri}`,
|
|
808
|
-
resourceUri: resource.uri,
|
|
809
|
-
});
|
|
810
|
-
}
|
|
811
|
-
}
|
|
1021
|
+
|
|
1022
|
+
const { metadata } = buildToolMetadata(connection.tools, connection.resources, definition, serverName, prefix);
|
|
812
1023
|
state.toolMetadata.set(serverName, metadata);
|
|
813
1024
|
}
|
|
814
1025
|
|
|
@@ -820,11 +1031,25 @@ async function showStatus(state: McpExtensionState, ctx: ExtensionContext): Prom
|
|
|
820
1031
|
// Show all configured servers, not just connected ones
|
|
821
1032
|
for (const name of Object.keys(state.config.mcpServers)) {
|
|
822
1033
|
const connection = state.manager.getConnection(name);
|
|
823
|
-
const
|
|
824
|
-
const
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
1034
|
+
const toolCount = getToolNames(state, name).length;
|
|
1035
|
+
const failedAgo = getFailureAgeSeconds(state, name);
|
|
1036
|
+
let status = "not connected";
|
|
1037
|
+
let statusIcon = "○";
|
|
1038
|
+
let failed = false;
|
|
1039
|
+
|
|
1040
|
+
if (connection?.status === "connected") {
|
|
1041
|
+
status = "connected";
|
|
1042
|
+
statusIcon = "✓";
|
|
1043
|
+
} else if (failedAgo !== null) {
|
|
1044
|
+
status = `failed ${failedAgo}s ago`;
|
|
1045
|
+
statusIcon = "✗";
|
|
1046
|
+
failed = true;
|
|
1047
|
+
} else if (state.toolMetadata.has(name)) {
|
|
1048
|
+
status = "cached";
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
const toolSuffix = failed ? "" : ` (${toolCount} tools${status === "cached" ? ", cached" : ""})`;
|
|
1052
|
+
lines.push(`${statusIcon} ${name}: ${status}${toolSuffix}`);
|
|
828
1053
|
}
|
|
829
1054
|
|
|
830
1055
|
if (Object.keys(state.config.mcpServers).length === 0) {
|
|
@@ -837,7 +1062,7 @@ async function showStatus(state: McpExtensionState, ctx: ExtensionContext): Prom
|
|
|
837
1062
|
async function showTools(state: McpExtensionState, ctx: ExtensionContext): Promise<void> {
|
|
838
1063
|
if (!ctx.hasUI) return;
|
|
839
1064
|
|
|
840
|
-
const allTools = [...state.
|
|
1065
|
+
const allTools = [...state.toolMetadata.values()].flat().map(m => m.name);
|
|
841
1066
|
|
|
842
1067
|
if (allTools.length === 0) {
|
|
843
1068
|
ctx.ui.notify("No MCP tools available", "info");
|
|
@@ -857,56 +1082,32 @@ async function showTools(state: McpExtensionState, ctx: ExtensionContext): Promi
|
|
|
857
1082
|
|
|
858
1083
|
async function reconnectServers(
|
|
859
1084
|
state: McpExtensionState,
|
|
860
|
-
ctx: ExtensionContext
|
|
1085
|
+
ctx: ExtensionContext,
|
|
1086
|
+
targetServer?: string
|
|
861
1087
|
): Promise<void> {
|
|
862
|
-
|
|
1088
|
+
if (targetServer && !state.config.mcpServers[targetServer]) {
|
|
1089
|
+
if (ctx.hasUI) {
|
|
1090
|
+
ctx.ui.notify(`Server "${targetServer}" not found in config`, "error");
|
|
1091
|
+
}
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
const entries = targetServer
|
|
1096
|
+
? [[targetServer, state.config.mcpServers[targetServer]] as [string, ServerEntry]]
|
|
1097
|
+
: Object.entries(state.config.mcpServers);
|
|
1098
|
+
|
|
1099
|
+
for (const [name, definition] of entries) {
|
|
863
1100
|
try {
|
|
864
1101
|
await state.manager.close(name);
|
|
865
|
-
|
|
866
|
-
// Clear old entries before reconnecting (in case reconnection fails)
|
|
867
|
-
state.registeredTools.delete(name);
|
|
868
|
-
state.toolMetadata.delete(name);
|
|
869
|
-
|
|
1102
|
+
|
|
870
1103
|
const connection = await state.manager.connect(name, definition);
|
|
871
1104
|
const prefix = state.config.settings?.toolPrefix ?? "server";
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
const { collected: toolNames, failed: failedTools } = collectToolNames(
|
|
875
|
-
connection.tools,
|
|
876
|
-
{ serverName: name, prefix }
|
|
877
|
-
);
|
|
878
|
-
|
|
879
|
-
// Collect resource tool names if enabled
|
|
880
|
-
if (definition.exposeResources !== false && connection.resources.length > 0) {
|
|
881
|
-
const resourceToolNames = collectResourceToolNames(
|
|
882
|
-
connection.resources,
|
|
883
|
-
{ serverName: name, prefix }
|
|
884
|
-
);
|
|
885
|
-
toolNames.push(...resourceToolNames);
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
state.registeredTools.set(name, toolNames);
|
|
889
|
-
|
|
890
|
-
// Update tool metadata for searching (include inputSchema for describe/errors)
|
|
891
|
-
const metadata: ToolMetadata[] = connection.tools.map(tool => ({
|
|
892
|
-
name: formatToolName(tool.name, name, prefix),
|
|
893
|
-
originalName: tool.name,
|
|
894
|
-
description: tool.description ?? "",
|
|
895
|
-
inputSchema: tool.inputSchema,
|
|
896
|
-
}));
|
|
897
|
-
for (const resource of connection.resources) {
|
|
898
|
-
if (definition.exposeResources !== false) {
|
|
899
|
-
const baseName = `get_${resourceNameToToolName(resource.name)}`;
|
|
900
|
-
metadata.push({
|
|
901
|
-
name: formatToolName(baseName, name, prefix),
|
|
902
|
-
originalName: baseName,
|
|
903
|
-
description: resource.description ?? `Read resource: ${resource.uri}`,
|
|
904
|
-
resourceUri: resource.uri,
|
|
905
|
-
});
|
|
906
|
-
}
|
|
907
|
-
}
|
|
1105
|
+
|
|
1106
|
+
const { metadata, failedTools } = buildToolMetadata(connection.tools, connection.resources, definition, name, prefix);
|
|
908
1107
|
state.toolMetadata.set(name, metadata);
|
|
909
|
-
|
|
1108
|
+
updateMetadataCache(state, name);
|
|
1109
|
+
state.failureTracker.delete(name);
|
|
1110
|
+
|
|
910
1111
|
if (ctx.hasUI) {
|
|
911
1112
|
ctx.ui.notify(
|
|
912
1113
|
`MCP: Reconnected to ${name} (${connection.tools.length} tools, ${connection.resources.length} resources)`,
|
|
@@ -918,6 +1119,7 @@ async function reconnectServers(
|
|
|
918
1119
|
}
|
|
919
1120
|
} catch (error) {
|
|
920
1121
|
const message = error instanceof Error ? error.message : String(error);
|
|
1122
|
+
state.failureTracker.set(name, Date.now());
|
|
921
1123
|
if (ctx.hasUI) {
|
|
922
1124
|
ctx.ui.notify(`MCP: Failed to reconnect to ${name}: ${message}`, "error");
|
|
923
1125
|
}
|
|
@@ -925,14 +1127,158 @@ async function reconnectServers(
|
|
|
925
1127
|
}
|
|
926
1128
|
|
|
927
1129
|
// Update status bar with server count
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1130
|
+
updateStatusBar(state);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function buildToolMetadata(
|
|
1134
|
+
tools: McpTool[],
|
|
1135
|
+
resources: McpResource[],
|
|
1136
|
+
definition: ServerEntry,
|
|
1137
|
+
serverName: string,
|
|
1138
|
+
prefix: "server" | "none" | "short"
|
|
1139
|
+
): { metadata: ToolMetadata[]; failedTools: string[] } {
|
|
1140
|
+
const metadata: ToolMetadata[] = [];
|
|
1141
|
+
const failedTools: string[] = [];
|
|
1142
|
+
|
|
1143
|
+
for (const tool of tools) {
|
|
1144
|
+
if (!tool?.name) {
|
|
1145
|
+
failedTools.push("(unnamed)");
|
|
1146
|
+
continue;
|
|
1147
|
+
}
|
|
1148
|
+
metadata.push({
|
|
1149
|
+
name: formatToolName(tool.name, serverName, prefix),
|
|
1150
|
+
originalName: tool.name,
|
|
1151
|
+
description: tool.description ?? "",
|
|
1152
|
+
inputSchema: tool.inputSchema,
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
if (definition.exposeResources !== false) {
|
|
1157
|
+
for (const resource of resources) {
|
|
1158
|
+
const baseName = `get_${resourceNameToToolName(resource.name)}`;
|
|
1159
|
+
metadata.push({
|
|
1160
|
+
name: formatToolName(baseName, serverName, prefix),
|
|
1161
|
+
originalName: baseName,
|
|
1162
|
+
description: resource.description ?? `Read resource: ${resource.uri}`,
|
|
1163
|
+
resourceUri: resource.uri,
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
return { metadata, failedTools };
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function updateMetadataCache(state: McpExtensionState, serverName: string): void {
|
|
1172
|
+
const connection = state.manager.getConnection(serverName);
|
|
1173
|
+
if (!connection || connection.status !== "connected") return;
|
|
1174
|
+
|
|
1175
|
+
const definition = state.config.mcpServers[serverName];
|
|
1176
|
+
if (!definition) return;
|
|
1177
|
+
|
|
1178
|
+
const configHash = computeServerHash(definition);
|
|
1179
|
+
const existing = loadMetadataCache();
|
|
1180
|
+
const existingEntry = existing?.servers?.[serverName];
|
|
1181
|
+
|
|
1182
|
+
const tools = serializeTools(connection.tools);
|
|
1183
|
+
let resources = definition.exposeResources === false ? [] : serializeResources(connection.resources);
|
|
1184
|
+
|
|
1185
|
+
if (
|
|
1186
|
+
definition.exposeResources !== false &&
|
|
1187
|
+
resources.length === 0 &&
|
|
1188
|
+
existingEntry?.resources?.length &&
|
|
1189
|
+
existingEntry.configHash === configHash
|
|
1190
|
+
) {
|
|
1191
|
+
resources = existingEntry.resources;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
const entry: ServerCacheEntry = {
|
|
1195
|
+
configHash,
|
|
1196
|
+
tools,
|
|
1197
|
+
resources,
|
|
1198
|
+
cachedAt: Date.now(),
|
|
1199
|
+
};
|
|
1200
|
+
|
|
1201
|
+
saveMetadataCache({ version: 1, servers: { [serverName]: entry } });
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
function flushMetadataCache(state: McpExtensionState): void {
|
|
1205
|
+
for (const [name, connection] of state.manager.getAllConnections()) {
|
|
1206
|
+
if (connection.status === "connected") {
|
|
1207
|
+
updateMetadataCache(state, name);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function getToolNames(state: McpExtensionState, serverName: string): string[] {
|
|
1213
|
+
return state.toolMetadata.get(serverName)?.map(m => m.name) ?? [];
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
function totalToolCount(state: McpExtensionState): number {
|
|
1217
|
+
let count = 0;
|
|
1218
|
+
for (const metadata of state.toolMetadata.values()) {
|
|
1219
|
+
count += metadata.length;
|
|
1220
|
+
}
|
|
1221
|
+
return count;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
function updateStatusBar(state: McpExtensionState): void {
|
|
1225
|
+
const ui = state.ui;
|
|
1226
|
+
if (!ui) return;
|
|
1227
|
+
const total = Object.keys(state.config.mcpServers).length;
|
|
1228
|
+
if (total === 0) {
|
|
1229
|
+
ui.setStatus("mcp", "");
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
const connectedCount = state.manager.getAllConnections().size;
|
|
1233
|
+
ui.setStatus("mcp", ui.theme.fg("accent", `MCP: ${connectedCount}/${total} servers`));
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
function getFailureAgeSeconds(state: McpExtensionState, serverName: string): number | null {
|
|
1237
|
+
const failedAt = state.failureTracker.get(serverName);
|
|
1238
|
+
if (!failedAt) return null;
|
|
1239
|
+
const ageMs = Date.now() - failedAt;
|
|
1240
|
+
if (ageMs > FAILURE_BACKOFF_MS) return null;
|
|
1241
|
+
return Math.round(ageMs / 1000);
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
function getEffectiveIdleTimeoutMinutes(state: McpExtensionState, serverName: string): number {
|
|
1245
|
+
const definition = state.config.mcpServers[serverName];
|
|
1246
|
+
if (!definition) {
|
|
1247
|
+
return typeof state.config.settings?.idleTimeout === "number" ? state.config.settings.idleTimeout : 10;
|
|
1248
|
+
}
|
|
1249
|
+
if (typeof definition.idleTimeout === "number") return definition.idleTimeout;
|
|
1250
|
+
const mode = definition.lifecycle ?? "lazy";
|
|
1251
|
+
if (mode === "eager") return 0;
|
|
1252
|
+
return typeof state.config.settings?.idleTimeout === "number" ? state.config.settings.idleTimeout : 10;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
async function lazyConnect(state: McpExtensionState, serverName: string): Promise<boolean> {
|
|
1256
|
+
const connection = state.manager.getConnection(serverName);
|
|
1257
|
+
if (connection?.status === "connected") {
|
|
1258
|
+
updateServerMetadata(state, serverName);
|
|
1259
|
+
return true;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
const failedAgo = getFailureAgeSeconds(state, serverName);
|
|
1263
|
+
if (failedAgo !== null) return false;
|
|
1264
|
+
|
|
1265
|
+
const definition = state.config.mcpServers[serverName];
|
|
1266
|
+
if (!definition) return false;
|
|
1267
|
+
|
|
1268
|
+
try {
|
|
1269
|
+
if (state.ui) {
|
|
1270
|
+
state.ui.setStatus("mcp", `MCP: connecting to ${serverName}...`);
|
|
935
1271
|
}
|
|
1272
|
+
await state.manager.connect(serverName, definition);
|
|
1273
|
+
state.failureTracker.delete(serverName);
|
|
1274
|
+
updateServerMetadata(state, serverName);
|
|
1275
|
+
updateMetadataCache(state, serverName);
|
|
1276
|
+
updateStatusBar(state);
|
|
1277
|
+
return true;
|
|
1278
|
+
} catch {
|
|
1279
|
+
state.failureTracker.set(serverName, Date.now());
|
|
1280
|
+
updateStatusBar(state);
|
|
1281
|
+
return false;
|
|
936
1282
|
}
|
|
937
1283
|
}
|
|
938
1284
|
|