pi-mcp-adapter 1.5.1 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -1,27 +1,48 @@
1
1
  // index.ts - Full extension entry point with commands
2
- import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import type { ExtensionAPI, ExtensionContext, ToolInfo } from "@mariozechner/pi-coding-agent";
3
3
  import { Type } from "@sinclair/typebox";
4
+ import { existsSync } from "node:fs";
4
5
  import { loadMcpConfig } from "./config.js";
5
- import { formatToolName, type McpConfig, type McpContent } from "./types.js";
6
+ import { formatToolName, getServerPrefix, type McpConfig, type McpContent, type ToolMetadata, type McpTool, type McpResource, type ServerEntry } from "./types.js";
6
7
  import { McpServerManager } from "./server-manager.js";
7
8
  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
- }
9
+ import { transformMcpContent } from "./tool-registrar.js";
10
+ import { resourceNameToToolName } from "./resource-tools.js";
11
+ import {
12
+ computeServerHash,
13
+ getMetadataCachePath,
14
+ isServerCacheValid,
15
+ loadMetadataCache,
16
+ reconstructToolMetadata,
17
+ saveMetadataCache,
18
+ serializeResources,
19
+ serializeTools,
20
+ type ServerCacheEntry,
21
+ } from "./metadata-cache.js";
18
22
 
19
23
  interface McpExtensionState {
20
24
  manager: McpServerManager;
21
25
  lifecycle: McpLifecycleManager;
22
- registeredTools: Map<string, string[]>;
23
26
  toolMetadata: Map<string, ToolMetadata[]>; // server -> tool metadata for searching
24
27
  config: McpConfig;
28
+ failureTracker: Map<string, number>;
29
+ ui?: ExtensionContext["ui"];
30
+ }
31
+
32
+ const FAILURE_BACKOFF_MS = 60 * 1000;
33
+
34
+ /**
35
+ * Find a tool by name with hyphen/underscore normalization fallback.
36
+ * MCP tools often use hyphens (resolve-library-id) but the prefix separator
37
+ * is underscore, so LLMs naturally guess all-underscores. Try exact match
38
+ * first, then normalized match.
39
+ */
40
+ function findToolByName(metadata: ToolMetadata[] | undefined, toolName: string): ToolMetadata | undefined {
41
+ if (!metadata) return undefined;
42
+ const exact = metadata.find(m => m.name === toolName);
43
+ if (exact) return exact;
44
+ const normalized = toolName.replace(/-/g, "_");
45
+ return metadata.find(m => m.name.replace(/-/g, "_") === normalized);
25
46
  }
26
47
 
27
48
  /** Run async tasks with concurrency limit */
