pi-mcp-adapter 2.1.2 → 2.2.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/CHANGELOG.md +34 -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 +36 -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 } from "./utils.js";
|
|
232
11
|
|
|
233
12
|
export default function mcpAdapter(pi: ExtensionAPI) {
|
|
234
13
|
let state: McpExtensionState | null = null;
|
|
@@ -255,109 +34,20 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
255
34
|
label: `MCP: ${spec.originalName}`,
|
|
256
35
|
description: spec.description || "(no description)",
|
|
257
36
|
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
|
-
},
|
|
37
|
+
execute: createDirectToolExecutor(() => state, () => initPromise, spec),
|
|
346
38
|
});
|
|
347
39
|
}
|
|
348
40
|
|
|
349
|
-
// Capture pi tool accessor (closure) for unified search
|
|
350
41
|
const getPiTools = (): ToolInfo[] => pi.getAllTools();
|
|
351
|
-
|
|
42
|
+
|
|
352
43
|
pi.registerFlag("mcp-config", {
|
|
353
44
|
description: "Path to MCP config file",
|
|
354
45
|
type: "string",
|
|
355
46
|
});
|
|
356
|
-
|
|
47
|
+
|
|
357
48
|
pi.on("session_start", async (_event, ctx) => {
|
|
358
|
-
// Non-blocking init - Pi starts immediately, MCP connects in background
|
|
359
49
|
initPromise = initializeMcp(pi, ctx);
|
|
360
|
-
|
|
50
|
+
|
|
361
51
|
initPromise.then(s => {
|
|
362
52
|
state = s;
|
|
363
53
|
initPromise = null;
|
|
@@ -367,7 +57,7 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
367
57
|
initPromise = null;
|
|
368
58
|
});
|
|
369
59
|
});
|
|
370
|
-
|
|
60
|
+
|
|
371
61
|
pi.on("session_shutdown", async () => {
|
|
372
62
|
if (initPromise) {
|
|
373
63
|
try {
|
|
@@ -376,19 +66,21 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
376
66
|
// Initialization failed, nothing to clean up
|
|
377
67
|
}
|
|
378
68
|
}
|
|
379
|
-
|
|
69
|
+
|
|
380
70
|
if (state) {
|
|
71
|
+
if (state.uiServer) {
|
|
72
|
+
state.uiServer.close("session_shutdown");
|
|
73
|
+
state.uiServer = null;
|
|
74
|
+
}
|
|
381
75
|
flushMetadataCache(state);
|
|
382
76
|
await state.lifecycle.gracefulShutdown();
|
|
383
77
|
state = null;
|
|
384
78
|
}
|
|
385
79
|
});
|
|
386
|
-
|
|
387
|
-
// /mcp command
|
|
80
|
+
|
|
388
81
|
pi.registerCommand("mcp", {
|
|
389
82
|
description: "Show MCP server status",
|
|
390
83
|
handler: async (args, ctx) => {
|
|
391
|
-
// Wait for init if still in progress
|
|
392
84
|
if (!state && initPromise) {
|
|
393
85
|
try {
|
|
394
86
|
state = await initPromise;
|
|
@@ -401,11 +93,11 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
401
93
|
if (ctx.hasUI) ctx.ui.notify("MCP not initialized", "error");
|
|
402
94
|
return;
|
|
403
95
|
}
|
|
404
|
-
|
|
96
|
+
|
|
405
97
|
const parts = args?.trim()?.split(/\s+/) ?? [];
|
|
406
98
|
const subcommand = parts[0] ?? "";
|
|
407
99
|
const targetServer = parts[1];
|
|
408
|
-
|
|
100
|
+
|
|
409
101
|
switch (subcommand) {
|
|
410
102
|
case "reconnect":
|
|
411
103
|
await reconnectServers(state, ctx, targetServer);
|
|
@@ -425,8 +117,7 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
425
117
|
}
|
|
426
118
|
},
|
|
427
119
|
});
|
|
428
|
-
|
|
429
|
-
// /mcp-auth command
|
|
120
|
+
|
|
430
121
|
pi.registerCommand("mcp-auth", {
|
|
431
122
|
description: "Authenticate with an MCP server (OAuth)",
|
|
432
123
|
handler: async (args, ctx) => {
|
|
@@ -435,8 +126,7 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
435
126
|
if (ctx.hasUI) ctx.ui.notify("Usage: /mcp-auth <server-name>", "error");
|
|
436
127
|
return;
|
|
437
128
|
}
|
|
438
|
-
|
|
439
|
-
// Wait for init if still in progress
|
|
129
|
+
|
|
440
130
|
if (!state && initPromise) {
|
|
441
131
|
try {
|
|
442
132
|
state = await initPromise;
|
|
@@ -449,29 +139,25 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
449
139
|
if (ctx.hasUI) ctx.ui.notify("MCP not initialized", "error");
|
|
450
140
|
return;
|
|
451
141
|
}
|
|
452
|
-
|
|
142
|
+
|
|
453
143
|
await authenticateServer(serverName, state.config, ctx);
|
|
454
144
|
},
|
|
455
145
|
});
|
|
456
|
-
|
|
457
|
-
// Single unified MCP tool - mode determined by parameters
|
|
146
|
+
|
|
458
147
|
pi.registerTool({
|
|
459
148
|
name: "mcp",
|
|
460
149
|
label: "MCP",
|
|
461
150
|
description: buildProxyDescription(earlyConfig, earlyCache, directSpecs),
|
|
462
151
|
parameters: Type.Object({
|
|
463
|
-
// Call mode
|
|
464
152
|
tool: Type.Optional(Type.String({ description: "Tool name to call (e.g., 'xcodebuild_list_sims')" })),
|
|
465
153
|
args: Type.Optional(Type.String({ description: "Arguments as JSON string (e.g., '{\"key\": \"value\"}')" })),
|
|
466
154
|
connect: Type.Optional(Type.String({ description: "Server name to connect (lazy connect + metadata refresh)" })),
|
|
467
|
-
// Describe mode
|
|
468
155
|
describe: Type.Optional(Type.String({ description: "Tool name to describe (shows parameters)" })),
|
|
469
|
-
// Search mode
|
|
470
156
|
search: Type.Optional(Type.String({ description: "Search tools by name/description" })),
|
|
471
157
|
regex: Type.Optional(Type.Boolean({ description: "Treat search as regex (default: substring match)" })),
|
|
472
158
|
includeSchemas: Type.Optional(Type.Boolean({ description: "Include parameter schemas in search results (default: true)" })),
|
|
473
|
-
// Filter (works with search or list)
|
|
474
159
|
server: Type.Optional(Type.String({ description: "Filter to specific server (also disambiguates tool calls)" })),
|
|
160
|
+
action: Type.Optional(Type.String({ description: "Action: 'ui-messages' to retrieve prompts/intents from UI sessions" })),
|
|
475
161
|
}),
|
|
476
162
|
async execute(_toolCallId, params: {
|
|
477
163
|
tool?: string;
|
|
@@ -482,8 +168,8 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
482
168
|
regex?: boolean;
|
|
483
169
|
includeSchemas?: boolean;
|
|
484
170
|
server?: string;
|
|
171
|
+
action?: string;
|
|
485
172
|
}, _signal, _onUpdate, _ctx) {
|
|
486
|
-
// Parse args from JSON string if provided
|
|
487
173
|
let parsedArgs: Record<string, unknown> | undefined;
|
|
488
174
|
if (params.args) {
|
|
489
175
|
try {
|
|
@@ -491,39 +177,40 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
491
177
|
if (typeof parsedArgs !== "object" || parsedArgs === null || Array.isArray(parsedArgs)) {
|
|
492
178
|
const gotType = Array.isArray(parsedArgs) ? "array" : parsedArgs === null ? "null" : typeof parsedArgs;
|
|
493
179
|
return {
|
|
494
|
-
content: [{ type: "text", text: `Invalid args: expected a JSON object, got ${gotType}` }],
|
|
180
|
+
content: [{ type: "text" as const, text: `Invalid args: expected a JSON object, got ${gotType}` }],
|
|
495
181
|
isError: true,
|
|
496
182
|
details: { error: "invalid_args_type" },
|
|
497
183
|
};
|
|
498
184
|
}
|
|
499
185
|
} catch (e) {
|
|
500
186
|
return {
|
|
501
|
-
content: [{ type: "text", text: `Invalid args JSON: ${e instanceof Error ? e.message : e}` }],
|
|
187
|
+
content: [{ type: "text" as const, text: `Invalid args JSON: ${e instanceof Error ? e.message : e}` }],
|
|
502
188
|
isError: true,
|
|
503
189
|
details: { error: "invalid_args" },
|
|
504
190
|
};
|
|
505
191
|
}
|
|
506
192
|
}
|
|
507
|
-
|
|
508
|
-
// Wait for init if still in progress
|
|
193
|
+
|
|
509
194
|
if (!state && initPromise) {
|
|
510
195
|
try {
|
|
511
196
|
state = await initPromise;
|
|
512
197
|
} catch {
|
|
513
198
|
return {
|
|
514
|
-
content: [{ type: "text", text: "MCP initialization failed" }],
|
|
199
|
+
content: [{ type: "text" as const, text: "MCP initialization failed" }],
|
|
515
200
|
details: { error: "init_failed" },
|
|
516
201
|
};
|
|
517
202
|
}
|
|
518
203
|
}
|
|
519
204
|
if (!state) {
|
|
520
205
|
return {
|
|
521
|
-
content: [{ type: "text", text: "MCP not initialized" }],
|
|
206
|
+
content: [{ type: "text" as const, text: "MCP not initialized" }],
|
|
522
207
|
details: { error: "not_initialized" },
|
|
523
208
|
};
|
|
524
209
|
}
|
|
525
|
-
|
|
526
|
-
|
|
210
|
+
|
|
211
|
+
if (params.action === "ui-messages") {
|
|
212
|
+
return executeUiMessages(state);
|
|
213
|
+
}
|
|
527
214
|
if (params.tool) {
|
|
528
215
|
return executeCall(state, params.tool, parsedArgs, params.server);
|
|
529
216
|
}
|
|
@@ -543,1166 +230,3 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
543
230
|
},
|
|
544
231
|
});
|
|
545
232
|
}
|
|
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
|
-
}
|