pi-mcp-adapter 2.1.2 → 2.2.1
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/CHANGELOG.md +39 -0
- package/README.md +60 -2
- package/app-bridge.bundle.js +67 -0
- package/cli.js +0 -1
- package/commands.ts +210 -0
- package/consent-manager.ts +64 -0
- package/direct-tools.ts +301 -0
- package/errors.ts +219 -0
- package/glimpse-ui.ts +80 -0
- package/host-html-template.ts +427 -0
- package/index.ts +38 -1512
- package/init.ts +319 -0
- package/lifecycle.ts +2 -2
- package/logger.ts +169 -0
- package/metadata-cache.ts +16 -0
- package/package.json +27 -4
- package/proxy-modes.ts +635 -0
- package/server-manager.ts +48 -4
- package/state.ts +41 -0
- package/tool-metadata.ts +144 -0
- package/types.ts +211 -0
- package/ui-resource-handler.ts +145 -0
- package/ui-server.ts +623 -0
- package/ui-session.ts +384 -0
- package/ui-stream-types.ts +89 -0
- package/utils.ts +75 -0
- package/ARCHITECTURE.md +0 -630
package/index.ts
CHANGED
|
@@ -1,234 +1,13 @@
|
|
|
1
|
-
// index.ts - Full extension entry point with commands
|
|
2
1
|
import type { ExtensionAPI, ExtensionContext, ToolInfo } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import type { McpExtensionState } from "./state.js";
|
|
3
3
|
import { Type } from "@sinclair/typebox";
|
|
4
|
-
import {
|
|
5
|
-
import { loadMcpConfig
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import { getStoredTokens } from "./oauth-handler.js";
|
|
12
|
-
import {
|
|
13
|
-
computeServerHash,
|
|
14
|
-
getMetadataCachePath,
|
|
15
|
-
isServerCacheValid,
|
|
16
|
-
loadMetadataCache,
|
|
17
|
-
type MetadataCache,
|
|
18
|
-
reconstructToolMetadata,
|
|
19
|
-
saveMetadataCache,
|
|
20
|
-
serializeResources,
|
|
21
|
-
serializeTools,
|
|
22
|
-
type ServerCacheEntry,
|
|
23
|
-
} from "./metadata-cache.js";
|
|
24
|
-
|
|
25
|
-
interface McpExtensionState {
|
|
26
|
-
manager: McpServerManager;
|
|
27
|
-
lifecycle: McpLifecycleManager;
|
|
28
|
-
toolMetadata: Map<string, ToolMetadata[]>; // server -> tool metadata for searching
|
|
29
|
-
config: McpConfig;
|
|
30
|
-
failureTracker: Map<string, number>;
|
|
31
|
-
ui?: ExtensionContext["ui"];
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const FAILURE_BACKOFF_MS = 60 * 1000;
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Find a tool by name with hyphen/underscore normalization fallback.
|
|
38
|
-
* MCP tools often use hyphens (resolve-library-id) but the prefix separator
|
|
39
|
-
* is underscore, so LLMs naturally guess all-underscores. Try exact match
|
|
40
|
-
* first, then normalized match.
|
|
41
|
-
*/
|
|
42
|
-
function findToolByName(metadata: ToolMetadata[] | undefined, toolName: string): ToolMetadata | undefined {
|
|
43
|
-
if (!metadata) return undefined;
|
|
44
|
-
const exact = metadata.find(m => m.name === toolName);
|
|
45
|
-
if (exact) return exact;
|
|
46
|
-
const normalized = toolName.replace(/-/g, "_");
|
|
47
|
-
return metadata.find(m => m.name.replace(/-/g, "_") === normalized);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** Run async tasks with concurrency limit */
|
|
51
|
-
async function parallelLimit<T, R>(
|
|
52
|
-
items: T[],
|
|
53
|
-
limit: number,
|
|
54
|
-
fn: (item: T) => Promise<R>
|
|
55
|
-
): Promise<R[]> {
|
|
56
|
-
const results: R[] = [];
|
|
57
|
-
let index = 0;
|
|
58
|
-
|
|
59
|
-
async function worker() {
|
|
60
|
-
while (index < items.length) {
|
|
61
|
-
const i = index++;
|
|
62
|
-
results[i] = await fn(items[i]);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const workers = Array(Math.min(limit, items.length)).fill(null).map(() => worker());
|
|
67
|
-
await Promise.all(workers);
|
|
68
|
-
return results;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const BUILTIN_NAMES = new Set(["read", "bash", "edit", "write", "grep", "find", "ls", "mcp"]);
|
|
72
|
-
|
|
73
|
-
function getConfigPathFromArgv(): string | undefined {
|
|
74
|
-
const idx = process.argv.indexOf("--mcp-config");
|
|
75
|
-
if (idx >= 0 && idx + 1 < process.argv.length) {
|
|
76
|
-
return process.argv[idx + 1];
|
|
77
|
-
}
|
|
78
|
-
return undefined;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function resolveDirectTools(
|
|
82
|
-
config: McpConfig,
|
|
83
|
-
cache: MetadataCache | null,
|
|
84
|
-
prefix: "server" | "none" | "short",
|
|
85
|
-
envOverride?: string[],
|
|
86
|
-
): DirectToolSpec[] {
|
|
87
|
-
const specs: DirectToolSpec[] = [];
|
|
88
|
-
if (!cache) return specs;
|
|
89
|
-
|
|
90
|
-
const seenNames = new Set<string>();
|
|
91
|
-
|
|
92
|
-
const envServers = new Set<string>();
|
|
93
|
-
const envTools = new Map<string, Set<string>>();
|
|
94
|
-
if (envOverride) {
|
|
95
|
-
for (let item of envOverride) {
|
|
96
|
-
item = item.replace(/\/+$/, "");
|
|
97
|
-
if (item.includes("/")) {
|
|
98
|
-
const [server, tool] = item.split("/", 2);
|
|
99
|
-
if (server && tool) {
|
|
100
|
-
if (!envTools.has(server)) envTools.set(server, new Set());
|
|
101
|
-
envTools.get(server)!.add(tool);
|
|
102
|
-
} else if (server) {
|
|
103
|
-
envServers.add(server);
|
|
104
|
-
}
|
|
105
|
-
} else if (item) {
|
|
106
|
-
envServers.add(item);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const globalDirect = config.settings?.directTools;
|
|
112
|
-
|
|
113
|
-
for (const [serverName, definition] of Object.entries(config.mcpServers)) {
|
|
114
|
-
const serverCache = cache.servers[serverName];
|
|
115
|
-
if (!serverCache || !isServerCacheValid(serverCache, definition)) continue;
|
|
116
|
-
|
|
117
|
-
let toolFilter: true | string[] | false = false;
|
|
118
|
-
|
|
119
|
-
if (envOverride) {
|
|
120
|
-
if (envServers.has(serverName)) {
|
|
121
|
-
toolFilter = true;
|
|
122
|
-
} else if (envTools.has(serverName)) {
|
|
123
|
-
toolFilter = [...envTools.get(serverName)!];
|
|
124
|
-
}
|
|
125
|
-
} else {
|
|
126
|
-
if (definition.directTools !== undefined) {
|
|
127
|
-
toolFilter = definition.directTools;
|
|
128
|
-
} else if (globalDirect) {
|
|
129
|
-
toolFilter = globalDirect;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
if (!toolFilter) continue;
|
|
134
|
-
|
|
135
|
-
for (const tool of serverCache.tools ?? []) {
|
|
136
|
-
if (toolFilter !== true && !toolFilter.includes(tool.name)) continue;
|
|
137
|
-
const prefixedName = formatToolName(tool.name, serverName, prefix);
|
|
138
|
-
if (BUILTIN_NAMES.has(prefixedName)) {
|
|
139
|
-
console.warn(`MCP: skipping direct tool "${prefixedName}" (collides with builtin)`);
|
|
140
|
-
continue;
|
|
141
|
-
}
|
|
142
|
-
if (seenNames.has(prefixedName)) {
|
|
143
|
-
console.warn(`MCP: skipping duplicate direct tool "${prefixedName}" from "${serverName}"`);
|
|
144
|
-
continue;
|
|
145
|
-
}
|
|
146
|
-
seenNames.add(prefixedName);
|
|
147
|
-
specs.push({
|
|
148
|
-
serverName,
|
|
149
|
-
originalName: tool.name,
|
|
150
|
-
prefixedName,
|
|
151
|
-
description: tool.description ?? "",
|
|
152
|
-
inputSchema: tool.inputSchema,
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
if (definition.exposeResources !== false) {
|
|
157
|
-
for (const resource of serverCache.resources ?? []) {
|
|
158
|
-
const baseName = `get_${resourceNameToToolName(resource.name)}`;
|
|
159
|
-
if (toolFilter !== true && !toolFilter.includes(baseName)) continue;
|
|
160
|
-
const prefixedName = formatToolName(baseName, serverName, prefix);
|
|
161
|
-
if (BUILTIN_NAMES.has(prefixedName)) {
|
|
162
|
-
console.warn(`MCP: skipping direct resource tool "${prefixedName}" (collides with builtin)`);
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
|
-
if (seenNames.has(prefixedName)) {
|
|
166
|
-
console.warn(`MCP: skipping duplicate direct resource tool "${prefixedName}" from "${serverName}"`);
|
|
167
|
-
continue;
|
|
168
|
-
}
|
|
169
|
-
seenNames.add(prefixedName);
|
|
170
|
-
specs.push({
|
|
171
|
-
serverName,
|
|
172
|
-
originalName: baseName,
|
|
173
|
-
prefixedName,
|
|
174
|
-
description: resource.description ?? `Read resource: ${resource.uri}`,
|
|
175
|
-
resourceUri: resource.uri,
|
|
176
|
-
});
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
return specs;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function buildProxyDescription(
|
|
185
|
-
config: McpConfig,
|
|
186
|
-
cache: MetadataCache | null,
|
|
187
|
-
directSpecs: DirectToolSpec[],
|
|
188
|
-
): string {
|
|
189
|
-
let desc = `MCP gateway - connect to MCP servers and call their tools.\n`;
|
|
190
|
-
|
|
191
|
-
const directByServer = new Map<string, number>();
|
|
192
|
-
for (const spec of directSpecs) {
|
|
193
|
-
directByServer.set(spec.serverName, (directByServer.get(spec.serverName) ?? 0) + 1);
|
|
194
|
-
}
|
|
195
|
-
if (directByServer.size > 0) {
|
|
196
|
-
const parts = [...directByServer.entries()].map(
|
|
197
|
-
([server, count]) => `${server} (${count})`,
|
|
198
|
-
);
|
|
199
|
-
desc += `\nDirect tools available (call as normal tools): ${parts.join(", ")}\n`;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
const serverSummaries: string[] = [];
|
|
203
|
-
for (const serverName of Object.keys(config.mcpServers)) {
|
|
204
|
-
const entry = cache?.servers?.[serverName];
|
|
205
|
-
const definition = config.mcpServers[serverName];
|
|
206
|
-
const toolCount = entry?.tools?.length ?? 0;
|
|
207
|
-
const resourceCount = definition?.exposeResources !== false ? (entry?.resources?.length ?? 0) : 0;
|
|
208
|
-
const totalItems = toolCount + resourceCount;
|
|
209
|
-
if (totalItems === 0) continue;
|
|
210
|
-
const directCount = directByServer.get(serverName) ?? 0;
|
|
211
|
-
const proxyCount = totalItems - directCount;
|
|
212
|
-
if (proxyCount > 0) {
|
|
213
|
-
serverSummaries.push(`${serverName} (${proxyCount} tools)`);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
if (serverSummaries.length > 0) {
|
|
218
|
-
desc += `\nServers: ${serverSummaries.join(", ")}\n`;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
desc += `\nUsage:\n`;
|
|
222
|
-
desc += ` mcp({ }) → Show server status\n`;
|
|
223
|
-
desc += ` mcp({ server: "name" }) → List tools from server\n`;
|
|
224
|
-
desc += ` mcp({ search: "query" }) → Search for tools (MCP + pi, space-separated words OR'd)\n`;
|
|
225
|
-
desc += ` mcp({ describe: "tool_name" }) → Show tool details and parameters\n`;
|
|
226
|
-
desc += ` mcp({ connect: "server-name" }) → Connect to a server and refresh metadata\n`;
|
|
227
|
-
desc += ` mcp({ tool: "name", args: '{"key": "value"}' }) → Call a tool (args is JSON string)\n`;
|
|
228
|
-
desc += `\nMode: tool (call) > connect > describe > search > server (list) > nothing (status)`;
|
|
229
|
-
|
|
230
|
-
return desc;
|
|
231
|
-
}
|
|
4
|
+
import { showStatus, showTools, reconnectServers, authenticateServer, openMcpPanel } from "./commands.js";
|
|
5
|
+
import { loadMcpConfig } from "./config.js";
|
|
6
|
+
import { buildProxyDescription, createDirectToolExecutor, resolveDirectTools } from "./direct-tools.js";
|
|
7
|
+
import { flushMetadataCache, initializeMcp, updateStatusBar } from "./init.js";
|
|
8
|
+
import { loadMetadataCache } from "./metadata-cache.js";
|
|
9
|
+
import { executeCall, executeConnect, executeDescribe, executeList, executeSearch, executeStatus, executeUiMessages } from "./proxy-modes.js";
|
|
10
|
+
import { getConfigPathFromArgv, truncateAtWord } from "./utils.js";
|
|
232
11
|
|
|
233
12
|
export default function mcpAdapter(pi: ExtensionAPI) {
|
|
234
13
|
let state: McpExtensionState | null = null;
|
|
@@ -254,110 +33,22 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
254
33
|
name: spec.prefixedName,
|
|
255
34
|
label: `MCP: ${spec.originalName}`,
|
|
256
35
|
description: spec.description || "(no description)",
|
|
36
|
+
promptSnippet: truncateAtWord(spec.description, 100) || `MCP tool from ${spec.serverName}`,
|
|
257
37
|
parameters: Type.Unsafe<Record<string, unknown>>(spec.inputSchema || { type: "object", properties: {} }),
|
|
258
|
-
|
|
259
|
-
if (!state && initPromise) {
|
|
260
|
-
try { state = await initPromise; } catch {
|
|
261
|
-
return {
|
|
262
|
-
content: [{ type: "text" as const, text: "MCP initialization failed" }],
|
|
263
|
-
details: { error: "init_failed" },
|
|
264
|
-
};
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
if (!state) {
|
|
268
|
-
return {
|
|
269
|
-
content: [{ type: "text" as const, text: "MCP not initialized" }],
|
|
270
|
-
details: { error: "not_initialized" },
|
|
271
|
-
};
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
const s = state;
|
|
275
|
-
const connected = await lazyConnect(s, spec.serverName);
|
|
276
|
-
if (!connected) {
|
|
277
|
-
const failedAgo = getFailureAgeSeconds(s, spec.serverName);
|
|
278
|
-
return {
|
|
279
|
-
content: [{ type: "text" as const, text: `MCP server "${spec.serverName}" not available${failedAgo !== null ? ` (failed ${failedAgo}s ago)` : ""}` }],
|
|
280
|
-
details: { error: "server_unavailable", server: spec.serverName },
|
|
281
|
-
};
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
const connection = s.manager.getConnection(spec.serverName);
|
|
285
|
-
if (!connection || connection.status !== "connected") {
|
|
286
|
-
return {
|
|
287
|
-
content: [{ type: "text" as const, text: `MCP server "${spec.serverName}" not connected` }],
|
|
288
|
-
details: { error: "not_connected", server: spec.serverName },
|
|
289
|
-
};
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
try {
|
|
293
|
-
s.manager.touch(spec.serverName);
|
|
294
|
-
s.manager.incrementInFlight(spec.serverName);
|
|
295
|
-
|
|
296
|
-
if (spec.resourceUri) {
|
|
297
|
-
const result = await connection.client.readResource({ uri: spec.resourceUri });
|
|
298
|
-
const content = (result.contents ?? []).map(c => ({
|
|
299
|
-
type: "text" as const,
|
|
300
|
-
text: "text" in c ? c.text : ("blob" in c ? `[Binary data: ${(c as { mimeType?: string }).mimeType ?? "unknown"}]` : JSON.stringify(c)),
|
|
301
|
-
}));
|
|
302
|
-
return {
|
|
303
|
-
content: content.length > 0 ? content : [{ type: "text" as const, text: "(empty resource)" }],
|
|
304
|
-
details: { server: spec.serverName, resourceUri: spec.resourceUri },
|
|
305
|
-
};
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
const result = await connection.client.callTool({
|
|
309
|
-
name: spec.originalName,
|
|
310
|
-
arguments: params ?? {},
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
const mcpContent = (result.content ?? []) as McpContent[];
|
|
314
|
-
const content = transformMcpContent(mcpContent);
|
|
315
|
-
|
|
316
|
-
if (result.isError) {
|
|
317
|
-
let errorText = content.filter(c => c.type === "text").map(c => (c as { text: string }).text).join("\n") || "Tool execution failed";
|
|
318
|
-
if (spec.inputSchema) {
|
|
319
|
-
errorText += `\n\nExpected parameters:\n${formatSchema(spec.inputSchema)}`;
|
|
320
|
-
}
|
|
321
|
-
return {
|
|
322
|
-
content: [{ type: "text" as const, text: `Error: ${errorText}` }],
|
|
323
|
-
details: { error: "tool_error", server: spec.serverName },
|
|
324
|
-
};
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
return {
|
|
328
|
-
content: content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }],
|
|
329
|
-
details: { server: spec.serverName, tool: spec.originalName },
|
|
330
|
-
};
|
|
331
|
-
} catch (error) {
|
|
332
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
333
|
-
let errorText = `Failed to call tool: ${message}`;
|
|
334
|
-
if (spec.inputSchema) {
|
|
335
|
-
errorText += `\n\nExpected parameters:\n${formatSchema(spec.inputSchema)}`;
|
|
336
|
-
}
|
|
337
|
-
return {
|
|
338
|
-
content: [{ type: "text" as const, text: errorText }],
|
|
339
|
-
details: { error: "call_failed", server: spec.serverName },
|
|
340
|
-
};
|
|
341
|
-
} finally {
|
|
342
|
-
s.manager.decrementInFlight(spec.serverName);
|
|
343
|
-
s.manager.touch(spec.serverName);
|
|
344
|
-
}
|
|
345
|
-
},
|
|
38
|
+
execute: createDirectToolExecutor(() => state, () => initPromise, spec),
|
|
346
39
|
});
|
|
347
40
|
}
|
|
348
41
|
|
|
349
|
-
// Capture pi tool accessor (closure) for unified search
|
|
350
42
|
const getPiTools = (): ToolInfo[] => pi.getAllTools();
|
|
351
|
-
|
|
43
|
+
|
|
352
44
|
pi.registerFlag("mcp-config", {
|
|
353
45
|
description: "Path to MCP config file",
|
|
354
46
|
type: "string",
|
|
355
47
|
});
|
|
356
|
-
|
|
48
|
+
|
|
357
49
|
pi.on("session_start", async (_event, ctx) => {
|
|
358
|
-
// Non-blocking init - Pi starts immediately, MCP connects in background
|
|
359
50
|
initPromise = initializeMcp(pi, ctx);
|
|
360
|
-
|
|
51
|
+
|
|
361
52
|
initPromise.then(s => {
|
|
362
53
|
state = s;
|
|
363
54
|
initPromise = null;
|
|
@@ -367,7 +58,7 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
367
58
|
initPromise = null;
|
|
368
59
|
});
|
|
369
60
|
});
|
|
370
|
-
|
|
61
|
+
|
|
371
62
|
pi.on("session_shutdown", async () => {
|
|
372
63
|
if (initPromise) {
|
|
373
64
|
try {
|
|
@@ -376,19 +67,21 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
376
67
|
// Initialization failed, nothing to clean up
|
|
377
68
|
}
|
|
378
69
|
}
|
|
379
|
-
|
|
70
|
+
|
|
380
71
|
if (state) {
|
|
72
|
+
if (state.uiServer) {
|
|
73
|
+
state.uiServer.close("session_shutdown");
|
|
74
|
+
state.uiServer = null;
|
|
75
|
+
}
|
|
381
76
|
flushMetadataCache(state);
|
|
382
77
|
await state.lifecycle.gracefulShutdown();
|
|
383
78
|
state = null;
|
|
384
79
|
}
|
|
385
80
|
});
|
|
386
|
-
|
|
387
|
-
// /mcp command
|
|
81
|
+
|
|
388
82
|
pi.registerCommand("mcp", {
|
|
389
83
|
description: "Show MCP server status",
|
|
390
84
|
handler: async (args, ctx) => {
|
|
391
|
-
// Wait for init if still in progress
|
|
392
85
|
if (!state && initPromise) {
|
|
393
86
|
try {
|
|
394
87
|
state = await initPromise;
|
|
@@ -401,11 +94,11 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
401
94
|
if (ctx.hasUI) ctx.ui.notify("MCP not initialized", "error");
|
|
402
95
|
return;
|
|
403
96
|
}
|
|
404
|
-
|
|
97
|
+
|
|
405
98
|
const parts = args?.trim()?.split(/\s+/) ?? [];
|
|
406
99
|
const subcommand = parts[0] ?? "";
|
|
407
100
|
const targetServer = parts[1];
|
|
408
|
-
|
|
101
|
+
|
|
409
102
|
switch (subcommand) {
|
|
410
103
|
case "reconnect":
|
|
411
104
|
await reconnectServers(state, ctx, targetServer);
|
|
@@ -425,8 +118,7 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
425
118
|
}
|
|
426
119
|
},
|
|
427
120
|
});
|
|
428
|
-
|
|
429
|
-
// /mcp-auth command
|
|
121
|
+
|
|
430
122
|
pi.registerCommand("mcp-auth", {
|
|
431
123
|
description: "Authenticate with an MCP server (OAuth)",
|
|
432
124
|
handler: async (args, ctx) => {
|
|
@@ -435,8 +127,7 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
435
127
|
if (ctx.hasUI) ctx.ui.notify("Usage: /mcp-auth <server-name>", "error");
|
|
436
128
|
return;
|
|
437
129
|
}
|
|
438
|
-
|
|
439
|
-
// Wait for init if still in progress
|
|
130
|
+
|
|
440
131
|
if (!state && initPromise) {
|
|
441
132
|
try {
|
|
442
133
|
state = await initPromise;
|
|
@@ -449,29 +140,26 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
449
140
|
if (ctx.hasUI) ctx.ui.notify("MCP not initialized", "error");
|
|
450
141
|
return;
|
|
451
142
|
}
|
|
452
|
-
|
|
143
|
+
|
|
453
144
|
await authenticateServer(serverName, state.config, ctx);
|
|
454
145
|
},
|
|
455
146
|
});
|
|
456
|
-
|
|
457
|
-
// Single unified MCP tool - mode determined by parameters
|
|
147
|
+
|
|
458
148
|
pi.registerTool({
|
|
459
149
|
name: "mcp",
|
|
460
150
|
label: "MCP",
|
|
461
151
|
description: buildProxyDescription(earlyConfig, earlyCache, directSpecs),
|
|
152
|
+
promptSnippet: "MCP gateway - connect to MCP servers and call their tools",
|
|
462
153
|
parameters: Type.Object({
|
|
463
|
-
// Call mode
|
|
464
154
|
tool: Type.Optional(Type.String({ description: "Tool name to call (e.g., 'xcodebuild_list_sims')" })),
|
|
465
155
|
args: Type.Optional(Type.String({ description: "Arguments as JSON string (e.g., '{\"key\": \"value\"}')" })),
|
|
466
156
|
connect: Type.Optional(Type.String({ description: "Server name to connect (lazy connect + metadata refresh)" })),
|
|
467
|
-
// Describe mode
|
|
468
157
|
describe: Type.Optional(Type.String({ description: "Tool name to describe (shows parameters)" })),
|
|
469
|
-
// Search mode
|
|
470
158
|
search: Type.Optional(Type.String({ description: "Search tools by name/description" })),
|
|
471
159
|
regex: Type.Optional(Type.Boolean({ description: "Treat search as regex (default: substring match)" })),
|
|
472
160
|
includeSchemas: Type.Optional(Type.Boolean({ description: "Include parameter schemas in search results (default: true)" })),
|
|
473
|
-
// Filter (works with search or list)
|
|
474
161
|
server: Type.Optional(Type.String({ description: "Filter to specific server (also disambiguates tool calls)" })),
|
|
162
|
+
action: Type.Optional(Type.String({ description: "Action: 'ui-messages' to retrieve prompts/intents from UI sessions" })),
|
|
475
163
|
}),
|
|
476
164
|
async execute(_toolCallId, params: {
|
|
477
165
|
tool?: string;
|
|
@@ -482,8 +170,8 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
482
170
|
regex?: boolean;
|
|
483
171
|
includeSchemas?: boolean;
|
|
484
172
|
server?: string;
|
|
173
|
+
action?: string;
|
|
485
174
|
}, _signal, _onUpdate, _ctx) {
|
|
486
|
-
// Parse args from JSON string if provided
|
|
487
175
|
let parsedArgs: Record<string, unknown> | undefined;
|
|
488
176
|
if (params.args) {
|
|
489
177
|
try {
|
|
@@ -491,39 +179,40 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
491
179
|
if (typeof parsedArgs !== "object" || parsedArgs === null || Array.isArray(parsedArgs)) {
|
|
492
180
|
const gotType = Array.isArray(parsedArgs) ? "array" : parsedArgs === null ? "null" : typeof parsedArgs;
|
|
493
181
|
return {
|
|
494
|
-
content: [{ type: "text", text: `Invalid args: expected a JSON object, got ${gotType}` }],
|
|
182
|
+
content: [{ type: "text" as const, text: `Invalid args: expected a JSON object, got ${gotType}` }],
|
|
495
183
|
isError: true,
|
|
496
184
|
details: { error: "invalid_args_type" },
|
|
497
185
|
};
|
|
498
186
|
}
|
|
499
187
|
} catch (e) {
|
|
500
188
|
return {
|
|
501
|
-
content: [{ type: "text", text: `Invalid args JSON: ${e instanceof Error ? e.message : e}` }],
|
|
189
|
+
content: [{ type: "text" as const, text: `Invalid args JSON: ${e instanceof Error ? e.message : e}` }],
|
|
502
190
|
isError: true,
|
|
503
191
|
details: { error: "invalid_args" },
|
|
504
192
|
};
|
|
505
193
|
}
|
|
506
194
|
}
|
|
507
|
-
|
|
508
|
-
// Wait for init if still in progress
|
|
195
|
+
|
|
509
196
|
if (!state && initPromise) {
|
|
510
197
|
try {
|
|
511
198
|
state = await initPromise;
|
|
512
199
|
} catch {
|
|
513
200
|
return {
|
|
514
|
-
content: [{ type: "text", text: "MCP initialization failed" }],
|
|
201
|
+
content: [{ type: "text" as const, text: "MCP initialization failed" }],
|
|
515
202
|
details: { error: "init_failed" },
|
|
516
203
|
};
|
|
517
204
|
}
|
|
518
205
|
}
|
|
519
206
|
if (!state) {
|
|
520
207
|
return {
|
|
521
|
-
content: [{ type: "text", text: "MCP not initialized" }],
|
|
208
|
+
content: [{ type: "text" as const, text: "MCP not initialized" }],
|
|
522
209
|
details: { error: "not_initialized" },
|
|
523
210
|
};
|
|
524
211
|
}
|
|
525
|
-
|
|
526
|
-
|
|
212
|
+
|
|
213
|
+
if (params.action === "ui-messages") {
|
|
214
|
+
return executeUiMessages(state);
|
|
215
|
+
}
|
|
527
216
|
if (params.tool) {
|
|
528
217
|
return executeCall(state, params.tool, parsedArgs, params.server);
|
|
529
218
|
}
|
|
@@ -543,1166 +232,3 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
543
232
|
},
|
|
544
233
|
});
|
|
545
234
|
}
|
|
546
|
-
|
|
547
|
-
// --- Mode implementations ---
|
|
548
|
-
|
|
549
|
-
function executeStatus(state: McpExtensionState) {
|
|
550
|
-
const servers: Array<{ name: string; status: string; toolCount: number }> = [];
|
|
551
|
-
|
|
552
|
-
for (const name of Object.keys(state.config.mcpServers)) {
|
|
553
|
-
const connection = state.manager.getConnection(name);
|
|
554
|
-
const toolCount = getToolNames(state, name).length;
|
|
555
|
-
const failedAgo = getFailureAgeSeconds(state, name);
|
|
556
|
-
let status = "not connected";
|
|
557
|
-
if (connection?.status === "connected") {
|
|
558
|
-
status = "connected";
|
|
559
|
-
} else if (failedAgo !== null) {
|
|
560
|
-
status = "failed";
|
|
561
|
-
} else if (state.toolMetadata.has(name)) {
|
|
562
|
-
status = "cached";
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
servers.push({ name, status, toolCount });
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
const totalTools = servers.reduce((sum, s) => sum + s.toolCount, 0);
|
|
569
|
-
const connectedCount = servers.filter(s => s.status === "connected").length;
|
|
570
|
-
|
|
571
|
-
let text = `MCP: ${connectedCount}/${servers.length} servers, ${totalTools} tools\n\n`;
|
|
572
|
-
for (const server of servers) {
|
|
573
|
-
if (server.status === "connected") {
|
|
574
|
-
text += `✓ ${server.name} (${server.toolCount} tools)\n`;
|
|
575
|
-
continue;
|
|
576
|
-
}
|
|
577
|
-
if (server.status === "cached") {
|
|
578
|
-
text += `○ ${server.name} (${server.toolCount} tools, cached)\n`;
|
|
579
|
-
continue;
|
|
580
|
-
}
|
|
581
|
-
if (server.status === "failed") {
|
|
582
|
-
const failedAgo = getFailureAgeSeconds(state, server.name) ?? 0;
|
|
583
|
-
text += `✗ ${server.name} (failed ${failedAgo}s ago)\n`;
|
|
584
|
-
continue;
|
|
585
|
-
}
|
|
586
|
-
text += `○ ${server.name} (not connected)\n`;
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
if (servers.length > 0) {
|
|
590
|
-
text += `\nmcp({ server: "name" }) to list tools, mcp({ search: "..." }) to search`;
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
return {
|
|
594
|
-
content: [{ type: "text" as const, text: text.trim() }],
|
|
595
|
-
details: { mode: "status", servers, totalTools, connectedCount },
|
|
596
|
-
};
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
function executeDescribe(state: McpExtensionState, toolName: string) {
|
|
600
|
-
// Find the tool in metadata
|
|
601
|
-
let serverName: string | undefined;
|
|
602
|
-
let toolMeta: ToolMetadata | undefined;
|
|
603
|
-
|
|
604
|
-
for (const [server, metadata] of state.toolMetadata.entries()) {
|
|
605
|
-
const found = findToolByName(metadata, toolName);
|
|
606
|
-
if (found) {
|
|
607
|
-
serverName = server;
|
|
608
|
-
toolMeta = found;
|
|
609
|
-
break;
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
if (!serverName || !toolMeta) {
|
|
614
|
-
return {
|
|
615
|
-
content: [{ type: "text" as const, text: `Tool "${toolName}" not found. Use mcp({ search: "..." }) to search.` }],
|
|
616
|
-
details: { mode: "describe", error: "tool_not_found", requestedTool: toolName },
|
|
617
|
-
};
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
let text = `${toolMeta.name}\n`;
|
|
621
|
-
text += `Server: ${serverName}\n`;
|
|
622
|
-
if (toolMeta.resourceUri) {
|
|
623
|
-
text += `Type: Resource (reads from ${toolMeta.resourceUri})\n`;
|
|
624
|
-
}
|
|
625
|
-
text += `\n${toolMeta.description || "(no description)"}\n`;
|
|
626
|
-
|
|
627
|
-
// Format parameters from schema
|
|
628
|
-
if (toolMeta.inputSchema && !toolMeta.resourceUri) {
|
|
629
|
-
text += `\nParameters:\n${formatSchema(toolMeta.inputSchema)}`;
|
|
630
|
-
} else if (toolMeta.resourceUri) {
|
|
631
|
-
text += `\nNo parameters required (resource tool).`;
|
|
632
|
-
} else {
|
|
633
|
-
text += `\nNo parameters defined.`;
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
return {
|
|
637
|
-
content: [{ type: "text" as const, text: text.trim() }],
|
|
638
|
-
details: { mode: "describe", tool: toolMeta, server: serverName },
|
|
639
|
-
};
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
/**
|
|
643
|
-
* Format JSON Schema to human-readable parameter documentation.
|
|
644
|
-
*/
|
|
645
|
-
function formatSchema(schema: unknown, indent = " "): string {
|
|
646
|
-
if (!schema || typeof schema !== "object") {
|
|
647
|
-
return `${indent}(no schema)`;
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
const s = schema as Record<string, unknown>;
|
|
651
|
-
|
|
652
|
-
// Handle object type with properties
|
|
653
|
-
if (s.type === "object" && s.properties && typeof s.properties === "object") {
|
|
654
|
-
const props = s.properties as Record<string, unknown>;
|
|
655
|
-
const required = Array.isArray(s.required) ? s.required as string[] : [];
|
|
656
|
-
|
|
657
|
-
if (Object.keys(props).length === 0) {
|
|
658
|
-
return `${indent}(no parameters)`;
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
const lines: string[] = [];
|
|
662
|
-
for (const [name, propSchema] of Object.entries(props)) {
|
|
663
|
-
const isRequired = required.includes(name);
|
|
664
|
-
const propLine = formatProperty(name, propSchema, isRequired, indent);
|
|
665
|
-
lines.push(propLine);
|
|
666
|
-
}
|
|
667
|
-
return lines.join("\n");
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
// Fallback: just show the schema type
|
|
671
|
-
if (s.type) {
|
|
672
|
-
return `${indent}(${s.type})`;
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
return `${indent}(complex schema)`;
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
/**
|
|
679
|
-
* Format a single property from JSON Schema.
|
|
680
|
-
*/
|
|
681
|
-
function formatProperty(name: string, schema: unknown, required: boolean, indent: string): string {
|
|
682
|
-
if (!schema || typeof schema !== "object") {
|
|
683
|
-
return `${indent}${name}${required ? " *required*" : ""}`;
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
const s = schema as Record<string, unknown>;
|
|
687
|
-
const parts: string[] = [];
|
|
688
|
-
|
|
689
|
-
// Type info
|
|
690
|
-
let typeStr = "";
|
|
691
|
-
if (s.type) {
|
|
692
|
-
if (Array.isArray(s.type)) {
|
|
693
|
-
typeStr = s.type.join(" | ");
|
|
694
|
-
} else {
|
|
695
|
-
typeStr = String(s.type);
|
|
696
|
-
}
|
|
697
|
-
} else if (s.enum) {
|
|
698
|
-
typeStr = "enum";
|
|
699
|
-
} else if (s.anyOf || s.oneOf) {
|
|
700
|
-
typeStr = "union";
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
// Enum values
|
|
704
|
-
if (Array.isArray(s.enum)) {
|
|
705
|
-
const enumVals = s.enum.map(v => JSON.stringify(v)).join(", ");
|
|
706
|
-
typeStr = `enum: ${enumVals}`;
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
// Build the line
|
|
710
|
-
parts.push(`${indent}${name}`);
|
|
711
|
-
if (typeStr) parts.push(`(${typeStr})`);
|
|
712
|
-
if (required) parts.push("*required*");
|
|
713
|
-
|
|
714
|
-
// Description
|
|
715
|
-
if (s.description && typeof s.description === "string") {
|
|
716
|
-
parts.push(`- ${s.description}`);
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
// Default value
|
|
720
|
-
if (s.default !== undefined) {
|
|
721
|
-
parts.push(`[default: ${JSON.stringify(s.default)}]`);
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
return parts.join(" ");
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
function executeSearch(
|
|
728
|
-
state: McpExtensionState,
|
|
729
|
-
query: string,
|
|
730
|
-
regex?: boolean,
|
|
731
|
-
server?: string,
|
|
732
|
-
includeSchemas?: boolean,
|
|
733
|
-
getPiTools?: () => ToolInfo[]
|
|
734
|
-
) {
|
|
735
|
-
// Default to including schemas
|
|
736
|
-
const showSchemas = includeSchemas !== false;
|
|
737
|
-
|
|
738
|
-
const matches: Array<{ server: string; tool: ToolMetadata }> = [];
|
|
739
|
-
|
|
740
|
-
let pattern: RegExp;
|
|
741
|
-
try {
|
|
742
|
-
if (regex) {
|
|
743
|
-
pattern = new RegExp(query, "i");
|
|
744
|
-
} else {
|
|
745
|
-
// Split on whitespace and OR the terms (like most search engines)
|
|
746
|
-
const terms = query.trim().split(/\s+/).filter(t => t.length > 0);
|
|
747
|
-
if (terms.length === 0) {
|
|
748
|
-
return {
|
|
749
|
-
content: [{ type: "text" as const, text: "Search query cannot be empty" }],
|
|
750
|
-
details: { mode: "search", error: "empty_query" },
|
|
751
|
-
};
|
|
752
|
-
}
|
|
753
|
-
const escaped = terms.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
754
|
-
pattern = new RegExp(escaped.join("|"), "i");
|
|
755
|
-
}
|
|
756
|
-
} catch {
|
|
757
|
-
return {
|
|
758
|
-
content: [{ type: "text" as const, text: `Invalid regex: ${query}` }],
|
|
759
|
-
details: { mode: "search", error: "invalid_pattern", query },
|
|
760
|
-
};
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
// Search pi tools (unless server filter is specified)
|
|
764
|
-
const piMatches: Array<{ name: string; description: string }> = [];
|
|
765
|
-
if (!server && getPiTools) {
|
|
766
|
-
const piTools = getPiTools();
|
|
767
|
-
for (const tool of piTools) {
|
|
768
|
-
// Skip the mcp tool itself to avoid confusion
|
|
769
|
-
if (tool.name === "mcp") continue;
|
|
770
|
-
|
|
771
|
-
if (pattern.test(tool.name) || pattern.test(tool.description ?? "")) {
|
|
772
|
-
piMatches.push({
|
|
773
|
-
name: tool.name,
|
|
774
|
-
description: tool.description ?? "",
|
|
775
|
-
});
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
// Search MCP tools (existing logic)
|
|
781
|
-
for (const [serverName, metadata] of state.toolMetadata.entries()) {
|
|
782
|
-
if (server && serverName !== server) continue;
|
|
783
|
-
for (const tool of metadata) {
|
|
784
|
-
if (pattern.test(tool.name) || pattern.test(tool.description)) {
|
|
785
|
-
matches.push({
|
|
786
|
-
server: serverName,
|
|
787
|
-
tool,
|
|
788
|
-
});
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
// Combine counts
|
|
794
|
-
const totalCount = piMatches.length + matches.length;
|
|
795
|
-
|
|
796
|
-
if (totalCount === 0) {
|
|
797
|
-
const msg = server
|
|
798
|
-
? `No tools matching "${query}" in "${server}"`
|
|
799
|
-
: `No tools matching "${query}"`;
|
|
800
|
-
return {
|
|
801
|
-
content: [{ type: "text" as const, text: msg }],
|
|
802
|
-
details: { mode: "search", matches: [], count: 0, query },
|
|
803
|
-
};
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
let text = `Found ${totalCount} tool${totalCount === 1 ? "" : "s"} matching "${query}":\n\n`;
|
|
807
|
-
|
|
808
|
-
// Pi tools first (with [pi tool] prefix)
|
|
809
|
-
for (const match of piMatches) {
|
|
810
|
-
if (showSchemas) {
|
|
811
|
-
// Full format (consistent with MCP tools)
|
|
812
|
-
text += `[pi tool] ${match.name}\n`;
|
|
813
|
-
text += ` ${match.description || "(no description)"}\n`;
|
|
814
|
-
text += ` No parameters (call directly).\n`;
|
|
815
|
-
text += "\n";
|
|
816
|
-
} else {
|
|
817
|
-
// Compact format
|
|
818
|
-
text += `[pi tool] ${match.name}`;
|
|
819
|
-
if (match.description) {
|
|
820
|
-
text += ` - ${truncateAtWord(match.description, 50)}`;
|
|
821
|
-
}
|
|
822
|
-
text += "\n";
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
// MCP tools (existing format, no prefix change for backwards compat)
|
|
827
|
-
for (const match of matches) {
|
|
828
|
-
if (showSchemas) {
|
|
829
|
-
// Full format with schema
|
|
830
|
-
text += `${match.tool.name}\n`;
|
|
831
|
-
text += ` ${match.tool.description || "(no description)"}\n`;
|
|
832
|
-
if (match.tool.inputSchema && !match.tool.resourceUri) {
|
|
833
|
-
text += `\n Parameters:\n${formatSchema(match.tool.inputSchema, " ")}\n`;
|
|
834
|
-
} else if (match.tool.resourceUri) {
|
|
835
|
-
text += ` No parameters (resource tool).\n`;
|
|
836
|
-
}
|
|
837
|
-
text += "\n";
|
|
838
|
-
} else {
|
|
839
|
-
// Compact format without schema
|
|
840
|
-
text += `- ${match.tool.name}`;
|
|
841
|
-
if (match.tool.description) {
|
|
842
|
-
text += ` - ${truncateAtWord(match.tool.description, 50)}`;
|
|
843
|
-
}
|
|
844
|
-
text += "\n";
|
|
845
|
-
}
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
return {
|
|
849
|
-
content: [{ type: "text" as const, text: text.trim() }],
|
|
850
|
-
details: {
|
|
851
|
-
mode: "search",
|
|
852
|
-
matches: [
|
|
853
|
-
...piMatches.map(m => ({ server: "pi", tool: m.name })),
|
|
854
|
-
...matches.map(m => ({ server: m.server, tool: m.tool.name })),
|
|
855
|
-
],
|
|
856
|
-
count: totalCount,
|
|
857
|
-
query,
|
|
858
|
-
},
|
|
859
|
-
};
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
function executeList(state: McpExtensionState, server: string) {
|
|
863
|
-
if (!state.config.mcpServers[server]) {
|
|
864
|
-
return {
|
|
865
|
-
content: [{ type: "text" as const, text: `Server "${server}" not found. Use mcp({}) to see available servers.` }],
|
|
866
|
-
details: { mode: "list", server, tools: [], count: 0, error: "not_found" },
|
|
867
|
-
};
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
const metadata = state.toolMetadata.get(server);
|
|
871
|
-
const toolNames = getToolNames(state, server);
|
|
872
|
-
const hasMetadata = state.toolMetadata.has(server);
|
|
873
|
-
const connection = state.manager.getConnection(server);
|
|
874
|
-
|
|
875
|
-
if (toolNames.length === 0) {
|
|
876
|
-
if (connection?.status === "connected") {
|
|
877
|
-
return {
|
|
878
|
-
content: [{ type: "text" as const, text: `Server "${server}" has no tools.` }],
|
|
879
|
-
details: { mode: "list", server, tools: [], count: 0 },
|
|
880
|
-
};
|
|
881
|
-
}
|
|
882
|
-
if (hasMetadata) {
|
|
883
|
-
return {
|
|
884
|
-
content: [{ type: "text" as const, text: `Server "${server}" has no cached tools (not connected).` }],
|
|
885
|
-
details: { mode: "list", server, tools: [], count: 0, cached: true },
|
|
886
|
-
};
|
|
887
|
-
}
|
|
888
|
-
return {
|
|
889
|
-
content: [{ type: "text" as const, text: `Server "${server}" is configured but not connected. Use mcp({ connect: "${server}" }) or /mcp reconnect ${server} to retry.` }],
|
|
890
|
-
details: { mode: "list", server, tools: [], count: 0, error: "not_connected" },
|
|
891
|
-
};
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
const cachedNote = connection?.status === "connected" ? "" : " (not connected, cached)";
|
|
895
|
-
let text = `${server} (${toolNames.length} tools${cachedNote}):\n\n`;
|
|
896
|
-
|
|
897
|
-
// Build a map of tool name -> description for quick lookup
|
|
898
|
-
const descMap = new Map<string, string>();
|
|
899
|
-
if (metadata) {
|
|
900
|
-
for (const m of metadata) {
|
|
901
|
-
descMap.set(m.name, m.description);
|
|
902
|
-
}
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
for (const tool of toolNames) {
|
|
906
|
-
const desc = descMap.get(tool) ?? "";
|
|
907
|
-
const truncated = truncateAtWord(desc, 50);
|
|
908
|
-
text += `- ${tool}`;
|
|
909
|
-
if (truncated) text += ` - ${truncated}`;
|
|
910
|
-
text += "\n";
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
return {
|
|
914
|
-
content: [{ type: "text" as const, text: text.trim() }],
|
|
915
|
-
details: { mode: "list", server, tools: toolNames, count: toolNames.length },
|
|
916
|
-
};
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
async function executeConnect(state: McpExtensionState, serverName: string) {
|
|
920
|
-
const definition = state.config.mcpServers[serverName];
|
|
921
|
-
if (!definition) {
|
|
922
|
-
return {
|
|
923
|
-
content: [{ type: "text" as const, text: `Server "${serverName}" not found. Use mcp({}) to see available servers.` }],
|
|
924
|
-
details: { mode: "connect", error: "not_found", server: serverName },
|
|
925
|
-
};
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
try {
|
|
929
|
-
if (state.ui) {
|
|
930
|
-
state.ui.setStatus("mcp", `MCP: connecting to ${serverName}...`);
|
|
931
|
-
}
|
|
932
|
-
const connection = await state.manager.connect(serverName, definition);
|
|
933
|
-
const prefix = state.config.settings?.toolPrefix ?? "server";
|
|
934
|
-
const { metadata } = buildToolMetadata(connection.tools, connection.resources, definition, serverName, prefix);
|
|
935
|
-
state.toolMetadata.set(serverName, metadata);
|
|
936
|
-
updateMetadataCache(state, serverName);
|
|
937
|
-
state.failureTracker.delete(serverName);
|
|
938
|
-
updateStatusBar(state);
|
|
939
|
-
return executeList(state, serverName);
|
|
940
|
-
} catch (error) {
|
|
941
|
-
state.failureTracker.set(serverName, Date.now());
|
|
942
|
-
updateStatusBar(state);
|
|
943
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
944
|
-
return {
|
|
945
|
-
content: [{ type: "text" as const, text: `Failed to connect to "${serverName}": ${message}` }],
|
|
946
|
-
details: { mode: "connect", error: "connect_failed", server: serverName, message },
|
|
947
|
-
};
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
async function executeCall(
|
|
952
|
-
state: McpExtensionState,
|
|
953
|
-
toolName: string,
|
|
954
|
-
args?: Record<string, unknown>,
|
|
955
|
-
serverOverride?: string
|
|
956
|
-
) {
|
|
957
|
-
// Find the tool in metadata
|
|
958
|
-
let serverName: string | undefined = serverOverride;
|
|
959
|
-
let toolMeta: ToolMetadata | undefined;
|
|
960
|
-
const prefixMode = state.config.settings?.toolPrefix ?? "server";
|
|
961
|
-
|
|
962
|
-
if (serverName && !state.config.mcpServers[serverName]) {
|
|
963
|
-
return {
|
|
964
|
-
content: [{ type: "text" as const, text: `Server "${serverName}" not found. Use mcp({}) to see available servers.` }],
|
|
965
|
-
details: { mode: "call", error: "server_not_found", server: serverName },
|
|
966
|
-
};
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
if (serverName) {
|
|
970
|
-
toolMeta = findToolByName(state.toolMetadata.get(serverName), toolName);
|
|
971
|
-
} else {
|
|
972
|
-
for (const [server, metadata] of state.toolMetadata.entries()) {
|
|
973
|
-
const found = findToolByName(metadata, toolName);
|
|
974
|
-
if (found) {
|
|
975
|
-
serverName = server;
|
|
976
|
-
toolMeta = found;
|
|
977
|
-
break;
|
|
978
|
-
}
|
|
979
|
-
}
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
if (serverName && !toolMeta) {
|
|
983
|
-
const connected = await lazyConnect(state, serverName);
|
|
984
|
-
if (connected) {
|
|
985
|
-
toolMeta = findToolByName(state.toolMetadata.get(serverName), toolName);
|
|
986
|
-
} else {
|
|
987
|
-
const failedAgo = getFailureAgeSeconds(state, serverName);
|
|
988
|
-
if (failedAgo !== null) {
|
|
989
|
-
return {
|
|
990
|
-
content: [{ type: "text" as const, text: `Server "${serverName}" not available (last failed ${failedAgo}s ago)` }],
|
|
991
|
-
details: { mode: "call", error: "server_backoff", server: serverName },
|
|
992
|
-
};
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
let prefixMatchedServer: string | undefined;
|
|
998
|
-
|
|
999
|
-
if (!serverName && !toolMeta && prefixMode !== "none") {
|
|
1000
|
-
const candidates = Object.keys(state.config.mcpServers)
|
|
1001
|
-
.map(name => ({ name, prefix: getServerPrefix(name, prefixMode) }))
|
|
1002
|
-
.filter(c => c.prefix && toolName.startsWith(c.prefix + "_"))
|
|
1003
|
-
.sort((a, b) => b.prefix.length - a.prefix.length);
|
|
1004
|
-
|
|
1005
|
-
for (const { name: configuredServer } of candidates) {
|
|
1006
|
-
const failedAgo = getFailureAgeSeconds(state, configuredServer);
|
|
1007
|
-
if (failedAgo !== null) continue;
|
|
1008
|
-
const connected = await lazyConnect(state, configuredServer);
|
|
1009
|
-
if (!connected) continue;
|
|
1010
|
-
if (!prefixMatchedServer) prefixMatchedServer = configuredServer;
|
|
1011
|
-
toolMeta = findToolByName(state.toolMetadata.get(configuredServer), toolName);
|
|
1012
|
-
if (toolMeta) {
|
|
1013
|
-
serverName = configuredServer;
|
|
1014
|
-
break;
|
|
1015
|
-
}
|
|
1016
|
-
}
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
if (!serverName || !toolMeta) {
|
|
1020
|
-
const hintServer = serverName ?? prefixMatchedServer;
|
|
1021
|
-
const available = hintServer ? getToolNames(state, hintServer) : [];
|
|
1022
|
-
let msg = `Tool "${toolName}" not found.`;
|
|
1023
|
-
if (available.length > 0) {
|
|
1024
|
-
msg += ` Server "${hintServer}" has: ${available.join(", ")}`;
|
|
1025
|
-
} else {
|
|
1026
|
-
msg += ` Use mcp({ search: "..." }) to search.`;
|
|
1027
|
-
}
|
|
1028
|
-
return {
|
|
1029
|
-
content: [{ type: "text" as const, text: msg }],
|
|
1030
|
-
details: { mode: "call", error: "tool_not_found", requestedTool: toolName, hintServer },
|
|
1031
|
-
};
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
let connection = state.manager.getConnection(serverName);
|
|
1035
|
-
if (!connection || connection.status !== "connected") {
|
|
1036
|
-
const failedAgo = getFailureAgeSeconds(state, serverName);
|
|
1037
|
-
if (failedAgo !== null) {
|
|
1038
|
-
return {
|
|
1039
|
-
content: [{ type: "text" as const, text: `Server "${serverName}" not available (last failed ${failedAgo}s ago)` }],
|
|
1040
|
-
details: { mode: "call", error: "server_backoff", server: serverName },
|
|
1041
|
-
};
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
const definition = state.config.mcpServers[serverName];
|
|
1045
|
-
if (!definition) {
|
|
1046
|
-
return {
|
|
1047
|
-
content: [{ type: "text" as const, text: `Server "${serverName}" not connected` }],
|
|
1048
|
-
details: { mode: "call", error: "server_not_connected", server: serverName },
|
|
1049
|
-
};
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
try {
|
|
1053
|
-
if (state.ui) {
|
|
1054
|
-
state.ui.setStatus("mcp", `MCP: connecting to ${serverName}...`);
|
|
1055
|
-
}
|
|
1056
|
-
connection = await state.manager.connect(serverName, definition);
|
|
1057
|
-
state.failureTracker.delete(serverName);
|
|
1058
|
-
updateServerMetadata(state, serverName);
|
|
1059
|
-
updateMetadataCache(state, serverName);
|
|
1060
|
-
updateStatusBar(state);
|
|
1061
|
-
toolMeta = findToolByName(state.toolMetadata.get(serverName), toolName);
|
|
1062
|
-
if (!toolMeta) {
|
|
1063
|
-
const available = getToolNames(state, serverName);
|
|
1064
|
-
const hint = available.length > 0
|
|
1065
|
-
? `Available tools on "${serverName}": ${available.join(", ")}`
|
|
1066
|
-
: `Server "${serverName}" has no tools.`;
|
|
1067
|
-
return {
|
|
1068
|
-
content: [{ type: "text" as const, text: `Tool "${toolName}" not found on "${serverName}" after reconnect. ${hint}` }],
|
|
1069
|
-
details: { mode: "call", error: "tool_not_found_after_reconnect", requestedTool: toolName },
|
|
1070
|
-
};
|
|
1071
|
-
}
|
|
1072
|
-
} catch (error) {
|
|
1073
|
-
state.failureTracker.set(serverName, Date.now());
|
|
1074
|
-
updateStatusBar(state);
|
|
1075
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1076
|
-
return {
|
|
1077
|
-
content: [{ type: "text" as const, text: `Failed to connect to "${serverName}": ${message}` }],
|
|
1078
|
-
details: { mode: "call", error: "connect_failed", message },
|
|
1079
|
-
};
|
|
1080
|
-
}
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
try {
|
|
1084
|
-
state.manager.touch(serverName);
|
|
1085
|
-
state.manager.incrementInFlight(serverName);
|
|
1086
|
-
|
|
1087
|
-
// Resource tools use readResource, regular tools use callTool
|
|
1088
|
-
if (toolMeta.resourceUri) {
|
|
1089
|
-
const result = await connection.client.readResource({ uri: toolMeta.resourceUri });
|
|
1090
|
-
const content = (result.contents ?? []).map(c => ({
|
|
1091
|
-
type: "text" as const,
|
|
1092
|
-
text: "text" in c ? c.text : ("blob" in c ? `[Binary data: ${(c as { mimeType?: string }).mimeType ?? "unknown"}]` : JSON.stringify(c)),
|
|
1093
|
-
}));
|
|
1094
|
-
return {
|
|
1095
|
-
content: content.length > 0 ? content : [{ type: "text" as const, text: "(empty resource)" }],
|
|
1096
|
-
details: { mode: "call", resourceUri: toolMeta.resourceUri, server: serverName },
|
|
1097
|
-
};
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
// Regular tool call
|
|
1101
|
-
const result = await connection.client.callTool({
|
|
1102
|
-
name: toolMeta.originalName,
|
|
1103
|
-
arguments: args ?? {},
|
|
1104
|
-
});
|
|
1105
|
-
|
|
1106
|
-
const mcpContent = (result.content ?? []) as McpContent[];
|
|
1107
|
-
const content = transformMcpContent(mcpContent);
|
|
1108
|
-
|
|
1109
|
-
if (result.isError) {
|
|
1110
|
-
const errorText = content
|
|
1111
|
-
.filter((c) => c.type === "text")
|
|
1112
|
-
.map((c) => (c as { text: string }).text)
|
|
1113
|
-
.join("\n") || "Tool execution failed";
|
|
1114
|
-
|
|
1115
|
-
// Include schema in error to help LLM self-correct
|
|
1116
|
-
let errorWithSchema = `Error: ${errorText}`;
|
|
1117
|
-
if (toolMeta.inputSchema) {
|
|
1118
|
-
errorWithSchema += `\n\nExpected parameters:\n${formatSchema(toolMeta.inputSchema)}`;
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
return {
|
|
1122
|
-
content: [{ type: "text" as const, text: errorWithSchema }],
|
|
1123
|
-
details: { mode: "call", error: "tool_error", mcpResult: result },
|
|
1124
|
-
};
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
return {
|
|
1128
|
-
content: content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }],
|
|
1129
|
-
details: { mode: "call", mcpResult: result, server: serverName, tool: toolMeta.originalName },
|
|
1130
|
-
};
|
|
1131
|
-
} catch (error) {
|
|
1132
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1133
|
-
|
|
1134
|
-
// Include schema in error to help LLM self-correct
|
|
1135
|
-
let errorWithSchema = `Failed to call tool: ${message}`;
|
|
1136
|
-
if (toolMeta.inputSchema) {
|
|
1137
|
-
errorWithSchema += `\n\nExpected parameters:\n${formatSchema(toolMeta.inputSchema)}`;
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
return {
|
|
1141
|
-
content: [{ type: "text" as const, text: errorWithSchema }],
|
|
1142
|
-
details: { mode: "call", error: "call_failed", message },
|
|
1143
|
-
};
|
|
1144
|
-
} finally {
|
|
1145
|
-
state.manager.decrementInFlight(serverName);
|
|
1146
|
-
state.manager.touch(serverName);
|
|
1147
|
-
}
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
async function initializeMcp(
|
|
1151
|
-
pi: ExtensionAPI,
|
|
1152
|
-
ctx: ExtensionContext
|
|
1153
|
-
): Promise<McpExtensionState> {
|
|
1154
|
-
const configPath = pi.getFlag("mcp-config") as string | undefined;
|
|
1155
|
-
const config = loadMcpConfig(configPath);
|
|
1156
|
-
|
|
1157
|
-
const manager = new McpServerManager();
|
|
1158
|
-
const lifecycle = new McpLifecycleManager(manager);
|
|
1159
|
-
const toolMetadata = new Map<string, ToolMetadata[]>();
|
|
1160
|
-
const failureTracker = new Map<string, number>();
|
|
1161
|
-
const ui = ctx.hasUI ? ctx.ui : undefined;
|
|
1162
|
-
const state: McpExtensionState = { manager, lifecycle, toolMetadata, config, failureTracker, ui };
|
|
1163
|
-
|
|
1164
|
-
const serverEntries = Object.entries(config.mcpServers);
|
|
1165
|
-
if (serverEntries.length === 0) {
|
|
1166
|
-
return state;
|
|
1167
|
-
}
|
|
1168
|
-
|
|
1169
|
-
const idleSetting = typeof config.settings?.idleTimeout === "number" ? config.settings.idleTimeout : 10;
|
|
1170
|
-
lifecycle.setGlobalIdleTimeout(idleSetting);
|
|
1171
|
-
|
|
1172
|
-
const cachePath = getMetadataCachePath();
|
|
1173
|
-
const cacheFileExists = existsSync(cachePath);
|
|
1174
|
-
let cache = loadMetadataCache();
|
|
1175
|
-
let bootstrapAll = false;
|
|
1176
|
-
|
|
1177
|
-
if (!cacheFileExists) {
|
|
1178
|
-
bootstrapAll = true;
|
|
1179
|
-
saveMetadataCache({ version: 1, servers: {} });
|
|
1180
|
-
} else if (!cache) {
|
|
1181
|
-
cache = { version: 1, servers: {} };
|
|
1182
|
-
saveMetadataCache(cache);
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
const prefix = config.settings?.toolPrefix ?? "server";
|
|
1186
|
-
|
|
1187
|
-
// Register servers and hydrate metadata from cache if valid
|
|
1188
|
-
for (const [name, definition] of serverEntries) {
|
|
1189
|
-
const lifecycleMode = definition.lifecycle ?? "lazy";
|
|
1190
|
-
const idleOverride = definition.idleTimeout ?? (lifecycleMode === "eager" ? 0 : undefined);
|
|
1191
|
-
lifecycle.registerServer(
|
|
1192
|
-
name,
|
|
1193
|
-
definition,
|
|
1194
|
-
idleOverride !== undefined ? { idleTimeout: idleOverride } : undefined
|
|
1195
|
-
);
|
|
1196
|
-
if (lifecycleMode === "keep-alive") {
|
|
1197
|
-
lifecycle.markKeepAlive(name, definition);
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
|
-
if (cache?.servers?.[name] && isServerCacheValid(cache.servers[name], definition)) {
|
|
1201
|
-
const metadata = reconstructToolMetadata(name, cache.servers[name], prefix, definition.exposeResources);
|
|
1202
|
-
toolMetadata.set(name, metadata);
|
|
1203
|
-
}
|
|
1204
|
-
}
|
|
1205
|
-
|
|
1206
|
-
const startupServers = bootstrapAll
|
|
1207
|
-
? serverEntries
|
|
1208
|
-
: serverEntries.filter(([, definition]) => {
|
|
1209
|
-
const mode = definition.lifecycle ?? "lazy";
|
|
1210
|
-
return mode === "keep-alive" || mode === "eager";
|
|
1211
|
-
});
|
|
1212
|
-
|
|
1213
|
-
if (ctx.hasUI && startupServers.length > 0) {
|
|
1214
|
-
ctx.ui.setStatus("mcp", `MCP: connecting to ${startupServers.length} servers...`);
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
|
-
// Connect selected servers in parallel (max 10 concurrent)
|
|
1218
|
-
const results = await parallelLimit(startupServers, 10, async ([name, definition]) => {
|
|
1219
|
-
try {
|
|
1220
|
-
const connection = await manager.connect(name, definition);
|
|
1221
|
-
return { name, definition, connection, error: null };
|
|
1222
|
-
} catch (error) {
|
|
1223
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1224
|
-
return { name, definition, connection: null, error: message };
|
|
1225
|
-
}
|
|
1226
|
-
});
|
|
1227
|
-
|
|
1228
|
-
// Process results
|
|
1229
|
-
for (const { name, definition, connection, error } of results) {
|
|
1230
|
-
if (error || !connection) {
|
|
1231
|
-
if (ctx.hasUI) {
|
|
1232
|
-
ctx.ui.notify(`MCP: Failed to connect to ${name}: ${error}`, "error");
|
|
1233
|
-
}
|
|
1234
|
-
console.error(`MCP: Failed to connect to ${name}: ${error}`);
|
|
1235
|
-
continue;
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
const { metadata, failedTools } = buildToolMetadata(connection.tools, connection.resources, definition, name, prefix);
|
|
1239
|
-
toolMetadata.set(name, metadata);
|
|
1240
|
-
updateMetadataCache(state, name);
|
|
1241
|
-
|
|
1242
|
-
if (failedTools.length > 0 && ctx.hasUI) {
|
|
1243
|
-
ctx.ui.notify(
|
|
1244
|
-
`MCP: ${name} - ${failedTools.length} tools skipped`,
|
|
1245
|
-
"warning"
|
|
1246
|
-
);
|
|
1247
|
-
}
|
|
1248
|
-
}
|
|
1249
|
-
|
|
1250
|
-
// Summary notification
|
|
1251
|
-
const connectedCount = results.filter(r => r.connection).length;
|
|
1252
|
-
const failedCount = results.filter(r => r.error).length;
|
|
1253
|
-
if (ctx.hasUI && connectedCount > 0) {
|
|
1254
|
-
const totalTools = totalToolCount(state);
|
|
1255
|
-
const msg = failedCount > 0
|
|
1256
|
-
? `MCP: ${connectedCount}/${startupServers.length} servers connected (${totalTools} tools)`
|
|
1257
|
-
: `MCP: ${connectedCount} servers connected (${totalTools} tools)`;
|
|
1258
|
-
ctx.ui.notify(msg, "info");
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
const envDirect = process.env.MCP_DIRECT_TOOLS;
|
|
1262
|
-
if (envDirect !== "__none__") {
|
|
1263
|
-
const missingCacheServers: string[] = [];
|
|
1264
|
-
const currentCache = loadMetadataCache();
|
|
1265
|
-
for (const [name, definition] of serverEntries) {
|
|
1266
|
-
const hasDirect = definition.directTools !== undefined
|
|
1267
|
-
? !!definition.directTools
|
|
1268
|
-
: !!config.settings?.directTools;
|
|
1269
|
-
if (!hasDirect) continue;
|
|
1270
|
-
const entry = currentCache?.servers?.[name];
|
|
1271
|
-
if (!entry || !isServerCacheValid(entry, definition)) {
|
|
1272
|
-
missingCacheServers.push(name);
|
|
1273
|
-
}
|
|
1274
|
-
}
|
|
1275
|
-
|
|
1276
|
-
if (missingCacheServers.length > 0) {
|
|
1277
|
-
const bootstrapResults = await parallelLimit(
|
|
1278
|
-
missingCacheServers.filter(name => !results.some(r => r.name === name && r.connection)),
|
|
1279
|
-
10,
|
|
1280
|
-
async (name) => {
|
|
1281
|
-
const definition = config.mcpServers[name];
|
|
1282
|
-
try {
|
|
1283
|
-
const connection = await manager.connect(name, definition);
|
|
1284
|
-
const { metadata } = buildToolMetadata(connection.tools, connection.resources, definition, name, prefix);
|
|
1285
|
-
toolMetadata.set(name, metadata);
|
|
1286
|
-
updateMetadataCache(state, name);
|
|
1287
|
-
return { name, ok: true };
|
|
1288
|
-
} catch {
|
|
1289
|
-
return { name, ok: false };
|
|
1290
|
-
}
|
|
1291
|
-
},
|
|
1292
|
-
);
|
|
1293
|
-
const bootstrapped = bootstrapResults.filter(r => r.ok).map(r => r.name);
|
|
1294
|
-
if (bootstrapped.length > 0 && ctx.hasUI) {
|
|
1295
|
-
ctx.ui.notify(`MCP: direct tools for ${bootstrapped.join(", ")} will be available after restart`, "info");
|
|
1296
|
-
}
|
|
1297
|
-
}
|
|
1298
|
-
}
|
|
1299
|
-
|
|
1300
|
-
lifecycle.setReconnectCallback((serverName) => {
|
|
1301
|
-
updateServerMetadata(state, serverName);
|
|
1302
|
-
updateMetadataCache(state, serverName);
|
|
1303
|
-
state.failureTracker.delete(serverName);
|
|
1304
|
-
updateStatusBar(state);
|
|
1305
|
-
});
|
|
1306
|
-
|
|
1307
|
-
lifecycle.setIdleShutdownCallback((serverName) => {
|
|
1308
|
-
const idleMinutes = getEffectiveIdleTimeoutMinutes(state, serverName);
|
|
1309
|
-
console.log(`MCP: ${serverName} shut down (idle ${idleMinutes}m)`);
|
|
1310
|
-
updateStatusBar(state);
|
|
1311
|
-
});
|
|
1312
|
-
|
|
1313
|
-
lifecycle.startHealthChecks();
|
|
1314
|
-
|
|
1315
|
-
return state;
|
|
1316
|
-
}
|
|
1317
|
-
|
|
1318
|
-
/**
|
|
1319
|
-
* Update tool metadata for a single server after reconnection.
|
|
1320
|
-
* Called by lifecycle manager when a keep-alive server reconnects.
|
|
1321
|
-
*/
|
|
1322
|
-
function updateServerMetadata(state: McpExtensionState, serverName: string): void {
|
|
1323
|
-
const connection = state.manager.getConnection(serverName);
|
|
1324
|
-
if (!connection || connection.status !== "connected") return;
|
|
1325
|
-
|
|
1326
|
-
const definition = state.config.mcpServers[serverName];
|
|
1327
|
-
if (!definition) return;
|
|
1328
|
-
|
|
1329
|
-
const prefix = state.config.settings?.toolPrefix ?? "server";
|
|
1330
|
-
|
|
1331
|
-
const { metadata } = buildToolMetadata(connection.tools, connection.resources, definition, serverName, prefix);
|
|
1332
|
-
state.toolMetadata.set(serverName, metadata);
|
|
1333
|
-
}
|
|
1334
|
-
|
|
1335
|
-
async function showStatus(state: McpExtensionState, ctx: ExtensionContext): Promise<void> {
|
|
1336
|
-
if (!ctx.hasUI) return;
|
|
1337
|
-
|
|
1338
|
-
const lines: string[] = ["MCP Server Status:", ""];
|
|
1339
|
-
|
|
1340
|
-
// Show all configured servers, not just connected ones
|
|
1341
|
-
for (const name of Object.keys(state.config.mcpServers)) {
|
|
1342
|
-
const connection = state.manager.getConnection(name);
|
|
1343
|
-
const toolCount = getToolNames(state, name).length;
|
|
1344
|
-
const failedAgo = getFailureAgeSeconds(state, name);
|
|
1345
|
-
let status = "not connected";
|
|
1346
|
-
let statusIcon = "○";
|
|
1347
|
-
let failed = false;
|
|
1348
|
-
|
|
1349
|
-
if (connection?.status === "connected") {
|
|
1350
|
-
status = "connected";
|
|
1351
|
-
statusIcon = "✓";
|
|
1352
|
-
} else if (failedAgo !== null) {
|
|
1353
|
-
status = `failed ${failedAgo}s ago`;
|
|
1354
|
-
statusIcon = "✗";
|
|
1355
|
-
failed = true;
|
|
1356
|
-
} else if (state.toolMetadata.has(name)) {
|
|
1357
|
-
status = "cached";
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
|
-
const toolSuffix = failed ? "" : ` (${toolCount} tools${status === "cached" ? ", cached" : ""})`;
|
|
1361
|
-
lines.push(`${statusIcon} ${name}: ${status}${toolSuffix}`);
|
|
1362
|
-
}
|
|
1363
|
-
|
|
1364
|
-
if (Object.keys(state.config.mcpServers).length === 0) {
|
|
1365
|
-
lines.push("No MCP servers configured");
|
|
1366
|
-
}
|
|
1367
|
-
|
|
1368
|
-
ctx.ui.notify(lines.join("\n"), "info");
|
|
1369
|
-
}
|
|
1370
|
-
|
|
1371
|
-
async function showTools(state: McpExtensionState, ctx: ExtensionContext): Promise<void> {
|
|
1372
|
-
if (!ctx.hasUI) return;
|
|
1373
|
-
|
|
1374
|
-
const allTools = [...state.toolMetadata.values()].flat().map(m => m.name);
|
|
1375
|
-
|
|
1376
|
-
if (allTools.length === 0) {
|
|
1377
|
-
ctx.ui.notify("No MCP tools available", "info");
|
|
1378
|
-
return;
|
|
1379
|
-
}
|
|
1380
|
-
|
|
1381
|
-
const lines = [
|
|
1382
|
-
"MCP Tools:",
|
|
1383
|
-
"",
|
|
1384
|
-
...allTools.map(t => ` ${t}`),
|
|
1385
|
-
"",
|
|
1386
|
-
`Total: ${allTools.length} tools`,
|
|
1387
|
-
];
|
|
1388
|
-
|
|
1389
|
-
ctx.ui.notify(lines.join("\n"), "info");
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
async function reconnectServers(
|
|
1393
|
-
state: McpExtensionState,
|
|
1394
|
-
ctx: ExtensionContext,
|
|
1395
|
-
targetServer?: string
|
|
1396
|
-
): Promise<void> {
|
|
1397
|
-
if (targetServer && !state.config.mcpServers[targetServer]) {
|
|
1398
|
-
if (ctx.hasUI) {
|
|
1399
|
-
ctx.ui.notify(`Server "${targetServer}" not found in config`, "error");
|
|
1400
|
-
}
|
|
1401
|
-
return;
|
|
1402
|
-
}
|
|
1403
|
-
|
|
1404
|
-
const entries = targetServer
|
|
1405
|
-
? [[targetServer, state.config.mcpServers[targetServer]] as [string, ServerEntry]]
|
|
1406
|
-
: Object.entries(state.config.mcpServers);
|
|
1407
|
-
|
|
1408
|
-
for (const [name, definition] of entries) {
|
|
1409
|
-
try {
|
|
1410
|
-
await state.manager.close(name);
|
|
1411
|
-
|
|
1412
|
-
const connection = await state.manager.connect(name, definition);
|
|
1413
|
-
const prefix = state.config.settings?.toolPrefix ?? "server";
|
|
1414
|
-
|
|
1415
|
-
const { metadata, failedTools } = buildToolMetadata(connection.tools, connection.resources, definition, name, prefix);
|
|
1416
|
-
state.toolMetadata.set(name, metadata);
|
|
1417
|
-
updateMetadataCache(state, name);
|
|
1418
|
-
state.failureTracker.delete(name);
|
|
1419
|
-
|
|
1420
|
-
if (ctx.hasUI) {
|
|
1421
|
-
ctx.ui.notify(
|
|
1422
|
-
`MCP: Reconnected to ${name} (${connection.tools.length} tools, ${connection.resources.length} resources)`,
|
|
1423
|
-
"info"
|
|
1424
|
-
);
|
|
1425
|
-
if (failedTools.length > 0) {
|
|
1426
|
-
ctx.ui.notify(`MCP: ${name} - ${failedTools.length} tools skipped`, "warning");
|
|
1427
|
-
}
|
|
1428
|
-
}
|
|
1429
|
-
} catch (error) {
|
|
1430
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1431
|
-
state.failureTracker.set(name, Date.now());
|
|
1432
|
-
if (ctx.hasUI) {
|
|
1433
|
-
ctx.ui.notify(`MCP: Failed to reconnect to ${name}: ${message}`, "error");
|
|
1434
|
-
}
|
|
1435
|
-
}
|
|
1436
|
-
}
|
|
1437
|
-
|
|
1438
|
-
// Update status bar with server count
|
|
1439
|
-
updateStatusBar(state);
|
|
1440
|
-
}
|
|
1441
|
-
|
|
1442
|
-
function buildToolMetadata(
|
|
1443
|
-
tools: McpTool[],
|
|
1444
|
-
resources: McpResource[],
|
|
1445
|
-
definition: ServerEntry,
|
|
1446
|
-
serverName: string,
|
|
1447
|
-
prefix: "server" | "none" | "short"
|
|
1448
|
-
): { metadata: ToolMetadata[]; failedTools: string[] } {
|
|
1449
|
-
const metadata: ToolMetadata[] = [];
|
|
1450
|
-
const failedTools: string[] = [];
|
|
1451
|
-
|
|
1452
|
-
for (const tool of tools) {
|
|
1453
|
-
if (!tool?.name) {
|
|
1454
|
-
failedTools.push("(unnamed)");
|
|
1455
|
-
continue;
|
|
1456
|
-
}
|
|
1457
|
-
metadata.push({
|
|
1458
|
-
name: formatToolName(tool.name, serverName, prefix),
|
|
1459
|
-
originalName: tool.name,
|
|
1460
|
-
description: tool.description ?? "",
|
|
1461
|
-
inputSchema: tool.inputSchema,
|
|
1462
|
-
});
|
|
1463
|
-
}
|
|
1464
|
-
|
|
1465
|
-
if (definition.exposeResources !== false) {
|
|
1466
|
-
for (const resource of resources) {
|
|
1467
|
-
const baseName = `get_${resourceNameToToolName(resource.name)}`;
|
|
1468
|
-
metadata.push({
|
|
1469
|
-
name: formatToolName(baseName, serverName, prefix),
|
|
1470
|
-
originalName: baseName,
|
|
1471
|
-
description: resource.description ?? `Read resource: ${resource.uri}`,
|
|
1472
|
-
resourceUri: resource.uri,
|
|
1473
|
-
});
|
|
1474
|
-
}
|
|
1475
|
-
}
|
|
1476
|
-
|
|
1477
|
-
return { metadata, failedTools };
|
|
1478
|
-
}
|
|
1479
|
-
|
|
1480
|
-
function updateMetadataCache(state: McpExtensionState, serverName: string): void {
|
|
1481
|
-
const connection = state.manager.getConnection(serverName);
|
|
1482
|
-
if (!connection || connection.status !== "connected") return;
|
|
1483
|
-
|
|
1484
|
-
const definition = state.config.mcpServers[serverName];
|
|
1485
|
-
if (!definition) return;
|
|
1486
|
-
|
|
1487
|
-
const configHash = computeServerHash(definition);
|
|
1488
|
-
const existing = loadMetadataCache();
|
|
1489
|
-
const existingEntry = existing?.servers?.[serverName];
|
|
1490
|
-
|
|
1491
|
-
const tools = serializeTools(connection.tools);
|
|
1492
|
-
let resources = definition.exposeResources === false ? [] : serializeResources(connection.resources);
|
|
1493
|
-
|
|
1494
|
-
if (
|
|
1495
|
-
definition.exposeResources !== false &&
|
|
1496
|
-
resources.length === 0 &&
|
|
1497
|
-
existingEntry?.resources?.length &&
|
|
1498
|
-
existingEntry.configHash === configHash
|
|
1499
|
-
) {
|
|
1500
|
-
resources = existingEntry.resources;
|
|
1501
|
-
}
|
|
1502
|
-
|
|
1503
|
-
const entry: ServerCacheEntry = {
|
|
1504
|
-
configHash,
|
|
1505
|
-
tools,
|
|
1506
|
-
resources,
|
|
1507
|
-
cachedAt: Date.now(),
|
|
1508
|
-
};
|
|
1509
|
-
|
|
1510
|
-
saveMetadataCache({ version: 1, servers: { [serverName]: entry } });
|
|
1511
|
-
}
|
|
1512
|
-
|
|
1513
|
-
function flushMetadataCache(state: McpExtensionState): void {
|
|
1514
|
-
for (const [name, connection] of state.manager.getAllConnections()) {
|
|
1515
|
-
if (connection.status === "connected") {
|
|
1516
|
-
updateMetadataCache(state, name);
|
|
1517
|
-
}
|
|
1518
|
-
}
|
|
1519
|
-
}
|
|
1520
|
-
|
|
1521
|
-
function getToolNames(state: McpExtensionState, serverName: string): string[] {
|
|
1522
|
-
return state.toolMetadata.get(serverName)?.map(m => m.name) ?? [];
|
|
1523
|
-
}
|
|
1524
|
-
|
|
1525
|
-
function totalToolCount(state: McpExtensionState): number {
|
|
1526
|
-
let count = 0;
|
|
1527
|
-
for (const metadata of state.toolMetadata.values()) {
|
|
1528
|
-
count += metadata.length;
|
|
1529
|
-
}
|
|
1530
|
-
return count;
|
|
1531
|
-
}
|
|
1532
|
-
|
|
1533
|
-
function updateStatusBar(state: McpExtensionState): void {
|
|
1534
|
-
const ui = state.ui;
|
|
1535
|
-
if (!ui) return;
|
|
1536
|
-
const total = Object.keys(state.config.mcpServers).length;
|
|
1537
|
-
if (total === 0) {
|
|
1538
|
-
ui.setStatus("mcp", "");
|
|
1539
|
-
return;
|
|
1540
|
-
}
|
|
1541
|
-
const connectedCount = state.manager.getAllConnections().size;
|
|
1542
|
-
ui.setStatus("mcp", ui.theme.fg("accent", `MCP: ${connectedCount}/${total} servers`));
|
|
1543
|
-
}
|
|
1544
|
-
|
|
1545
|
-
function getFailureAgeSeconds(state: McpExtensionState, serverName: string): number | null {
|
|
1546
|
-
const failedAt = state.failureTracker.get(serverName);
|
|
1547
|
-
if (!failedAt) return null;
|
|
1548
|
-
const ageMs = Date.now() - failedAt;
|
|
1549
|
-
if (ageMs > FAILURE_BACKOFF_MS) return null;
|
|
1550
|
-
return Math.round(ageMs / 1000);
|
|
1551
|
-
}
|
|
1552
|
-
|
|
1553
|
-
function getEffectiveIdleTimeoutMinutes(state: McpExtensionState, serverName: string): number {
|
|
1554
|
-
const definition = state.config.mcpServers[serverName];
|
|
1555
|
-
if (!definition) {
|
|
1556
|
-
return typeof state.config.settings?.idleTimeout === "number" ? state.config.settings.idleTimeout : 10;
|
|
1557
|
-
}
|
|
1558
|
-
if (typeof definition.idleTimeout === "number") return definition.idleTimeout;
|
|
1559
|
-
const mode = definition.lifecycle ?? "lazy";
|
|
1560
|
-
if (mode === "eager") return 0;
|
|
1561
|
-
return typeof state.config.settings?.idleTimeout === "number" ? state.config.settings.idleTimeout : 10;
|
|
1562
|
-
}
|
|
1563
|
-
|
|
1564
|
-
async function lazyConnect(state: McpExtensionState, serverName: string): Promise<boolean> {
|
|
1565
|
-
const connection = state.manager.getConnection(serverName);
|
|
1566
|
-
if (connection?.status === "connected") {
|
|
1567
|
-
updateServerMetadata(state, serverName);
|
|
1568
|
-
return true;
|
|
1569
|
-
}
|
|
1570
|
-
|
|
1571
|
-
const failedAgo = getFailureAgeSeconds(state, serverName);
|
|
1572
|
-
if (failedAgo !== null) return false;
|
|
1573
|
-
|
|
1574
|
-
const definition = state.config.mcpServers[serverName];
|
|
1575
|
-
if (!definition) return false;
|
|
1576
|
-
|
|
1577
|
-
try {
|
|
1578
|
-
if (state.ui) {
|
|
1579
|
-
state.ui.setStatus("mcp", `MCP: connecting to ${serverName}...`);
|
|
1580
|
-
}
|
|
1581
|
-
await state.manager.connect(serverName, definition);
|
|
1582
|
-
state.failureTracker.delete(serverName);
|
|
1583
|
-
updateServerMetadata(state, serverName);
|
|
1584
|
-
updateMetadataCache(state, serverName);
|
|
1585
|
-
updateStatusBar(state);
|
|
1586
|
-
return true;
|
|
1587
|
-
} catch {
|
|
1588
|
-
state.failureTracker.set(serverName, Date.now());
|
|
1589
|
-
updateStatusBar(state);
|
|
1590
|
-
return false;
|
|
1591
|
-
}
|
|
1592
|
-
}
|
|
1593
|
-
|
|
1594
|
-
async function authenticateServer(
|
|
1595
|
-
serverName: string,
|
|
1596
|
-
config: McpConfig,
|
|
1597
|
-
ctx: ExtensionContext
|
|
1598
|
-
): Promise<void> {
|
|
1599
|
-
if (!ctx.hasUI) return;
|
|
1600
|
-
|
|
1601
|
-
const definition = config.mcpServers[serverName];
|
|
1602
|
-
if (!definition) {
|
|
1603
|
-
ctx.ui.notify(`Server "${serverName}" not found in config`, "error");
|
|
1604
|
-
return;
|
|
1605
|
-
}
|
|
1606
|
-
|
|
1607
|
-
if (definition.auth !== "oauth") {
|
|
1608
|
-
ctx.ui.notify(
|
|
1609
|
-
`Server "${serverName}" does not use OAuth authentication.\n` +
|
|
1610
|
-
`Current auth mode: ${definition.auth ?? "none"}`,
|
|
1611
|
-
"error"
|
|
1612
|
-
);
|
|
1613
|
-
return;
|
|
1614
|
-
}
|
|
1615
|
-
|
|
1616
|
-
if (!definition.url) {
|
|
1617
|
-
ctx.ui.notify(
|
|
1618
|
-
`Server "${serverName}" has no URL configured (OAuth requires HTTP transport)`,
|
|
1619
|
-
"error"
|
|
1620
|
-
);
|
|
1621
|
-
return;
|
|
1622
|
-
}
|
|
1623
|
-
|
|
1624
|
-
// Show instructions for obtaining OAuth tokens
|
|
1625
|
-
const tokenPath = `~/.pi/agent/mcp-oauth/${serverName}/tokens.json`;
|
|
1626
|
-
|
|
1627
|
-
ctx.ui.notify(
|
|
1628
|
-
`OAuth setup for "${serverName}":\n\n` +
|
|
1629
|
-
`1. Obtain an access token from your OAuth provider\n` +
|
|
1630
|
-
`2. Create the token file:\n` +
|
|
1631
|
-
` ${tokenPath}\n\n` +
|
|
1632
|
-
`3. Add your token:\n` +
|
|
1633
|
-
` {\n` +
|
|
1634
|
-
` "access_token": "your-token-here",\n` +
|
|
1635
|
-
` "token_type": "bearer"\n` +
|
|
1636
|
-
` }\n\n` +
|
|
1637
|
-
`4. Run /mcp reconnect to connect with the token`,
|
|
1638
|
-
"info"
|
|
1639
|
-
);
|
|
1640
|
-
}
|
|
1641
|
-
|
|
1642
|
-
async function openMcpPanel(
|
|
1643
|
-
state: McpExtensionState,
|
|
1644
|
-
pi: ExtensionAPI,
|
|
1645
|
-
ctx: ExtensionContext,
|
|
1646
|
-
configOverridePath?: string,
|
|
1647
|
-
): Promise<void> {
|
|
1648
|
-
const config = state.config;
|
|
1649
|
-
const cache = loadMetadataCache();
|
|
1650
|
-
const provenanceMap = getServerProvenance(pi.getFlag("mcp-config") as string | undefined ?? configOverridePath);
|
|
1651
|
-
|
|
1652
|
-
const callbacks: McpPanelCallbacks = {
|
|
1653
|
-
reconnect: async (serverName: string) => {
|
|
1654
|
-
return lazyConnect(state, serverName);
|
|
1655
|
-
},
|
|
1656
|
-
getConnectionStatus: (serverName: string) => {
|
|
1657
|
-
const definition = config.mcpServers[serverName];
|
|
1658
|
-
if (definition?.auth === "oauth" && getStoredTokens(serverName) === undefined) {
|
|
1659
|
-
return "needs-auth";
|
|
1660
|
-
}
|
|
1661
|
-
const connection = state.manager.getConnection(serverName);
|
|
1662
|
-
if (connection?.status === "connected") return "connected";
|
|
1663
|
-
if (getFailureAgeSeconds(state, serverName) !== null) return "failed";
|
|
1664
|
-
return "idle";
|
|
1665
|
-
},
|
|
1666
|
-
refreshCacheAfterReconnect: (serverName: string) => {
|
|
1667
|
-
const freshCache = loadMetadataCache();
|
|
1668
|
-
return freshCache?.servers?.[serverName] ?? null;
|
|
1669
|
-
},
|
|
1670
|
-
};
|
|
1671
|
-
|
|
1672
|
-
const { createMcpPanel } = await import("./mcp-panel.js");
|
|
1673
|
-
|
|
1674
|
-
return new Promise<void>((resolve) => {
|
|
1675
|
-
ctx.ui.custom(
|
|
1676
|
-
(tui, _theme, _keybindings, done) => {
|
|
1677
|
-
return createMcpPanel(config, cache, provenanceMap, callbacks, tui, (result: McpPanelResult) => {
|
|
1678
|
-
if (!result.cancelled && result.changes.size > 0) {
|
|
1679
|
-
writeDirectToolsConfig(result.changes, provenanceMap, config);
|
|
1680
|
-
ctx.ui.notify("Direct tools updated. Restart pi to apply.", "info");
|
|
1681
|
-
}
|
|
1682
|
-
done();
|
|
1683
|
-
resolve();
|
|
1684
|
-
});
|
|
1685
|
-
},
|
|
1686
|
-
{ overlay: true, overlayOptions: { anchor: "center", width: 82 } },
|
|
1687
|
-
);
|
|
1688
|
-
});
|
|
1689
|
-
}
|
|
1690
|
-
|
|
1691
|
-
/**
|
|
1692
|
-
* Truncate text at word boundary, aiming for target length.
|
|
1693
|
-
*/
|
|
1694
|
-
function truncateAtWord(text: string, target: number): string {
|
|
1695
|
-
if (!text || text.length <= target) return text;
|
|
1696
|
-
|
|
1697
|
-
// Find last space before or at target
|
|
1698
|
-
const truncated = text.slice(0, target);
|
|
1699
|
-
const lastSpace = truncated.lastIndexOf(" ");
|
|
1700
|
-
|
|
1701
|
-
if (lastSpace > target * 0.6) {
|
|
1702
|
-
// Found a reasonable break point
|
|
1703
|
-
return truncated.slice(0, lastSpace) + "...";
|
|
1704
|
-
}
|
|
1705
|
-
|
|
1706
|
-
// No good break point, just cut at target
|
|
1707
|
-
return truncated + "...";
|
|
1708
|
-
}
|