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/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 { existsSync } from "node:fs";
5
- import { loadMcpConfig, getServerProvenance, writeDirectToolsConfig } from "./config.js";
6
- import { formatToolName, getServerPrefix, type McpConfig, type McpContent, type ToolMetadata, type McpTool, type McpResource, type ServerEntry, type DirectToolSpec, type McpPanelCallbacks, type McpPanelResult } from "./types.js";
7
- import { McpServerManager } from "./server-manager.js";
8
- import { McpLifecycleManager } from "./lifecycle.js";
9
- import { transformMcpContent } from "./tool-registrar.js";
10
- import { resourceNameToToolName } from "./resource-tools.js";
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
- async execute(_toolCallId, params) {
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
- // Mode resolution: tool > connect > describe > search > server > status
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
- }