pi-mcp-adapter 1.1.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 ADDED
@@ -0,0 +1,907 @@
1
+ // index.ts - Full extension entry point with commands
2
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
3
+ import { Type } from "@sinclair/typebox";
4
+ import { loadMcpConfig } from "./config.js";
5
+ import { formatToolName, type McpConfig, type McpContent } from "./types.js";
6
+ import { McpServerManager } from "./server-manager.js";
7
+ import { McpLifecycleManager } from "./lifecycle.js";
8
+ import { collectToolNames, transformMcpContent } from "./tool-registrar.js";
9
+ import { collectResourceToolNames, resourceNameToToolName } from "./resource-tools.js";
10
+
11
+ interface ToolMetadata {
12
+ name: string; // Prefixed tool name (e.g., "xcodebuild_list_sims")
13
+ originalName: string; // Original MCP tool name (e.g., "list_sims")
14
+ description: string;
15
+ resourceUri?: string; // For resource tools: the URI to read
16
+ inputSchema?: unknown; // JSON Schema for parameters (stored for describe/errors)
17
+ }
18
+
19
+ interface McpExtensionState {
20
+ manager: McpServerManager;
21
+ lifecycle: McpLifecycleManager;
22
+ registeredTools: Map<string, string[]>;
23
+ toolMetadata: Map<string, ToolMetadata[]>; // server -> tool metadata for searching
24
+ config: McpConfig;
25
+ }
26
+
27
+ export default function mcpAdapter(pi: ExtensionAPI) {
28
+ let state: McpExtensionState | null = null;
29
+ let initPromise: Promise<McpExtensionState> | null = null;
30
+
31
+ pi.registerFlag("mcp-config", {
32
+ description: "Path to MCP config file",
33
+ type: "string",
34
+ });
35
+
36
+ pi.on("session_start", async (_event, ctx) => {
37
+ initPromise = initializeMcp(pi, ctx);
38
+ state = await initPromise;
39
+ initPromise = null;
40
+
41
+ // Set up callback for auto-reconnect to update metadata
42
+ state.lifecycle.setReconnectCallback((serverName) => {
43
+ if (state) {
44
+ updateServerMetadata(state, serverName);
45
+ }
46
+ });
47
+
48
+ if (ctx.hasUI) {
49
+ const totalTools = [...state.registeredTools.values()].flat().length;
50
+ if (totalTools > 0) {
51
+ ctx.ui.setStatus("mcp", ctx.ui.theme.fg("accent", `MCP: ${totalTools} tools`));
52
+ } else {
53
+ ctx.ui.setStatus("mcp", "");
54
+ }
55
+ }
56
+ });
57
+
58
+ pi.on("session_shutdown", async () => {
59
+ if (initPromise) {
60
+ try {
61
+ state = await initPromise;
62
+ } catch {
63
+ // Initialization failed, nothing to clean up
64
+ }
65
+ }
66
+
67
+ if (state) {
68
+ await state.lifecycle.gracefulShutdown();
69
+ state = null;
70
+ }
71
+ });
72
+
73
+ // /mcp command
74
+ pi.registerCommand("mcp", {
75
+ description: "Show MCP server status",
76
+ handler: async (args, ctx) => {
77
+ if (!state) {
78
+ if (ctx.hasUI) ctx.ui.notify("MCP not initialized", "error");
79
+ return;
80
+ }
81
+
82
+ const subcommand = args?.trim()?.split(/\s+/)?.[0] ?? "";
83
+
84
+ switch (subcommand) {
85
+ case "reconnect":
86
+ await reconnectServers(state, ctx);
87
+ break;
88
+ case "tools":
89
+ await showTools(state, ctx);
90
+ break;
91
+ case "status":
92
+ case "":
93
+ default:
94
+ await showStatus(state, ctx);
95
+ break;
96
+ }
97
+ },
98
+ });
99
+
100
+ // /mcp-auth command
101
+ pi.registerCommand("mcp-auth", {
102
+ description: "Authenticate with an MCP server (OAuth)",
103
+ handler: async (args, ctx) => {
104
+ const serverName = args?.trim();
105
+ if (!serverName) {
106
+ if (ctx.hasUI) ctx.ui.notify("Usage: /mcp-auth <server-name>", "error");
107
+ return;
108
+ }
109
+
110
+ if (!state) {
111
+ if (ctx.hasUI) ctx.ui.notify("MCP not initialized", "error");
112
+ return;
113
+ }
114
+
115
+ await authenticateServer(serverName, state.config, ctx);
116
+ },
117
+ });
118
+
119
+ // Single unified MCP tool - mode determined by parameters
120
+ pi.registerTool({
121
+ name: "mcp",
122
+ label: "MCP",
123
+ description: `MCP gateway - connect to MCP servers and call their tools.
124
+
125
+ Usage:
126
+ mcp({ }) → Show server status
127
+ mcp({ server: "name" }) → List tools from server
128
+ mcp({ search: "query" }) → Search for tools (includes schemas, space-separated words OR'd)
129
+ mcp({ describe: "tool_name" }) → Show tool details and parameters
130
+ mcp({ tool: "name", args: {...} }) → Call a tool
131
+
132
+ Mode: tool (call) > describe > search > server (list) > nothing (status)`,
133
+ parameters: Type.Object({
134
+ // Call mode
135
+ tool: Type.Optional(Type.String({ description: "Tool name to call (e.g., 'xcodebuild_list_sims')" })),
136
+ args: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Arguments for tool call" })),
137
+ // Describe mode
138
+ describe: Type.Optional(Type.String({ description: "Tool name to describe (shows parameters)" })),
139
+ // Search mode
140
+ search: Type.Optional(Type.String({ description: "Search tools by name/description" })),
141
+ regex: Type.Optional(Type.Boolean({ description: "Treat search as regex (default: substring match)" })),
142
+ includeSchemas: Type.Optional(Type.Boolean({ description: "Include parameter schemas in search results (default: true)" })),
143
+ // Filter (works with search or list)
144
+ server: Type.Optional(Type.String({ description: "Filter to specific server" })),
145
+ }),
146
+ async execute(_toolCallId, params: {
147
+ tool?: string;
148
+ args?: Record<string, unknown>;
149
+ describe?: string;
150
+ search?: string;
151
+ regex?: boolean;
152
+ includeSchemas?: boolean;
153
+ server?: string;
154
+ }) {
155
+ if (!state) {
156
+ return {
157
+ content: [{ type: "text", text: "MCP not initialized" }],
158
+ details: { error: "not_initialized" },
159
+ };
160
+ }
161
+
162
+ // Mode resolution: tool > describe > search > server > status
163
+ if (params.tool) {
164
+ return executeCall(state, params.tool, params.args);
165
+ }
166
+ if (params.describe) {
167
+ return executeDescribe(state, params.describe);
168
+ }
169
+ if (params.search) {
170
+ return executeSearch(state, params.search, params.regex, params.server, params.includeSchemas);
171
+ }
172
+ if (params.server) {
173
+ return executeList(state, params.server);
174
+ }
175
+ return executeStatus(state);
176
+ },
177
+ });
178
+ }
179
+
180
+ // --- Mode implementations ---
181
+
182
+ function executeStatus(state: McpExtensionState) {
183
+ const servers: Array<{ name: string; status: string; toolCount: number }> = [];
184
+
185
+ for (const name of Object.keys(state.config.mcpServers)) {
186
+ const connection = state.manager.getConnection(name);
187
+ const toolNames = state.registeredTools.get(name) ?? [];
188
+ servers.push({
189
+ name,
190
+ status: connection?.status ?? "not connected",
191
+ toolCount: toolNames.length,
192
+ });
193
+ }
194
+
195
+ const totalTools = servers.reduce((sum, s) => sum + s.toolCount, 0);
196
+ const connectedCount = servers.filter(s => s.status === "connected").length;
197
+
198
+ let text = `MCP: ${connectedCount}/${servers.length} servers, ${totalTools} tools\n\n`;
199
+ for (const server of servers) {
200
+ const icon = server.status === "connected" ? "✓" : "○";
201
+ text += `${icon} ${server.name} (${server.toolCount} tools)\n`;
202
+ }
203
+
204
+ if (servers.length > 0) {
205
+ text += `\nmcp({ server: "name" }) to list tools, mcp({ search: "..." }) to search`;
206
+ }
207
+
208
+ return {
209
+ content: [{ type: "text" as const, text: text.trim() }],
210
+ details: { mode: "status", servers, totalTools, connectedCount },
211
+ };
212
+ }
213
+
214
+ function executeDescribe(state: McpExtensionState, toolName: string) {
215
+ // Find the tool in metadata
216
+ let serverName: string | undefined;
217
+ let toolMeta: ToolMetadata | undefined;
218
+
219
+ for (const [server, metadata] of state.toolMetadata.entries()) {
220
+ const found = metadata.find(m => m.name === toolName);
221
+ if (found) {
222
+ serverName = server;
223
+ toolMeta = found;
224
+ break;
225
+ }
226
+ }
227
+
228
+ if (!serverName || !toolMeta) {
229
+ return {
230
+ content: [{ type: "text" as const, text: `Tool "${toolName}" not found. Use mcp({ search: "..." }) to search.` }],
231
+ details: { mode: "describe", error: "tool_not_found", requestedTool: toolName },
232
+ };
233
+ }
234
+
235
+ let text = `${toolMeta.name}\n`;
236
+ text += `Server: ${serverName}\n`;
237
+ if (toolMeta.resourceUri) {
238
+ text += `Type: Resource (reads from ${toolMeta.resourceUri})\n`;
239
+ }
240
+ text += `\n${toolMeta.description || "(no description)"}\n`;
241
+
242
+ // Format parameters from schema
243
+ if (toolMeta.inputSchema && !toolMeta.resourceUri) {
244
+ text += `\nParameters:\n${formatSchema(toolMeta.inputSchema)}`;
245
+ } else if (toolMeta.resourceUri) {
246
+ text += `\nNo parameters required (resource tool).`;
247
+ } else {
248
+ text += `\nNo parameters defined.`;
249
+ }
250
+
251
+ return {
252
+ content: [{ type: "text" as const, text: text.trim() }],
253
+ details: { mode: "describe", tool: toolMeta, server: serverName },
254
+ };
255
+ }
256
+
257
+ /**
258
+ * Format JSON Schema to human-readable parameter documentation.
259
+ */
260
+ function formatSchema(schema: unknown, indent = " "): string {
261
+ if (!schema || typeof schema !== "object") {
262
+ return `${indent}(no schema)`;
263
+ }
264
+
265
+ const s = schema as Record<string, unknown>;
266
+
267
+ // Handle object type with properties
268
+ if (s.type === "object" && s.properties && typeof s.properties === "object") {
269
+ const props = s.properties as Record<string, unknown>;
270
+ const required = Array.isArray(s.required) ? s.required as string[] : [];
271
+
272
+ if (Object.keys(props).length === 0) {
273
+ return `${indent}(no parameters)`;
274
+ }
275
+
276
+ const lines: string[] = [];
277
+ for (const [name, propSchema] of Object.entries(props)) {
278
+ const isRequired = required.includes(name);
279
+ const propLine = formatProperty(name, propSchema, isRequired, indent);
280
+ lines.push(propLine);
281
+ }
282
+ return lines.join("\n");
283
+ }
284
+
285
+ // Fallback: just show the schema type
286
+ if (s.type) {
287
+ return `${indent}(${s.type})`;
288
+ }
289
+
290
+ return `${indent}(complex schema)`;
291
+ }
292
+
293
+ /**
294
+ * Format a single property from JSON Schema.
295
+ */
296
+ function formatProperty(name: string, schema: unknown, required: boolean, indent: string): string {
297
+ if (!schema || typeof schema !== "object") {
298
+ return `${indent}${name}${required ? " *required*" : ""}`;
299
+ }
300
+
301
+ const s = schema as Record<string, unknown>;
302
+ const parts: string[] = [];
303
+
304
+ // Type info
305
+ let typeStr = "";
306
+ if (s.type) {
307
+ if (Array.isArray(s.type)) {
308
+ typeStr = s.type.join(" | ");
309
+ } else {
310
+ typeStr = String(s.type);
311
+ }
312
+ } else if (s.enum) {
313
+ typeStr = "enum";
314
+ } else if (s.anyOf || s.oneOf) {
315
+ typeStr = "union";
316
+ }
317
+
318
+ // Enum values
319
+ if (Array.isArray(s.enum)) {
320
+ const enumVals = s.enum.map(v => JSON.stringify(v)).join(", ");
321
+ typeStr = `enum: ${enumVals}`;
322
+ }
323
+
324
+ // Build the line
325
+ parts.push(`${indent}${name}`);
326
+ if (typeStr) parts.push(`(${typeStr})`);
327
+ if (required) parts.push("*required*");
328
+
329
+ // Description
330
+ if (s.description && typeof s.description === "string") {
331
+ parts.push(`- ${s.description}`);
332
+ }
333
+
334
+ // Default value
335
+ if (s.default !== undefined) {
336
+ parts.push(`[default: ${JSON.stringify(s.default)}]`);
337
+ }
338
+
339
+ return parts.join(" ");
340
+ }
341
+
342
+ function executeSearch(
343
+ state: McpExtensionState,
344
+ query: string,
345
+ regex?: boolean,
346
+ server?: string,
347
+ includeSchemas?: boolean
348
+ ) {
349
+ // Default to including schemas
350
+ const showSchemas = includeSchemas !== false;
351
+
352
+ const matches: Array<{ server: string; tool: ToolMetadata }> = [];
353
+
354
+ let pattern: RegExp;
355
+ try {
356
+ if (regex) {
357
+ pattern = new RegExp(query, "i");
358
+ } else {
359
+ // Split on whitespace and OR the terms (like most search engines)
360
+ const terms = query.trim().split(/\s+/).filter(t => t.length > 0);
361
+ if (terms.length === 0) {
362
+ return {
363
+ content: [{ type: "text" as const, text: "Search query cannot be empty" }],
364
+ details: { mode: "search", error: "empty_query" },
365
+ };
366
+ }
367
+ const escaped = terms.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
368
+ pattern = new RegExp(escaped.join("|"), "i");
369
+ }
370
+ } catch {
371
+ return {
372
+ content: [{ type: "text" as const, text: `Invalid regex: ${query}` }],
373
+ details: { mode: "search", error: "invalid_pattern", query },
374
+ };
375
+ }
376
+
377
+ for (const [serverName, metadata] of state.toolMetadata.entries()) {
378
+ if (server && serverName !== server) continue;
379
+ for (const tool of metadata) {
380
+ if (pattern.test(tool.name) || pattern.test(tool.description)) {
381
+ matches.push({
382
+ server: serverName,
383
+ tool,
384
+ });
385
+ }
386
+ }
387
+ }
388
+
389
+ if (matches.length === 0) {
390
+ const msg = server
391
+ ? `No tools matching "${query}" in "${server}"`
392
+ : `No tools matching "${query}"`;
393
+ return {
394
+ content: [{ type: "text" as const, text: msg }],
395
+ details: { mode: "search", matches: [], count: 0, query },
396
+ };
397
+ }
398
+
399
+ let text = `Found ${matches.length} tool${matches.length === 1 ? "" : "s"} matching "${query}":\n\n`;
400
+
401
+ for (const match of matches) {
402
+ if (showSchemas) {
403
+ // Full format with schema
404
+ text += `${match.tool.name}\n`;
405
+ text += ` ${match.tool.description || "(no description)"}\n`;
406
+ if (match.tool.inputSchema && !match.tool.resourceUri) {
407
+ text += `\n Parameters:\n${formatSchema(match.tool.inputSchema, " ")}\n`;
408
+ } else if (match.tool.resourceUri) {
409
+ text += ` No parameters (resource tool).\n`;
410
+ }
411
+ text += "\n";
412
+ } else {
413
+ // Compact format without schema
414
+ text += `- ${match.tool.name}`;
415
+ if (match.tool.description) {
416
+ text += ` - ${truncateAtWord(match.tool.description, 50)}`;
417
+ }
418
+ text += "\n";
419
+ }
420
+ }
421
+
422
+ return {
423
+ content: [{ type: "text" as const, text: text.trim() }],
424
+ details: { mode: "search", matches: matches.map(m => ({ server: m.server, tool: m.tool.name })), count: matches.length, query },
425
+ };
426
+ }
427
+
428
+ function executeList(state: McpExtensionState, server: string) {
429
+ const toolNames = state.registeredTools.get(server);
430
+ const metadata = state.toolMetadata.get(server);
431
+
432
+ if (!toolNames || toolNames.length === 0) {
433
+ // Server exists in registeredTools (even if empty) means it connected
434
+ if (state.registeredTools.has(server)) {
435
+ return {
436
+ content: [{ type: "text" as const, text: `Server "${server}" has no tools.` }],
437
+ details: { mode: "list", server, tools: [], count: 0 },
438
+ };
439
+ }
440
+ // Server in config but not in registeredTools means connection failed
441
+ if (state.config.mcpServers[server]) {
442
+ return {
443
+ content: [{ type: "text" as const, text: `Server "${server}" is configured but not connected. Use /mcp reconnect to retry.` }],
444
+ details: { mode: "list", server, tools: [], count: 0, error: "not_connected" },
445
+ };
446
+ }
447
+ // Server not in config at all
448
+ return {
449
+ content: [{ type: "text" as const, text: `Server "${server}" not found. Use mcp({}) to see available servers.` }],
450
+ details: { mode: "list", server, tools: [], count: 0, error: "not_found" },
451
+ };
452
+ }
453
+
454
+ let text = `${server} (${toolNames.length} tools):\n\n`;
455
+
456
+ // Build a map of tool name -> description for quick lookup
457
+ const descMap = new Map<string, string>();
458
+ if (metadata) {
459
+ for (const m of metadata) {
460
+ descMap.set(m.name, m.description);
461
+ }
462
+ }
463
+
464
+ for (const tool of toolNames) {
465
+ const desc = descMap.get(tool) ?? "";
466
+ const truncated = truncateAtWord(desc, 50);
467
+ text += `- ${tool}`;
468
+ if (truncated) text += ` - ${truncated}`;
469
+ text += "\n";
470
+ }
471
+
472
+ return {
473
+ content: [{ type: "text" as const, text: text.trim() }],
474
+ details: { mode: "list", server, tools: toolNames, count: toolNames.length },
475
+ };
476
+ }
477
+
478
+ async function executeCall(
479
+ state: McpExtensionState,
480
+ toolName: string,
481
+ args?: Record<string, unknown>
482
+ ) {
483
+ // Find the tool in metadata
484
+ let serverName: string | undefined;
485
+ let toolMeta: ToolMetadata | undefined;
486
+
487
+ for (const [server, metadata] of state.toolMetadata.entries()) {
488
+ const found = metadata.find(m => m.name === toolName);
489
+ if (found) {
490
+ serverName = server;
491
+ toolMeta = found;
492
+ break;
493
+ }
494
+ }
495
+
496
+ if (!serverName || !toolMeta) {
497
+ return {
498
+ content: [{ type: "text" as const, text: `Tool "${toolName}" not found. Use mcp({ search: "..." }) to search.` }],
499
+ details: { mode: "call", error: "tool_not_found", requestedTool: toolName },
500
+ };
501
+ }
502
+
503
+ const connection = state.manager.getConnection(serverName);
504
+ if (!connection || connection.status !== "connected") {
505
+ return {
506
+ content: [{ type: "text" as const, text: `Server "${serverName}" not connected` }],
507
+ details: { mode: "call", error: "server_not_connected", server: serverName },
508
+ };
509
+ }
510
+
511
+ try {
512
+ // Resource tools use readResource, regular tools use callTool
513
+ if (toolMeta.resourceUri) {
514
+ const result = await connection.client.readResource({ uri: toolMeta.resourceUri });
515
+ const content = (result.contents ?? []).map(c => ({
516
+ type: "text" as const,
517
+ text: "text" in c ? c.text : ("blob" in c ? `[Binary data: ${(c as { mimeType?: string }).mimeType ?? "unknown"}]` : JSON.stringify(c)),
518
+ }));
519
+ return {
520
+ content: content.length > 0 ? content : [{ type: "text" as const, text: "(empty resource)" }],
521
+ details: { mode: "call", resourceUri: toolMeta.resourceUri, server: serverName },
522
+ };
523
+ }
524
+
525
+ // Regular tool call
526
+ const result = await connection.client.callTool({
527
+ name: toolMeta.originalName,
528
+ arguments: args ?? {},
529
+ });
530
+
531
+ const mcpContent = (result.content ?? []) as McpContent[];
532
+ const content = transformMcpContent(mcpContent);
533
+
534
+ if (result.isError) {
535
+ const errorText = content
536
+ .filter((c) => c.type === "text")
537
+ .map((c) => (c as { text: string }).text)
538
+ .join("\n") || "Tool execution failed";
539
+
540
+ // Include schema in error to help LLM self-correct
541
+ let errorWithSchema = `Error: ${errorText}`;
542
+ if (toolMeta.inputSchema) {
543
+ errorWithSchema += `\n\nExpected parameters:\n${formatSchema(toolMeta.inputSchema)}`;
544
+ }
545
+
546
+ return {
547
+ content: [{ type: "text" as const, text: errorWithSchema }],
548
+ details: { mode: "call", error: "tool_error", mcpResult: result },
549
+ };
550
+ }
551
+
552
+ return {
553
+ content: content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }],
554
+ details: { mode: "call", mcpResult: result, server: serverName, tool: toolMeta.originalName },
555
+ };
556
+ } catch (error) {
557
+ const message = error instanceof Error ? error.message : String(error);
558
+
559
+ // Include schema in error to help LLM self-correct
560
+ let errorWithSchema = `Failed to call tool: ${message}`;
561
+ if (toolMeta.inputSchema) {
562
+ errorWithSchema += `\n\nExpected parameters:\n${formatSchema(toolMeta.inputSchema)}`;
563
+ }
564
+
565
+ return {
566
+ content: [{ type: "text" as const, text: errorWithSchema }],
567
+ details: { mode: "call", error: "call_failed", message },
568
+ };
569
+ }
570
+ }
571
+
572
+ async function initializeMcp(
573
+ pi: ExtensionAPI,
574
+ ctx: ExtensionContext
575
+ ): Promise<McpExtensionState> {
576
+ const configPath = pi.getFlag("mcp-config") as string | undefined;
577
+ const config = loadMcpConfig(configPath);
578
+
579
+ const manager = new McpServerManager();
580
+ const lifecycle = new McpLifecycleManager(manager);
581
+ const registeredTools = new Map<string, string[]>();
582
+ const toolMetadata = new Map<string, ToolMetadata[]>();
583
+
584
+ const serverEntries = Object.entries(config.mcpServers);
585
+ if (serverEntries.length === 0) {
586
+ return { manager, lifecycle, registeredTools, toolMetadata, config };
587
+ }
588
+
589
+ for (const [name, definition] of serverEntries) {
590
+ try {
591
+ if (ctx.hasUI) {
592
+ ctx.ui.setStatus("mcp", `Connecting to ${name}...`);
593
+ }
594
+
595
+ const connection = await manager.connect(name, definition);
596
+ const prefix = config.settings?.toolPrefix ?? "server";
597
+
598
+ // Collect tool names (NOT registered with Pi - only mcp proxy is registered)
599
+ const { collected: toolNames, failed: failedTools } = collectToolNames(
600
+ connection.tools,
601
+ { serverName: name, prefix }
602
+ );
603
+
604
+ // Collect resource tool names (if enabled)
605
+ if (definition.exposeResources !== false && connection.resources.length > 0) {
606
+ const resourceToolNames = collectResourceToolNames(
607
+ connection.resources,
608
+ { serverName: name, prefix }
609
+ );
610
+ toolNames.push(...resourceToolNames);
611
+ }
612
+
613
+ registeredTools.set(name, toolNames);
614
+
615
+ // Build tool metadata for searching (include inputSchema for describe/errors)
616
+ const metadata: ToolMetadata[] = connection.tools.map(tool => ({
617
+ name: formatToolName(tool.name, name, prefix),
618
+ originalName: tool.name,
619
+ description: tool.description ?? "",
620
+ inputSchema: tool.inputSchema,
621
+ }));
622
+ // Add resource tools to metadata
623
+ for (const resource of connection.resources) {
624
+ if (definition.exposeResources !== false) {
625
+ const baseName = `get_${resourceNameToToolName(resource.name)}`;
626
+ metadata.push({
627
+ name: formatToolName(baseName, name, prefix),
628
+ originalName: baseName,
629
+ description: resource.description ?? `Read resource: ${resource.uri}`,
630
+ resourceUri: resource.uri,
631
+ });
632
+ }
633
+ }
634
+ toolMetadata.set(name, metadata);
635
+
636
+ // Mark keep-alive servers
637
+ if (definition.lifecycle === "keep-alive") {
638
+ lifecycle.markKeepAlive(name, definition);
639
+ }
640
+
641
+ if (failedTools.length > 0 && ctx.hasUI) {
642
+ ctx.ui.notify(
643
+ `MCP: ${name} - ${failedTools.length} tools skipped`,
644
+ "warning"
645
+ );
646
+ }
647
+
648
+ if (ctx.hasUI) {
649
+ ctx.ui.notify(
650
+ `MCP: ${name} connected (${connection.tools.length} tools, ${connection.resources.length} resources)`,
651
+ "info"
652
+ );
653
+ }
654
+ } catch (error) {
655
+ const message = error instanceof Error ? error.message : String(error);
656
+ if (ctx.hasUI) {
657
+ ctx.ui.notify(`MCP: Failed to connect to ${name}: ${message}`, "error");
658
+ }
659
+ console.error(`MCP: Failed to connect to ${name}: ${message}`);
660
+ }
661
+ }
662
+
663
+ // Start health checks for keep-alive servers
664
+ lifecycle.startHealthChecks();
665
+
666
+ return { manager, lifecycle, registeredTools, toolMetadata, config };
667
+ }
668
+
669
+ /**
670
+ * Update tool metadata for a single server after reconnection.
671
+ * Called by lifecycle manager when a keep-alive server reconnects.
672
+ */
673
+ function updateServerMetadata(state: McpExtensionState, serverName: string): void {
674
+ const connection = state.manager.getConnection(serverName);
675
+ if (!connection || connection.status !== "connected") return;
676
+
677
+ const definition = state.config.mcpServers[serverName];
678
+ if (!definition) return;
679
+
680
+ const prefix = state.config.settings?.toolPrefix ?? "server";
681
+
682
+ // Collect tool names
683
+ const { collected: toolNames } = collectToolNames(
684
+ connection.tools,
685
+ { serverName, prefix }
686
+ );
687
+
688
+ // Collect resource tool names if enabled
689
+ if (definition.exposeResources !== false && connection.resources.length > 0) {
690
+ const resourceToolNames = collectResourceToolNames(
691
+ connection.resources,
692
+ { serverName, prefix }
693
+ );
694
+ toolNames.push(...resourceToolNames);
695
+ }
696
+
697
+ state.registeredTools.set(serverName, toolNames);
698
+
699
+ // Update tool metadata (include inputSchema for describe/errors)
700
+ const metadata: ToolMetadata[] = connection.tools.map(tool => ({
701
+ name: formatToolName(tool.name, serverName, prefix),
702
+ originalName: tool.name,
703
+ description: tool.description ?? "",
704
+ inputSchema: tool.inputSchema,
705
+ }));
706
+ for (const resource of connection.resources) {
707
+ if (definition.exposeResources !== false) {
708
+ const baseName = `get_${resourceNameToToolName(resource.name)}`;
709
+ metadata.push({
710
+ name: formatToolName(baseName, serverName, prefix),
711
+ originalName: baseName,
712
+ description: resource.description ?? `Read resource: ${resource.uri}`,
713
+ resourceUri: resource.uri,
714
+ });
715
+ }
716
+ }
717
+ state.toolMetadata.set(serverName, metadata);
718
+ }
719
+
720
+ async function showStatus(state: McpExtensionState, ctx: ExtensionContext): Promise<void> {
721
+ if (!ctx.hasUI) return;
722
+
723
+ const lines: string[] = ["MCP Server Status:", ""];
724
+
725
+ // Show all configured servers, not just connected ones
726
+ for (const name of Object.keys(state.config.mcpServers)) {
727
+ const connection = state.manager.getConnection(name);
728
+ const toolNames = state.registeredTools.get(name) ?? [];
729
+ const status = connection?.status ?? "not connected";
730
+ const statusIcon = status === "connected" ? "✓" : "○";
731
+
732
+ lines.push(`${statusIcon} ${name}: ${status} (${toolNames.length} tools)`);
733
+ }
734
+
735
+ if (Object.keys(state.config.mcpServers).length === 0) {
736
+ lines.push("No MCP servers configured");
737
+ }
738
+
739
+ ctx.ui.notify(lines.join("\n"), "info");
740
+ }
741
+
742
+ async function showTools(state: McpExtensionState, ctx: ExtensionContext): Promise<void> {
743
+ if (!ctx.hasUI) return;
744
+
745
+ const allTools = [...state.registeredTools.values()].flat();
746
+
747
+ if (allTools.length === 0) {
748
+ ctx.ui.notify("No MCP tools available", "info");
749
+ return;
750
+ }
751
+
752
+ const lines = [
753
+ "MCP Tools:",
754
+ "",
755
+ ...allTools.map(t => ` ${t}`),
756
+ "",
757
+ `Total: ${allTools.length} tools`,
758
+ ];
759
+
760
+ ctx.ui.notify(lines.join("\n"), "info");
761
+ }
762
+
763
+ async function reconnectServers(
764
+ state: McpExtensionState,
765
+ ctx: ExtensionContext
766
+ ): Promise<void> {
767
+ for (const [name, definition] of Object.entries(state.config.mcpServers)) {
768
+ try {
769
+ await state.manager.close(name);
770
+
771
+ // Clear old entries before reconnecting (in case reconnection fails)
772
+ state.registeredTools.delete(name);
773
+ state.toolMetadata.delete(name);
774
+
775
+ const connection = await state.manager.connect(name, definition);
776
+ const prefix = state.config.settings?.toolPrefix ?? "server";
777
+
778
+ // Collect tool names (NOT registered with Pi)
779
+ const { collected: toolNames, failed: failedTools } = collectToolNames(
780
+ connection.tools,
781
+ { serverName: name, prefix }
782
+ );
783
+
784
+ // Collect resource tool names if enabled
785
+ if (definition.exposeResources !== false && connection.resources.length > 0) {
786
+ const resourceToolNames = collectResourceToolNames(
787
+ connection.resources,
788
+ { serverName: name, prefix }
789
+ );
790
+ toolNames.push(...resourceToolNames);
791
+ }
792
+
793
+ state.registeredTools.set(name, toolNames);
794
+
795
+ // Update tool metadata for searching
796
+ const metadata: ToolMetadata[] = connection.tools.map(tool => ({
797
+ name: formatToolName(tool.name, name, prefix),
798
+ originalName: tool.name,
799
+ description: tool.description ?? "",
800
+ }));
801
+ for (const resource of connection.resources) {
802
+ if (definition.exposeResources !== false) {
803
+ const baseName = `get_${resourceNameToToolName(resource.name)}`;
804
+ metadata.push({
805
+ name: formatToolName(baseName, name, prefix),
806
+ originalName: baseName,
807
+ description: resource.description ?? `Read resource: ${resource.uri}`,
808
+ resourceUri: resource.uri,
809
+ });
810
+ }
811
+ }
812
+ state.toolMetadata.set(name, metadata);
813
+
814
+ if (ctx.hasUI) {
815
+ ctx.ui.notify(
816
+ `MCP: Reconnected to ${name} (${connection.tools.length} tools, ${connection.resources.length} resources)`,
817
+ "info"
818
+ );
819
+ if (failedTools.length > 0) {
820
+ ctx.ui.notify(`MCP: ${name} - ${failedTools.length} tools skipped`, "warning");
821
+ }
822
+ }
823
+ } catch (error) {
824
+ const message = error instanceof Error ? error.message : String(error);
825
+ if (ctx.hasUI) {
826
+ ctx.ui.notify(`MCP: Failed to reconnect to ${name}: ${message}`, "error");
827
+ }
828
+ }
829
+ }
830
+
831
+ // Update status bar with new tool count
832
+ if (ctx.hasUI) {
833
+ const totalTools = [...state.registeredTools.values()].flat().length;
834
+ if (totalTools > 0) {
835
+ ctx.ui.setStatus("mcp", ctx.ui.theme.fg("accent", `MCP: ${totalTools} tools`));
836
+ } else {
837
+ ctx.ui.setStatus("mcp", "");
838
+ }
839
+ }
840
+ }
841
+
842
+ async function authenticateServer(
843
+ serverName: string,
844
+ config: McpConfig,
845
+ ctx: ExtensionContext
846
+ ): Promise<void> {
847
+ if (!ctx.hasUI) return;
848
+
849
+ const definition = config.mcpServers[serverName];
850
+ if (!definition) {
851
+ ctx.ui.notify(`Server "${serverName}" not found in config`, "error");
852
+ return;
853
+ }
854
+
855
+ if (definition.auth !== "oauth") {
856
+ ctx.ui.notify(
857
+ `Server "${serverName}" does not use OAuth authentication.\n` +
858
+ `Current auth mode: ${definition.auth ?? "none"}`,
859
+ "error"
860
+ );
861
+ return;
862
+ }
863
+
864
+ if (!definition.url) {
865
+ ctx.ui.notify(
866
+ `Server "${serverName}" has no URL configured (OAuth requires HTTP transport)`,
867
+ "error"
868
+ );
869
+ return;
870
+ }
871
+
872
+ // Show instructions for obtaining OAuth tokens
873
+ const tokenPath = `~/.pi/agent/mcp-oauth/${serverName}/tokens.json`;
874
+
875
+ ctx.ui.notify(
876
+ `OAuth setup for "${serverName}":\n\n` +
877
+ `1. Obtain an access token from your OAuth provider\n` +
878
+ `2. Create the token file:\n` +
879
+ ` ${tokenPath}\n\n` +
880
+ `3. Add your token:\n` +
881
+ ` {\n` +
882
+ ` "access_token": "your-token-here",\n` +
883
+ ` "token_type": "bearer"\n` +
884
+ ` }\n\n` +
885
+ `4. Run /mcp reconnect to connect with the token`,
886
+ "info"
887
+ );
888
+ }
889
+
890
+ /**
891
+ * Truncate text at word boundary, aiming for target length.
892
+ */
893
+ function truncateAtWord(text: string, target: number): string {
894
+ if (!text || text.length <= target) return text;
895
+
896
+ // Find last space before or at target
897
+ const truncated = text.slice(0, target);
898
+ const lastSpace = truncated.lastIndexOf(" ");
899
+
900
+ if (lastSpace > target * 0.6) {
901
+ // Found a reasonable break point
902
+ return truncated.slice(0, lastSpace) + "...";
903
+ }
904
+
905
+ // No good break point, just cut at target
906
+ return truncated + "...";
907
+ }