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/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 } 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
- 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
- },
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
- // Mode resolution: tool > connect > describe > search > server > status
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
- }