@@ -49,6 +70,9 @@ export default function mcpAdapter(pi: ExtensionAPI) {
49
70
  let state: McpExtensionState | null = null;
50
71
  let initPromise: Promise<McpExtensionState> | null = null;
51
72
 
73
+ // Capture pi tool accessor (closure) for unified search
74
+ const getPiTools = (): ToolInfo[] => pi.getAllTools();
75
+
52
76
  pi.registerFlag("mcp-config", {
53
77
  description: "Path to MCP config file",
54
78
  type: "string",
@@ -61,24 +85,7 @@ export default function mcpAdapter(pi: ExtensionAPI) {
61
85
  initPromise.then(s => {
62
86
  state = s;
63
87
  initPromise = null;
64
-
65
- // Set up callback for auto-reconnect to update metadata
66
- s.lifecycle.setReconnectCallback((serverName) => {
67
- if (state) {
68
- updateServerMetadata(state, serverName);
69
- }
70
- });
71
-
72
- // Update status bar when ready
73
- if (ctx.hasUI) {
74
- const serverCount = s.registeredTools.size;
75
- if (serverCount > 0) {
76
- const label = serverCount === 1 ? "server" : "servers";
77
- ctx.ui.setStatus("mcp", ctx.ui.theme.fg("accent", `MCP: ${serverCount} ${label}`));
78
- } else {
79
- ctx.ui.setStatus("mcp", "");
80
- }
81
- }
88
+ updateStatusBar(s);
82
89
  }).catch(err => {
83
90
  console.error("MCP initialization failed:", err);
84
91
  initPromise = null;
@@ -95,6 +102,7 @@ export default function mcpAdapter(pi: ExtensionAPI) {
95
102
  }
96
103
 
97
104
  if (state) {
105
+ flushMetadataCache(state);
98
106
  await state.lifecycle.gracefulShutdown();
99
107
  state = null;
100
108
  }
@@ -118,11 +126,13 @@ export default function mcpAdapter(pi: ExtensionAPI) {
118
126
  return;
119
127
  }
120
128
 
121
- const subcommand = args?.trim()?.split(/\s+/)?.[0] ?? "";
129
+ const parts = args?.trim()?.split(/\s+/) ?? [];
130
+ const subcommand = parts[0] ?? "";
131
+ const targetServer = parts[1];
122
132
 
123
133
  switch (subcommand) {
124
134
  case "reconnect":
125
- await reconnectServers(state, ctx);
135
+ await reconnectServers(state, ctx, targetServer);
126
136
  break;
127
137
  case "tools":
128
138
  await showTools(state, ctx);
@@ -173,15 +183,17 @@ export default function mcpAdapter(pi: ExtensionAPI) {
173
183
  Usage:
174
184
  mcp({ }) → Show server status
175
185
  mcp({ server: "name" }) → List tools from server
176
- mcp({ search: "query" }) → Search for tools (includes schemas, space-separated words OR'd)
186
+ mcp({ search: "query" }) → Search for tools (MCP + pi, space-separated words OR'd)
177
187
  mcp({ describe: "tool_name" }) → Show tool details and parameters
188
+ mcp({ connect: "server-name" }) → Connect to a server and refresh metadata
178
189
  mcp({ tool: "name", args: '{"key": "value"}' }) → Call a tool (args is JSON string)
179
190
 
180
- Mode: tool (call) > describe > search > server (list) > nothing (status)`,
191
+ Mode: tool (call) > connect > describe > search > server (list) > nothing (status)`,
181
192
  parameters: Type.Object({
182
193
  // Call mode
183
194
  tool: Type.Optional(Type.String({ description: "Tool name to call (e.g., 'xcodebuild_list_sims')" })),
184
195
  args: Type.Optional(Type.String({ description: "Arguments as JSON string (e.g., '{\"key\": \"value\"}')" })),
196
+ connect: Type.Optional(Type.String({ description: "Server name to connect (lazy connect + metadata refresh)" })),
185
197
  // Describe mode
186
198
  describe: Type.Optional(Type.String({ description: "Tool name to describe (shows parameters)" })),
187
199
  // Search mode
@@ -189,17 +201,18 @@ Mode: tool (call) > describe > search > server (list) > nothing (status)`,
189
201
  regex: Type.Optional(Type.Boolean({ description: "Treat search as regex (default: substring match)" })),
190
202
  includeSchemas: Type.Optional(Type.Boolean({ description: "Include parameter schemas in search results (default: true)" })),
191
203
  // Filter (works with search or list)
192
- server: Type.Optional(Type.String({ description: "Filter to specific server" })),
204
+ server: Type.Optional(Type.String({ description: "Filter to specific server (also disambiguates tool calls)" })),
193
205
  }),
194
206
  async execute(_toolCallId, params: {
195
207
  tool?: string;
196
208
  args?: string;
209
+ connect?: string;
197
210
  describe?: string;
198
211
  search?: string;
199
212
  regex?: boolean;
200
213
  includeSchemas?: boolean;
201
214
  server?: string;
202
- }) {
215
+ }, _signal, _onUpdate, _ctx) {
203
216
  // Parse args from JSON string if provided
204
217
  let parsedArgs: Record<string, unknown> | undefined;
205
218
  if (params.args) {
@@ -240,15 +253,18 @@ Mode: tool (call) > describe > search > server (list) > nothing (status)`,
240
253
  };
241
254
  }
242
255
 
243
- // Mode resolution: tool > describe > search > server > status
256
+ // Mode resolution: tool > connect > describe > search > server > status
244
257
  if (params.tool) {
245
- return executeCall(state, params.tool, parsedArgs);
258
+ return executeCall(state, params.tool, parsedArgs, params.server);
259
+ }
260
+ if (params.connect) {
261
+ return executeConnect(state, params.connect);
246
262
  }
247
263
  if (params.describe) {
248
264
  return executeDescribe(state, params.describe);
249
265
  }
250
266
  if (params.search) {
251
- return executeSearch(state, params.search, params.regex, params.server, params.includeSchemas);
267
+ return executeSearch(state, params.search, params.regex, params.server, params.includeSchemas, getPiTools);
252
268
  }
253
269
  if (params.server) {
254
270
  return executeList(state, params.server);
@@ -262,30 +278,48 @@ Mode: tool (call) > describe > search > server (list) > nothing (status)`,
262
278
 
263
279
  function executeStatus(state: McpExtensionState) {
264
280
  const servers: Array<{ name: string; status: string; toolCount: number }> = [];
265
-
281
+
266
282
  for (const name of Object.keys(state.config.mcpServers)) {
267
283
  const connection = state.manager.getConnection(name);
268
- const toolNames = state.registeredTools.get(name) ?? [];
269
- servers.push({
270
- name,
271
- status: connection?.status ?? "not connected",
272
- toolCount: toolNames.length,
273
- });
284
+ const toolCount = getToolNames(state, name).length;
285
+ const failedAgo = getFailureAgeSeconds(state, name);
286
+ let status = "not connected";
287
+ if (connection?.status === "connected") {
288
+ status = "connected";
289
+ } else if (failedAgo !== null) {
290
+ status = "failed";
291
+ } else if (state.toolMetadata.has(name)) {
292
+ status = "cached";
293
+ }
294
+
295
+ servers.push({ name, status, toolCount });
274
296
  }
275
-
297
+
276
298
  const totalTools = servers.reduce((sum, s) => sum + s.toolCount, 0);
277
299
  const connectedCount = servers.filter(s => s.status === "connected").length;
278
-
300
+
279
301
  let text = `MCP: ${connectedCount}/${servers.length} servers, ${totalTools} tools\n\n`;
280
302
  for (const server of servers) {
281
- const icon = server.status === "connected" ? "✓" : "○";
282
- text += `${icon} ${server.name} (${server.toolCount} tools)\n`;
303
+ if (server.status === "connected") {
304
+ text += `✓ ${server.name} (${server.toolCount} tools)\n`;
305
+ continue;
306
+ }
307
+ if (server.status === "cached") {
308
+ text += `○ ${server.name} (${server.toolCount} tools, cached)\n`;
309
+ continue;
310
+ }
311
+ if (server.status === "failed") {
312
+ const failedAgo = getFailureAgeSeconds(state, server.name) ?? 0;
313
+ text += `✗ ${server.name} (failed ${failedAgo}s ago)\n`;
314
+ continue;
315
+ }
316
+ text += `○ ${server.name} (not connected)\n`;
283
317
  }
284
-
318
+
285
319
  if (servers.length > 0) {
286
320
  text += `\nmcp({ server: "name" }) to list tools, mcp({ search: "..." }) to search`;
287
321
  }
288
-
322
+
289
323
  return {
290
324
  content: [{ type: "text" as const, text: text.trim() }],
291
325
  details: { mode: "status", servers, totalTools, connectedCount },
@@ -298,7 +332,7 @@ function executeDescribe(state: McpExtensionState, toolName: string) {
298
332
  let toolMeta: ToolMetadata | undefined;
299
333
 
300
334
  for (const [server, metadata] of state.toolMetadata.entries()) {
301
- const found = metadata.find(m => m.name === toolName);
335
+ const found = findToolByName(metadata, toolName);
302
336
  if (found) {
303
337
  serverName = server;
304
338
  toolMeta = found;
@@ -425,7 +459,8 @@ function executeSearch(
425
459
  query: string,
426
460
  regex?: boolean,
427
461
  server?: string,
428
- includeSchemas?: boolean
462
+ includeSchemas?: boolean,
463
+ getPiTools?: () => ToolInfo[]
429
464
  ) {
430
465
  // Default to including schemas
431
466
  const showSchemas = includeSchemas !== false;
@@ -455,6 +490,24 @@ function executeSearch(
455
490
  };
456
491
  }
457
492
 
493
+ // Search pi tools (unless server filter is specified)
494
+ const piMatches: Array<{ name: string; description: string }> = [];
495
+ if (!server && getPiTools) {
496
+ const piTools = getPiTools();
497
+ for (const tool of piTools) {
498
+ // Skip the mcp tool itself to avoid confusion
499
+ if (tool.name === "mcp") continue;
500
+
501
+ if (pattern.test(tool.name) || pattern.test(tool.description ?? "")) {
502
+ piMatches.push({
503
+ name: tool.name,
504
+ description: tool.description ?? "",
505
+ });
506
+ }
507
+ }
508
+ }
509
+
510
+ // Search MCP tools (existing logic)
458
511
  for (const [serverName, metadata] of state.toolMetadata.entries()) {
459
512
  if (server && serverName !== server) continue;
460
513
  for (const tool of metadata) {
@@ -467,7 +520,10 @@ function executeSearch(
467
520
  }
468
521
  }
469
522
 
470
- if (matches.length === 0) {
523
+ // Combine counts
524
+ const totalCount = piMatches.length + matches.length;
525
+
526
+ if (totalCount === 0) {
471
527
  const msg = server
472
528
  ? `No tools matching "${query}" in "${server}"`
473
529
  : `No tools matching "${query}"`;
@@ -477,8 +533,27 @@ function executeSearch(
477
533
  };
478
534
  }
479
535
 
480
- let text = `Found ${matches.length} tool${matches.length === 1 ? "" : "s"} matching "${query}":\n\n`;
536
+ let text = `Found ${totalCount} tool${totalCount === 1 ? "" : "s"} matching "${query}":\n\n`;
537
+
538
+ // Pi tools first (with [pi tool] prefix)
539
+ for (const match of piMatches) {
540
+ if (showSchemas) {
541
+ // Full format (consistent with MCP tools)
542
+ text += `[pi tool] ${match.name}\n`;
543
+ text += ` ${match.description || "(no description)"}\n`;
544
+ text += ` No parameters (call directly).\n`;
545
+ text += "\n";
546
+ } else {
547
+ // Compact format
548
+ text += `[pi tool] ${match.name}`;
549
+ if (match.description) {
550
+ text += ` - ${truncateAtWord(match.description, 50)}`;
551
+ }
552
+ text += "\n";
553
+ }
554
+ }
481
555
 
556
+ // MCP tools (existing format, no prefix change for backwards compat)
482
557
  for (const match of matches) {
483
558
  if (showSchemas) {
484
559
  // Full format with schema
@@ -502,38 +577,53 @@ function executeSearch(
502
577
 
503
578
  return {
504
579
  content: [{ type: "text" as const, text: text.trim() }],
505
- details: { mode: "search", matches: matches.map(m => ({ server: m.server, tool: m.tool.name })), count: matches.length, query },
580
+ details: {
581
+ mode: "search",
582
+ matches: [
583
+ ...piMatches.map(m => ({ server: "pi", tool: m.name })),
584
+ ...matches.map(m => ({ server: m.server, tool: m.tool.name })),
585
+ ],
586
+ count: totalCount,
587
+ query,
588
+ },
506
589
  };
507
590
  }
508
591
 
509
592
  function executeList(state: McpExtensionState, server: string) {
510
- const toolNames = state.registeredTools.get(server);
593
+ if (!state.config.mcpServers[server]) {
594
+ return {
595
+ content: [{ type: "text" as const, text: `Server "${server}" not found. Use mcp({}) to see available servers.` }],
596
+ details: { mode: "list", server, tools: [], count: 0, error: "not_found" },
597
+ };
598
+ }
599
+
511
600
  const metadata = state.toolMetadata.get(server);
512
-
513
- if (!toolNames || toolNames.length === 0) {
514
- // Server exists in registeredTools (even if empty) means it connected
515
- if (state.registeredTools.has(server)) {
601
+ const toolNames = getToolNames(state, server);
602
+ const hasMetadata = state.toolMetadata.has(server);
603
+ const connection = state.manager.getConnection(server);
604
+
605
+ if (toolNames.length === 0) {
606
+ if (connection?.status === "connected") {
516
607
  return {
517
608
  content: [{ type: "text" as const, text: `Server "${server}" has no tools.` }],
518
609
  details: { mode: "list", server, tools: [], count: 0 },
519
610
  };
520
611
  }
521
- // Server in config but not in registeredTools means connection failed
522
- if (state.config.mcpServers[server]) {
612
+ if (hasMetadata) {
523
613
  return {
524
- content: [{ type: "text" as const, text: `Server "${server}" is configured but not connected. Use /mcp reconnect to retry.` }],
525
- details: { mode: "list", server, tools: [], count: 0, error: "not_connected" },
614
+ content: [{ type: "text" as const, text: `Server "${server}" has no cached tools (not connected).` }],
615
+ details: { mode: "list", server, tools: [], count: 0, cached: true },
526
616
  };
527
617
  }
528
- // Server not in config at all
529
618
  return {
530
- content: [{ type: "text" as const, text: `Server "${server}" not found. Use mcp({}) to see available servers.` }],
531
- details: { mode: "list", server, tools: [], count: 0, error: "not_found" },
619
+ content: [{ type: "text" as const, text: `Server "${server}" is configured but not connected. Use mcp({ connect: "${server}" }) or /mcp reconnect ${server} to retry.` }],
620
+ details: { mode: "list", server, tools: [], count: 0, error: "not_connected" },
532
621
  };
533
622
  }
534
-
535
- let text = `${server} (${toolNames.length} tools):\n\n`;
536
-
623
+
624
+ const cachedNote = connection?.status === "connected" ? "" : " (not connected, cached)";
625
+ let text = `${server} (${toolNames.length} tools${cachedNote}):\n\n`;
626
+
537
627
  // Build a map of tool name -> description for quick lookup
538
628
  const descMap = new Map<string, string>();
539
629
  if (metadata) {
@@ -541,7 +631,7 @@ function executeList(state: McpExtensionState, server: string) {
541
631
  descMap.set(m.name, m.description);
542
632
  }
543
633
  }
544
-
634
+
545
635
  for (const tool of toolNames) {
546
636
  const desc = descMap.get(tool) ?? "";
547
637
  const truncated = truncateAtWord(desc, 50);
@@ -549,47 +639,181 @@ function executeList(state: McpExtensionState, server: string) {
549
639
  if (truncated) text += ` - ${truncated}`;
550
640
  text += "\n";
551
641
  }
552
-
642
+
553
643
  return {
554
644
  content: [{ type: "text" as const, text: text.trim() }],
555
645
  details: { mode: "list", server, tools: toolNames, count: toolNames.length },
556
646
  };
557
647
  }
558
648
 
649
+ async function executeConnect(state: McpExtensionState, serverName: string) {
650
+ const definition = state.config.mcpServers[serverName];
651
+ if (!definition) {
652
+ return {
653
+ content: [{ type: "text" as const, text: `Server "${serverName}" not found. Use mcp({}) to see available servers.` }],
654
+ details: { mode: "connect", error: "not_found", server: serverName },
655
+ };
656
+ }
657
+
658
+ try {
659
+ if (state.ui) {
660
+ state.ui.setStatus("mcp", `MCP: connecting to ${serverName}...`);
661
+ }
662
+ const connection = await state.manager.connect(serverName, definition);
663
+ const prefix = state.config.settings?.toolPrefix ?? "server";
664
+ const { metadata } = buildToolMetadata(connection.tools, connection.resources, definition, serverName, prefix);
665
+ state.toolMetadata.set(serverName, metadata);
666
+ updateMetadataCache(state, serverName);
667
+ state.failureTracker.delete(serverName);
668
+ updateStatusBar(state);
669
+ return executeList(state, serverName);
670
+ } catch (error) {
671
+ state.failureTracker.set(serverName, Date.now());
672
+ updateStatusBar(state);
673
+ const message = error instanceof Error ? error.message : String(error);
674
+ return {
675
+ content: [{ type: "text" as const, text: `Failed to connect to "${serverName}": ${message}` }],
676
+ details: { mode: "connect", error: "connect_failed", server: serverName, message },
677
+ };
678
+ }
679
+ }
680
+
559
681
  async function executeCall(
560
682
  state: McpExtensionState,
561
683
  toolName: string,
562
- args?: Record<string, unknown>
684
+ args?: Record<string, unknown>,
685
+ serverOverride?: string
563
686
  ) {
564
687
  // Find the tool in metadata
565
- let serverName: string | undefined;
688
+ let serverName: string | undefined = serverOverride;
566
689
  let toolMeta: ToolMetadata | undefined;
567
-
568
- for (const [server, metadata] of state.toolMetadata.entries()) {
569
- const found = metadata.find(m => m.name === toolName);
570
- if (found) {
571
- serverName = server;
572
- toolMeta = found;
573
- break;
690
+ const prefixMode = state.config.settings?.toolPrefix ?? "server";
691
+
692
+ if (serverName && !state.config.mcpServers[serverName]) {
693
+ return {
694
+ content: [{ type: "text" as const, text: `Server "${serverName}" not found. Use mcp({}) to see available servers.` }],
695
+ details: { mode: "call", error: "server_not_found", server: serverName },
696
+ };
697
+ }
698
+
699
+ if (serverName) {
700
+ toolMeta = findToolByName(state.toolMetadata.get(serverName), toolName);
701
+ } else {
702
+ for (const [server, metadata] of state.toolMetadata.entries()) {
703
+ const found = findToolByName(metadata, toolName);
704
+ if (found) {
705
+ serverName = server;
706
+ toolMeta = found;
707
+ break;
708
+ }
574
709
  }
575
710
  }
576
-
711
+
712
+ if (serverName && !toolMeta) {
713
+ const connected = await lazyConnect(state, serverName);
714
+ if (connected) {
715
+ toolMeta = findToolByName(state.toolMetadata.get(serverName), toolName);
716
+ } else {
717
+ const failedAgo = getFailureAgeSeconds(state, serverName);
718
+ if (failedAgo !== null) {
719
+ return {
720
+ content: [{ type: "text" as const, text: `Server "${serverName}" not available (last failed ${failedAgo}s ago)` }],
721
+ details: { mode: "call", error: "server_backoff", server: serverName },
722
+ };
723
+ }
724
+ }
725
+ }
726
+
727
+ let prefixMatchedServer: string | undefined;
728
+
729
+ if (!serverName && !toolMeta && prefixMode !== "none") {
730
+ const candidates = Object.keys(state.config.mcpServers)
731
+ .map(name => ({ name, prefix: getServerPrefix(name, prefixMode) }))
732
+ .filter(c => c.prefix && toolName.startsWith(c.prefix + "_"))
733
+ .sort((a, b) => b.prefix.length - a.prefix.length);
734
+
735
+ for (const { name: configuredServer } of candidates) {
736
+ const failedAgo = getFailureAgeSeconds(state, configuredServer);
737
+ if (failedAgo !== null) continue;
738
+ const connected = await lazyConnect(state, configuredServer);
739
+ if (!connected) continue;
740
+ if (!prefixMatchedServer) prefixMatchedServer = configuredServer;
741
+ toolMeta = findToolByName(state.toolMetadata.get(configuredServer), toolName);
742
+ if (toolMeta) {
743
+ serverName = configuredServer;
744
+ break;
745
+ }
746
+ }
747
+ }
748
+
577
749
  if (!serverName || !toolMeta) {
750
+ const hintServer = serverName ?? prefixMatchedServer;
751
+ const available = hintServer ? getToolNames(state, hintServer) : [];
752
+ let msg = `Tool "${toolName}" not found.`;
753
+ if (available.length > 0) {
754
+ msg += ` Server "${hintServer}" has: ${available.join(", ")}`;
755
+ } else {
756
+ msg += ` Use mcp({ search: "..." }) to search.`;
757
+ }
578
758
  return {
579
- content: [{ type: "text" as const, text: `Tool "${toolName}" not found. Use mcp({ search: "..." }) to search.` }],
580
- details: { mode: "call", error: "tool_not_found", requestedTool: toolName },
759
+ content: [{ type: "text" as const, text: msg }],
760
+ details: { mode: "call", error: "tool_not_found", requestedTool: toolName, hintServer },
581
761
  };
582
762
  }
583
-
584
- const connection = state.manager.getConnection(serverName);
763
+
764
+ let connection = state.manager.getConnection(serverName);
585
765
  if (!connection || connection.status !== "connected") {
586
- return {
587
- content: [{ type: "text" as const, text: `Server "${serverName}" not connected` }],
588
- details: { mode: "call", error: "server_not_connected", server: serverName },
589
- };
766
+ const failedAgo = getFailureAgeSeconds(state, serverName);
767
+ if (failedAgo !== null) {
768
+ return {
769
+ content: [{ type: "text" as const, text: `Server "${serverName}" not available (last failed ${failedAgo}s ago)` }],
770
+ details: { mode: "call", error: "server_backoff", server: serverName },
771
+ };
772
+ }
773
+
774
+ const definition = state.config.mcpServers[serverName];
775
+ if (!definition) {
776
+ return {
777
+ content: [{ type: "text" as const, text: `Server "${serverName}" not connected` }],
778
+ details: { mode: "call", error: "server_not_connected", server: serverName },
779
+ };
780
+ }
781
+
782
+ try {
783
+ if (state.ui) {
784
+ state.ui.setStatus("mcp", `MCP: connecting to ${serverName}...`);
785
+ }
786
+ connection = await state.manager.connect(serverName, definition);
787
+ state.failureTracker.delete(serverName);
788
+ updateServerMetadata(state, serverName);
789
+ updateMetadataCache(state, serverName);
790
+ updateStatusBar(state);
791
+ toolMeta = findToolByName(state.toolMetadata.get(serverName), toolName);
792
+ if (!toolMeta) {
793
+ const available = getToolNames(state, serverName);
794
+ const hint = available.length > 0
795
+ ? `Available tools on "${serverName}": ${available.join(", ")}`
796
+ : `Server "${serverName}" has no tools.`;
797
+ return {
798
+ content: [{ type: "text" as const, text: `Tool "${toolName}" not found on "${serverName}" after reconnect. ${hint}` }],
799
+ details: { mode: "call", error: "tool_not_found_after_reconnect", requestedTool: toolName },
800
+ };
801
+ }
802
+ } catch (error) {
803
+ state.failureTracker.set(serverName, Date.now());
804
+ updateStatusBar(state);
805
+ const message = error instanceof Error ? error.message : String(error);
806
+ return {
807
+ content: [{ type: "text" as const, text: `Failed to connect to "${serverName}": ${message}` }],
808
+ details: { mode: "call", error: "connect_failed", message },
809
+ };
810
+ }
590
811
  }
591
-
812
+
592
813
  try {
814
+ state.manager.touch(serverName);
815
+ state.manager.incrementInFlight(serverName);
816
+
593
817
  // Resource tools use readResource, regular tools use callTool
594
818
  if (toolMeta.resourceUri) {
595
819
  const result = await connection.client.readResource({ uri: toolMeta.resourceUri });
@@ -602,51 +826,54 @@ async function executeCall(
602
826
  details: { mode: "call", resourceUri: toolMeta.resourceUri, server: serverName },
603
827
  };
604
828
  }
605
-
829
+
606
830
  // Regular tool call
607
831
  const result = await connection.client.callTool({
608
832
  name: toolMeta.originalName,
609
833
  arguments: args ?? {},
610
834
  });
611
-
835
+
612
836
  const mcpContent = (result.content ?? []) as McpContent[];
613
837
  const content = transformMcpContent(mcpContent);
614
-
838
+
615
839
  if (result.isError) {
616
840
  const errorText = content
617
841
  .filter((c) => c.type === "text")
618
842
  .map((c) => (c as { text: string }).text)
619
843
  .join("\n") || "Tool execution failed";
620
-
844
+
621
845
  // Include schema in error to help LLM self-correct
622
846
  let errorWithSchema = `Error: ${errorText}`;
623
847
  if (toolMeta.inputSchema) {
624
848
  errorWithSchema += `\n\nExpected parameters:\n${formatSchema(toolMeta.inputSchema)}`;
625
849
  }
626
-
850
+
627
851
  return {
628
852
  content: [{ type: "text" as const, text: errorWithSchema }],
629
853
  details: { mode: "call", error: "tool_error", mcpResult: result },
630
854
  };
631
855
  }
632
-
856
+
633
857
  return {
634
858
  content: content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }],
635
859
  details: { mode: "call", mcpResult: result, server: serverName, tool: toolMeta.originalName },
636
860
  };
637
861
  } catch (error) {
638
862
  const message = error instanceof Error ? error.message : String(error);
639
-
863
+
640
864
  // Include schema in error to help LLM self-correct
641
865
  let errorWithSchema = `Failed to call tool: ${message}`;
642
866
  if (toolMeta.inputSchema) {
643
867
  errorWithSchema += `\n\nExpected parameters:\n${formatSchema(toolMeta.inputSchema)}`;
644
868
  }
645
-
869
+
646
870
  return {
647
871
  content: [{ type: "text" as const, text: errorWithSchema }],
648
872
  details: { mode: "call", error: "call_failed", message },
649
873
  };
874
+ } finally {
875
+ state.manager.decrementInFlight(serverName);
876
+ state.manager.touch(serverName);
650
877
  }
651
878
  }
652
879
 
@@ -659,20 +886,66 @@ async function initializeMcp(
659
886
 
660
887
  const manager = new McpServerManager();
661
888
  const lifecycle = new McpLifecycleManager(manager);
662
- const registeredTools = new Map<string, string[]>();
663
889
  const toolMetadata = new Map<string, ToolMetadata[]>();
890
+ const failureTracker = new Map<string, number>();
891
+ const ui = ctx.hasUI ? ctx.ui : undefined;
892
+ const state: McpExtensionState = { manager, lifecycle, toolMetadata, config, failureTracker, ui };
664
893
 
665
894
  const serverEntries = Object.entries(config.mcpServers);
666
895
  if (serverEntries.length === 0) {
667
- return { manager, lifecycle, registeredTools, toolMetadata, config };
896
+ return state;
668
897
  }
669
-
670
- if (ctx.hasUI) {
671
- ctx.ui.setStatus("mcp", `Connecting to ${serverEntries.length} servers...`);
898
+
899
+ const idleSetting = typeof config.settings?.idleTimeout === "number" ? config.settings.idleTimeout : 10;
900
+ lifecycle.setGlobalIdleTimeout(idleSetting);
901
+
902
+ const cachePath = getMetadataCachePath();
903
+ const cacheFileExists = existsSync(cachePath);
904
+ let cache = loadMetadataCache();
905
+ let bootstrapAll = false;
906
+
907
+ if (!cacheFileExists) {
908
+ bootstrapAll = true;
909
+ saveMetadataCache({ version: 1, servers: {} });
910
+ } else if (!cache) {
911
+ cache = { version: 1, servers: {} };
912
+ saveMetadataCache(cache);
672
913
  }
673
-
674
- // Connect to all servers in parallel (max 10 concurrent)
675
- const results = await parallelLimit(serverEntries, 10, async ([name, definition]) => {
914
+
915
+ const prefix = config.settings?.toolPrefix ?? "server";
916
+
917
+ // Register servers and hydrate metadata from cache if valid
918
+ for (const [name, definition] of serverEntries) {
919
+ const lifecycleMode = definition.lifecycle ?? "lazy";
920
+ const idleOverride = definition.idleTimeout ?? (lifecycleMode === "eager" ? 0 : undefined);
921
+ lifecycle.registerServer(
922
+ name,
923
+ definition,
924
+ idleOverride !== undefined ? { idleTimeout: idleOverride } : undefined
925
+ );
926
+ if (lifecycleMode === "keep-alive") {
927
+ lifecycle.markKeepAlive(name, definition);
928
+ }
929
+
930
+ if (cache?.servers?.[name] && isServerCacheValid(cache.servers[name], definition)) {
931
+ const metadata = reconstructToolMetadata(name, cache.servers[name], prefix, definition.exposeResources);
932
+ toolMetadata.set(name, metadata);
933
+ }
934
+ }
935
+
936
+ const startupServers = bootstrapAll
937
+ ? serverEntries
938
+ : serverEntries.filter(([, definition]) => {
939
+ const mode = definition.lifecycle ?? "lazy";
940
+ return mode === "keep-alive" || mode === "eager";
941
+ });
942
+
943
+ if (ctx.hasUI && startupServers.length > 0) {
944
+ ctx.ui.setStatus("mcp", `MCP: connecting to ${startupServers.length} servers...`);
945
+ }
946
+
947
+ // Connect selected servers in parallel (max 10 concurrent)
948
+ const results = await parallelLimit(startupServers, 10, async ([name, definition]) => {
676
949
  try {
677
950
  const connection = await manager.connect(name, definition);
678
951
  return { name, definition, connection, error: null };
@@ -681,8 +954,7 @@ async function initializeMcp(
681
954
  return { name, definition, connection: null, error: message };
682
955
  }
683
956
  });
684
- const prefix = config.settings?.toolPrefix ?? "server";
685
-
957
+
686
958
  // Process results
687
959
  for (const { name, definition, connection, error } of results) {
688
960
  if (error || !connection) {
@@ -692,50 +964,11 @@ async function initializeMcp(
692
964
  console.error(`MCP: Failed to connect to ${name}: ${error}`);
693
965
  continue;
694
966
  }
695
-
696
- // Collect tool names (NOT registered with Pi - only mcp proxy is registered)
697
- const { collected: toolNames, failed: failedTools } = collectToolNames(
698
- connection.tools,
699
- { serverName: name, prefix }
700
- );
701
-
702
- // Collect resource tool names (if enabled)
703
- if (definition.exposeResources !== false && connection.resources.length > 0) {
704
- const resourceToolNames = collectResourceToolNames(
705
- connection.resources,
706
- { serverName: name, prefix }
707
- );
708
- toolNames.push(...resourceToolNames);
709
- }
710
-
711
- registeredTools.set(name, toolNames);
712
-
713
- // Build tool metadata for searching (include inputSchema for describe/errors)
714
- const metadata: ToolMetadata[] = connection.tools.map(tool => ({
715
- name: formatToolName(tool.name, name, prefix),
716
- originalName: tool.name,
717
- description: tool.description ?? "",
718
- inputSchema: tool.inputSchema,
719
- }));
720
- // Add resource tools to metadata
721
- for (const resource of connection.resources) {
722
- if (definition.exposeResources !== false) {
723
- const baseName = `get_${resourceNameToToolName(resource.name)}`;
724
- metadata.push({
725
- name: formatToolName(baseName, name, prefix),
726
- originalName: baseName,
727
- description: resource.description ?? `Read resource: ${resource.uri}`,
728
- resourceUri: resource.uri,
729
- });
730
- }
731
- }
967
+
968
+ const { metadata, failedTools } = buildToolMetadata(connection.tools, connection.resources, definition, name, prefix);
732
969
  toolMetadata.set(name, metadata);
733
-
734
- // Mark keep-alive servers
735
- if (definition.lifecycle === "keep-alive") {
736
- lifecycle.markKeepAlive(name, definition);
737
- }
738
-
970
+ updateMetadataCache(state, name);
971
+
739
972
  if (failedTools.length > 0 && ctx.hasUI) {
740
973
  ctx.ui.notify(
741
974
  `MCP: ${name} - ${failedTools.length} tools skipped`,
@@ -743,22 +976,34 @@ async function initializeMcp(
743
976
  );
744
977
  }
745
978
  }
746
-
979
+
747
980
  // Summary notification
748
981
  const connectedCount = results.filter(r => r.connection).length;
749
982
  const failedCount = results.filter(r => r.error).length;
750
983
  if (ctx.hasUI && connectedCount > 0) {
751
- const totalTools = [...registeredTools.values()].flat().length;
752
- const msg = failedCount > 0
753
- ? `MCP: ${connectedCount}/${serverEntries.length} servers connected (${totalTools} tools)`
984
+ const totalTools = totalToolCount(state);
985
+ const msg = failedCount > 0
986
+ ? `MCP: ${connectedCount}/${startupServers.length} servers connected (${totalTools} tools)`
754
987
  : `MCP: ${connectedCount} servers connected (${totalTools} tools)`;
755
988
  ctx.ui.notify(msg, "info");
756
989
  }
757
-
758
- // Start health checks for keep-alive servers
990
+
991
+ lifecycle.setReconnectCallback((serverName) => {
992
+ updateServerMetadata(state, serverName);
993
+ updateMetadataCache(state, serverName);
994
+ state.failureTracker.delete(serverName);
995
+ updateStatusBar(state);
996
+ });
997
+
998
+ lifecycle.setIdleShutdownCallback((serverName) => {
999
+ const idleMinutes = getEffectiveIdleTimeoutMinutes(state, serverName);
1000
+ console.log(`MCP: ${serverName} shut down (idle ${idleMinutes}m)`);
1001
+ updateStatusBar(state);
1002
+ });
1003
+
759
1004
  lifecycle.startHealthChecks();
760
-
761
- return { manager, lifecycle, registeredTools, toolMetadata, config };
1005
+
1006
+ return state;
762
1007
  }
763
1008
 
764
1009
  /**
@@ -773,42 +1018,8 @@ function updateServerMetadata(state: McpExtensionState, serverName: string): voi
773
1018
  if (!definition) return;
774
1019
 
775
1020
  const prefix = state.config.settings?.toolPrefix ?? "server";
776
-
777
- // Collect tool names
778
- const { collected: toolNames } = collectToolNames(
779
- connection.tools,
780
- { serverName, prefix }
781
- );
782
-
783
- // Collect resource tool names if enabled
784
- if (definition.exposeResources !== false && connection.resources.length > 0) {
785
- const resourceToolNames = collectResourceToolNames(
786
- connection.resources,
787
- { serverName, prefix }
788
- );
789
- toolNames.push(...resourceToolNames);
790
- }
791
-
792
- state.registeredTools.set(serverName, toolNames);
793
-
794
- // Update tool metadata (include inputSchema for describe/errors)
795
- const metadata: ToolMetadata[] = connection.tools.map(tool => ({
796
- name: formatToolName(tool.name, serverName, prefix),
797
- originalName: tool.name,
798
- description: tool.description ?? "",
799
- inputSchema: tool.inputSchema,
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, serverName, prefix),
806
- originalName: baseName,
807
- description: resource.description ?? `Read resource: ${resource.uri}`,
808
- resourceUri: resource.uri,
809
- });
810
- }
811
- }
1021
+
1022
+ const { metadata } = buildToolMetadata(connection.tools, connection.resources, definition, serverName, prefix);
812
1023
  state.toolMetadata.set(serverName, metadata);
813
1024
  }
814
1025
 
@@ -820,11 +1031,25 @@ async function showStatus(state: McpExtensionState, ctx: ExtensionContext): Prom
820
1031
  // Show all configured servers, not just connected ones
821
1032
  for (const name of Object.keys(state.config.mcpServers)) {
822
1033
  const connection = state.manager.getConnection(name);
823
- const toolNames = state.registeredTools.get(name) ?? [];
824
- const status = connection?.status ?? "not connected";
825
- const statusIcon = status === "connected" ? "✓" : "○";
826
-
827
- lines.push(`${statusIcon} ${name}: ${status} (${toolNames.length} tools)`);
1034
+ const toolCount = getToolNames(state, name).length;
1035
+ const failedAgo = getFailureAgeSeconds(state, name);
1036
+ let status = "not connected";
1037
+ let statusIcon = "○";
1038
+ let failed = false;
1039
+
1040
+ if (connection?.status === "connected") {
1041
+ status = "connected";
1042
+ statusIcon = "✓";
1043
+ } else if (failedAgo !== null) {
1044
+ status = `failed ${failedAgo}s ago`;
1045
+ statusIcon = "✗";
1046
+ failed = true;
1047
+ } else if (state.toolMetadata.has(name)) {
1048
+ status = "cached";
1049
+ }
1050
+
1051
+ const toolSuffix = failed ? "" : ` (${toolCount} tools${status === "cached" ? ", cached" : ""})`;
1052
+ lines.push(`${statusIcon} ${name}: ${status}${toolSuffix}`);
828
1053
  }
829
1054
 
830
1055
  if (Object.keys(state.config.mcpServers).length === 0) {
@@ -837,7 +1062,7 @@ async function showStatus(state: McpExtensionState, ctx: ExtensionContext): Prom
837
1062
  async function showTools(state: McpExtensionState, ctx: ExtensionContext): Promise<void> {
838
1063
  if (!ctx.hasUI) return;
839
1064
 
840
- const allTools = [...state.registeredTools.values()].flat();
1065
+ const allTools = [...state.toolMetadata.values()].flat().map(m => m.name);
841
1066
 
842
1067
  if (allTools.length === 0) {
843
1068
  ctx.ui.notify("No MCP tools available", "info");
@@ -857,56 +1082,32 @@ async function showTools(state: McpExtensionState, ctx: ExtensionContext): Promi
857
1082
 
858
1083
  async function reconnectServers(
859
1084
  state: McpExtensionState,
860
- ctx: ExtensionContext
1085
+ ctx: ExtensionContext,
1086
+ targetServer?: string
861
1087
  ): Promise<void> {
862
- for (const [name, definition] of Object.entries(state.config.mcpServers)) {
1088
+ if (targetServer && !state.config.mcpServers[targetServer]) {
1089
+ if (ctx.hasUI) {
1090
+ ctx.ui.notify(`Server "${targetServer}" not found in config`, "error");
1091
+ }
1092
+ return;
1093
+ }
1094
+
1095
+ const entries = targetServer
1096
+ ? [[targetServer, state.config.mcpServers[targetServer]] as [string, ServerEntry]]
1097
+ : Object.entries(state.config.mcpServers);
1098
+
1099
+ for (const [name, definition] of entries) {
863
1100
  try {
864
1101
  await state.manager.close(name);
865
-
866
- // Clear old entries before reconnecting (in case reconnection fails)
867
- state.registeredTools.delete(name);
868
- state.toolMetadata.delete(name);
869
-
1102
+
870
1103
  const connection = await state.manager.connect(name, definition);
871
1104
  const prefix = state.config.settings?.toolPrefix ?? "server";
872
-
873
- // Collect tool names (NOT registered with Pi)
874
- const { collected: toolNames, failed: failedTools } = collectToolNames(
875
- connection.tools,
876
- { serverName: name, prefix }
877
- );
878
-
879
- // Collect resource tool names if enabled
880
- if (definition.exposeResources !== false && connection.resources.length > 0) {
881
- const resourceToolNames = collectResourceToolNames(
882
- connection.resources,
883
- { serverName: name, prefix }
884
- );
885
- toolNames.push(...resourceToolNames);
886
- }
887
-
888
- state.registeredTools.set(name, toolNames);
889
-
890
- // Update tool metadata for searching (include inputSchema for describe/errors)
891
- const metadata: ToolMetadata[] = connection.tools.map(tool => ({
892
- name: formatToolName(tool.name, name, prefix),
893
- originalName: tool.name,
894
- description: tool.description ?? "",
895
- inputSchema: tool.inputSchema,
896
- }));
897
- for (const resource of connection.resources) {
898
- if (definition.exposeResources !== false) {
899
- const baseName = `get_${resourceNameToToolName(resource.name)}`;
900
- metadata.push({
901
- name: formatToolName(baseName, name, prefix),
902
- originalName: baseName,
903
- description: resource.description ?? `Read resource: ${resource.uri}`,
904
- resourceUri: resource.uri,
905
- });
906
- }
907
- }
1105
+
1106
+ const { metadata, failedTools } = buildToolMetadata(connection.tools, connection.resources, definition, name, prefix);
908
1107
  state.toolMetadata.set(name, metadata);
909
-
1108
+ updateMetadataCache(state, name);
1109
+ state.failureTracker.delete(name);
1110
+
910
1111
  if (ctx.hasUI) {
911
1112
  ctx.ui.notify(
912
1113
  `MCP: Reconnected to ${name} (${connection.tools.length} tools, ${connection.resources.length} resources)`,
@@ -918,6 +1119,7 @@ async function reconnectServers(
918
1119
  }
919
1120
  } catch (error) {
920
1121
  const message = error instanceof Error ? error.message : String(error);
1122
+ state.failureTracker.set(name, Date.now());
921
1123
  if (ctx.hasUI) {
922
1124
  ctx.ui.notify(`MCP: Failed to reconnect to ${name}: ${message}`, "error");
923
1125
  }
@@ -925,14 +1127,158 @@ async function reconnectServers(
925
1127
  }
926
1128
 
927
1129
  // Update status bar with server count
928
- if (ctx.hasUI) {
929
- const serverCount = state.registeredTools.size;
930
- if (serverCount > 0) {
931
- const label = serverCount === 1 ? "server" : "servers";
932
- ctx.ui.setStatus("mcp", ctx.ui.theme.fg("accent", `MCP: ${serverCount} ${label}`));
933
- } else {
934
- ctx.ui.setStatus("mcp", "");
1130
+ updateStatusBar(state);
1131
+ }
1132
+
1133
+ function buildToolMetadata(
1134
+ tools: McpTool[],
1135
+ resources: McpResource[],
1136
+ definition: ServerEntry,
1137
+ serverName: string,
1138
+ prefix: "server" | "none" | "short"
1139
+ ): { metadata: ToolMetadata[]; failedTools: string[] } {
1140
+ const metadata: ToolMetadata[] = [];
1141
+ const failedTools: string[] = [];
1142
+
1143
+ for (const tool of tools) {
1144
+ if (!tool?.name) {
1145
+ failedTools.push("(unnamed)");
1146
+ continue;
1147
+ }
1148
+ metadata.push({
1149
+ name: formatToolName(tool.name, serverName, prefix),
1150
+ originalName: tool.name,
1151
+ description: tool.description ?? "",
1152
+ inputSchema: tool.inputSchema,
1153
+ });
1154
+ }
1155
+
1156
+ if (definition.exposeResources !== false) {
1157
+ for (const resource of resources) {
1158
+ const baseName = `get_${resourceNameToToolName(resource.name)}`;
1159
+ metadata.push({
1160
+ name: formatToolName(baseName, serverName, prefix),
1161
+ originalName: baseName,
1162
+ description: resource.description ?? `Read resource: ${resource.uri}`,
1163
+ resourceUri: resource.uri,
1164
+ });
1165
+ }
1166
+ }
1167
+
1168
+ return { metadata, failedTools };
1169
+ }
1170
+
1171
+ function updateMetadataCache(state: McpExtensionState, serverName: string): void {
1172
+ const connection = state.manager.getConnection(serverName);
1173
+ if (!connection || connection.status !== "connected") return;
1174
+
1175
+ const definition = state.config.mcpServers[serverName];
1176
+ if (!definition) return;
1177
+
1178
+ const configHash = computeServerHash(definition);
1179
+ const existing = loadMetadataCache();
1180
+ const existingEntry = existing?.servers?.[serverName];
1181
+
1182
+ const tools = serializeTools(connection.tools);
1183
+ let resources = definition.exposeResources === false ? [] : serializeResources(connection.resources);
1184
+
1185
+ if (
1186
+ definition.exposeResources !== false &&
1187
+ resources.length === 0 &&
1188
+ existingEntry?.resources?.length &&
1189
+ existingEntry.configHash === configHash
1190
+ ) {
1191
+ resources = existingEntry.resources;
1192
+ }
1193
+
1194
+ const entry: ServerCacheEntry = {
1195
+ configHash,
1196
+ tools,
1197
+ resources,
1198
+ cachedAt: Date.now(),
1199
+ };
1200
+
1201
+ saveMetadataCache({ version: 1, servers: { [serverName]: entry } });
1202
+ }
1203
+
1204
+ function flushMetadataCache(state: McpExtensionState): void {
1205
+ for (const [name, connection] of state.manager.getAllConnections()) {
1206
+ if (connection.status === "connected") {
1207
+ updateMetadataCache(state, name);
1208
+ }
1209
+ }
1210
+ }
1211
+
1212
+ function getToolNames(state: McpExtensionState, serverName: string): string[] {
1213
+ return state.toolMetadata.get(serverName)?.map(m => m.name) ?? [];
1214
+ }
1215
+
1216
+ function totalToolCount(state: McpExtensionState): number {
1217
+ let count = 0;
1218
+ for (const metadata of state.toolMetadata.values()) {
1219
+ count += metadata.length;
1220
+ }
1221
+ return count;
1222
+ }
1223
+
1224
+ function updateStatusBar(state: McpExtensionState): void {
1225
+ const ui = state.ui;
1226
+ if (!ui) return;
1227
+ const total = Object.keys(state.config.mcpServers).length;
1228
+ if (total === 0) {
1229
+ ui.setStatus("mcp", "");
1230
+ return;
1231
+ }
1232
+ const connectedCount = state.manager.getAllConnections().size;
1233
+ ui.setStatus("mcp", ui.theme.fg("accent", `MCP: ${connectedCount}/${total} servers`));
1234
+ }
1235
+
1236
+ function getFailureAgeSeconds(state: McpExtensionState, serverName: string): number | null {
1237
+ const failedAt = state.failureTracker.get(serverName);
1238
+ if (!failedAt) return null;
1239
+ const ageMs = Date.now() - failedAt;
1240
+ if (ageMs > FAILURE_BACKOFF_MS) return null;
1241
+ return Math.round(ageMs / 1000);
1242
+ }
1243
+
1244
+ function getEffectiveIdleTimeoutMinutes(state: McpExtensionState, serverName: string): number {
1245
+ const definition = state.config.mcpServers[serverName];
1246
+ if (!definition) {
1247
+ return typeof state.config.settings?.idleTimeout === "number" ? state.config.settings.idleTimeout : 10;
1248
+ }
1249
+ if (typeof definition.idleTimeout === "number") return definition.idleTimeout;
1250
+ const mode = definition.lifecycle ?? "lazy";
1251
+ if (mode === "eager") return 0;
1252
+ return typeof state.config.settings?.idleTimeout === "number" ? state.config.settings.idleTimeout : 10;
1253
+ }
1254
+
1255
+ async function lazyConnect(state: McpExtensionState, serverName: string): Promise<boolean> {
1256
+ const connection = state.manager.getConnection(serverName);
1257
+ if (connection?.status === "connected") {
1258
+ updateServerMetadata(state, serverName);
1259
+ return true;
1260
+ }
1261
+
1262
+ const failedAgo = getFailureAgeSeconds(state, serverName);
1263
+ if (failedAgo !== null) return false;
1264
+
1265
+ const definition = state.config.mcpServers[serverName];
1266
+ if (!definition) return false;
1267
+
1268
+ try {
1269
+ if (state.ui) {
1270
+ state.ui.setStatus("mcp", `MCP: connecting to ${serverName}...`);
935
1271
  }
1272
+ await state.manager.connect(serverName, definition);
1273
+ state.failureTracker.delete(serverName);
1274
+ updateServerMetadata(state, serverName);
1275
+ updateMetadataCache(state, serverName);
1276
+ updateStatusBar(state);
1277
+ return true;
1278
+ } catch {
1279
+ state.failureTracker.set(serverName, Date.now());
1280
+ updateStatusBar(state);
1281
+ return false;
936
1282
  }
937
1283
  }
938
1284