@oh-my-pi/pi-coding-agent 13.13.2 → 13.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [13.14.0] - 2026-03-20
6
+
7
+ ### Added
8
+
9
+ - Auto-reconnect MCP servers on connection loss with proactive SSE stream monitoring and retry backoff
10
+ - Tool-level reconnect: retriable connection errors (ECONNREFUSED, ECONNRESET, stale session 404/502/503) trigger automatic reconnection and single retry
11
+ - `/mcp reconnect <name>` command for manual server recovery after extended outages
12
+
13
+ ### Changed
14
+
15
+ - Extended transport reconnect handling to all transport types (not just HTTP/SSE), ensuring stdio and other transports trigger automatic reconnection on connection loss
16
+ - Improved reconnect robustness by aborting retry attempts when MCP server configuration changes during reconnection sequence
17
+ - Updated explore agent thinking level from off to med for improved reasoning
18
+ - Simplified explore agent output schema: consolidated file references into single `ref` field with optional line ranges instead of separate `path`, `line_start`, `line_end` fields
19
+ - Removed `code` section from explore agent output (critical code excerpts no longer extracted)
20
+ - Removed `dependencies` section from explore agent output
21
+ - Removed `risks` section from explore agent output
22
+ - Removed `start_here` section from explore agent output
23
+
24
+ ### Fixed
25
+
26
+ - Fixed reconnect retry loop continuing after configuration changes by checking epoch before each reconnection attempt
27
+ - `roots/list` timeout on MCP server initialization: `connectToServer` now always installs a default handler for `ping` and `roots/list`
28
+ - Fixed resumed GitHub Copilot conversations that could fail with `401 input item does not belong to this connection` on the first follow-up after process restart ([#488](https://github.com/can1357/oh-my-pi/issues/488))
29
+ - Fixed STT Alt+H mic cursor rendering to measure the actual microphone glyph width, preventing one-column TUI overflow crashes when the active symbol preset uses a wide icon ([#484](https://github.com/can1357/oh-my-pi/issues/484))
30
+
5
31
  ## [13.13.2] - 2026-03-18
6
32
 
7
33
  ### Added
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "13.13.2",
4
+ "version": "13.14.0",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -41,12 +41,12 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@mozilla/readability": "^0.6",
44
- "@oh-my-pi/omp-stats": "13.13.2",
45
- "@oh-my-pi/pi-agent-core": "13.13.2",
46
- "@oh-my-pi/pi-ai": "13.13.2",
47
- "@oh-my-pi/pi-natives": "13.13.2",
48
- "@oh-my-pi/pi-tui": "13.13.2",
49
- "@oh-my-pi/pi-utils": "13.13.2",
44
+ "@oh-my-pi/omp-stats": "13.14.0",
45
+ "@oh-my-pi/pi-agent-core": "13.14.0",
46
+ "@oh-my-pi/pi-ai": "13.14.0",
47
+ "@oh-my-pi/pi-natives": "13.14.0",
48
+ "@oh-my-pi/pi-tui": "13.14.0",
49
+ "@oh-my-pi/pi-utils": "13.14.0",
50
50
  "@sinclair/typebox": "^0.34",
51
51
  "@xterm/headless": "^6.0",
52
52
  "ajv": "^8.18",
@@ -1440,10 +1440,17 @@ export class ModelRegistry {
1440
1440
  }
1441
1441
  #applyHardcodedModelPolicies(models: Model<Api>[]): Model<Api>[] {
1442
1442
  return models.map(model => {
1443
- if (model.id === "gpt-5.4" && model.provider !== "github-copilot") {
1444
- return { ...model, contextWindow: 1_000_000 };
1443
+ if (model.id !== "gpt-5.4" || model.provider === "github-copilot") {
1444
+ return model;
1445
+ }
1446
+ const overrides = this.#modelOverrides.get(model.provider)?.get(model.id);
1447
+ if (!overrides) {
1448
+ return applyModelOverride(model, { contextWindow: 1_000_000 });
1445
1449
  }
1446
- return model;
1450
+ return applyModelOverride(model, {
1451
+ contextWindow: overrides.contextWindow ?? 1_000_000,
1452
+ ...overrides,
1453
+ });
1447
1454
  });
1448
1455
  }
1449
1456
 
@@ -1521,6 +1521,8 @@ export const SETTINGS_SCHEMA = {
1521
1521
  "thinkingBudgets.medium": { type: "number", default: 8192 },
1522
1522
 
1523
1523
  "thinkingBudgets.high": { type: "number", default: 16384 },
1524
+
1525
+ "thinkingBudgets.xhigh": { type: "number", default: 32768 },
1524
1526
  } as const;
1525
1527
 
1526
1528
  // ═══════════════════════════════════════════════════════════════════════════
@@ -1703,6 +1705,7 @@ export interface ThinkingBudgetsSettings {
1703
1705
  low: number;
1704
1706
  medium: number;
1705
1707
  high: number;
1708
+ xhigh: number;
1706
1709
  }
1707
1710
 
1708
1711
  export interface SttSettings {
@@ -3,7 +3,7 @@ import * as path from "node:path";
3
3
  import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
4
4
  import { FileType, glob } from "@oh-my-pi/pi-natives";
5
5
  import { CONFIG_DIR_NAME, getConfigDirName, tryParseJson } from "@oh-my-pi/pi-utils";
6
- import { readFile } from "../capability/fs";
6
+ import { readDirEntries, readFile } from "../capability/fs";
7
7
  import { parseRuleConditionAndScope, type Rule, type RuleFrontmatter } from "../capability/rule";
8
8
  import type { Skill, SkillFrontmatter } from "../capability/skill";
9
9
  import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
@@ -529,7 +529,14 @@ export async function discoverExtensionModulePaths(_ctx: LoadContext, dir: strin
529
529
  subdirsWithDeclaredExtensions.add(subdir);
530
530
  const subdirPath = path.join(dir, subdir);
531
531
  for (const extPath of declaredExtensions) {
532
- const resolvedExtPath = path.resolve(subdirPath, extPath);
532
+ let resolvedExtPath = path.resolve(subdirPath, extPath);
533
+ const entries = await readDirEntries(resolvedExtPath);
534
+ if (entries.length !== 0) {
535
+ const pluginFilePath = entries.find(
536
+ e => e.isFile() && (e.name === "index.ts" || e.name === "index.js"),
537
+ )?.name;
538
+ resolvedExtPath = pluginFilePath ? path.join(resolvedExtPath, pluginFilePath) : resolvedExtPath;
539
+ }
533
540
  const content = await readFile(resolvedExtPath);
534
541
  if (content !== null) {
535
542
  discovered.add(resolvedExtPath);
package/src/mcp/client.ts CHANGED
@@ -3,7 +3,9 @@
3
3
  *
4
4
  * Handles connection initialization, tool listing, and tool calling.
5
5
  */
6
- import { logger, withTimeout } from "@oh-my-pi/pi-utils";
6
+ import * as path from "node:path";
7
+ import * as url from "node:url";
8
+ import { getProjectDir, logger, withTimeout } from "@oh-my-pi/pi-utils";
7
9
  import { createHttpTransport } from "./transports/http";
8
10
  import { createStdioTransport } from "./transports/stdio";
9
11
  import type {
@@ -46,6 +48,27 @@ const CLIENT_INFO = {
46
48
  version: "1.0.0",
47
49
  };
48
50
 
51
+ /**
52
+ * Default handler for standard MCP server-to-client requests.
53
+ * Handles `ping` and `roots/list`; rejects unknown methods with -32601.
54
+ * Reads getProjectDir() at call time so the root stays stable even if
55
+ * the process cwd changes during tool execution.
56
+ */
57
+ async function defaultRequestHandler(method: string, _params: unknown): Promise<unknown> {
58
+ switch (method) {
59
+ case "ping":
60
+ return {};
61
+ case "roots/list": {
62
+ const cwd = getProjectDir();
63
+ return {
64
+ roots: [{ uri: url.pathToFileURL(cwd).href, name: path.basename(cwd) }],
65
+ };
66
+ }
67
+ default:
68
+ throw Object.assign(new Error(`Unsupported server request: ${method}`), { code: -32601 });
69
+ }
70
+ }
71
+
49
72
  /**
50
73
  * Create a transport for the given server config.
51
74
  */
@@ -124,9 +147,11 @@ export async function connectToServer(
124
147
  if (options?.onNotification) {
125
148
  transport.onNotification = options.onNotification;
126
149
  }
127
- if (options?.onRequest) {
128
- transport.onRequest = options.onRequest;
129
- }
150
+
151
+ // Always handle standard MCP server-to-client requests (ping, roots/list).
152
+ // The initialize request declares roots capability, so we must respond to
153
+ // roots/list — even for short-lived test connections.
154
+ transport.onRequest = options?.onRequest ?? defaultRequestHandler;
130
155
 
131
156
  try {
132
157
  const initResult = await initializeConnection(transport, {
@@ -131,6 +131,11 @@ export class MCPManager {
131
131
  #notificationsEpoch = 0;
132
132
  #subscribedResources = new Map<string, Set<string>>();
133
133
  #pendingResourceRefresh = new Map<string, { connection: MCPServerConnection; promise: Promise<void> }>();
134
+ #pendingReconnections = new Map<string, Promise<MCPServerConnection | null>>();
135
+ /** Preserved configs for reconnection after connection loss. */
136
+ #serverConfigs = new Map<string, MCPServerConfig>();
137
+ /** Monotonic epoch incremented on disconnectAll to invalidate stale reconnections. */
138
+ #epoch = 0;
134
139
 
135
140
  constructor(
136
141
  private cwd: string,
@@ -287,7 +292,11 @@ export class MCPManager {
287
292
  continue;
288
293
  }
289
294
 
290
- if (this.#pendingConnections.has(name) || this.#pendingToolLoads.has(name)) {
295
+ if (
296
+ this.#pendingConnections.has(name) ||
297
+ this.#pendingToolLoads.has(name) ||
298
+ this.#pendingReconnections.has(name)
299
+ ) {
291
300
  continue;
292
301
  }
293
302
 
@@ -299,6 +308,10 @@ export class MCPManager {
299
308
  continue;
300
309
  }
301
310
 
311
+ // Save config early so reconnection works even if the initial connect times out
312
+ // and falls back to cached/deferred tools.
313
+ this.#serverConfigs.set(name, config);
314
+
302
315
  // Resolve auth config before connecting, but do so per-server in parallel.
303
316
  const connectionPromise = (async () => {
304
317
  const resolvedConfig = await this.#resolveAuthConfig(config);
@@ -315,6 +328,7 @@ export class MCPManager {
315
328
  // Store original config (without resolved tokens) to keep
316
329
  // cache keys stable and avoid leaking rotating credentials.
317
330
  connection.config = config;
331
+ this.#serverConfigs.set(name, config);
318
332
  if (sources[name]) {
319
333
  connection._source = sources[name];
320
334
  }
@@ -323,7 +337,7 @@ export class MCPManager {
323
337
  this.#connections.set(name, connection);
324
338
  }
325
339
 
326
- // Wire auth refresh for HTTP transports so 401s trigger token refresh
340
+ // Wire auth refresh for HTTP transports so 401s trigger token refresh.
327
341
  if (connection.transport instanceof HttpTransport && config.auth?.type === "oauth") {
328
342
  connection.transport.onAuthError = async () => {
329
343
  const refreshed = await this.#resolveAuthConfig(config, true);
@@ -334,6 +348,13 @@ export class MCPManager {
334
348
  };
335
349
  }
336
350
 
351
+ // Re-establish connection if the transport closes (server restart,
352
+ // network interruption).
353
+ connection.transport.onClose = () => {
354
+ logger.debug("MCP transport lost, triggering reconnect", { path: `mcp:${name}` });
355
+ void this.reconnectServer(name);
356
+ };
357
+
337
358
  return connection;
338
359
  },
339
360
  error => {
@@ -358,61 +379,13 @@ export class MCPManager {
358
379
  .then(async ({ connection, serverTools }) => {
359
380
  if (this.#pendingToolLoads.get(name) !== toolsPromise) return;
360
381
  this.#pendingToolLoads.delete(name);
361
- const customTools = MCPTool.fromTools(connection, serverTools);
382
+ const reconnect = () => this.reconnectServer(name);
383
+ const customTools = MCPTool.fromTools(connection, serverTools, reconnect);
362
384
  this.#replaceServerTools(name, customTools);
363
385
  this.#onToolsChanged?.(this.#tools);
364
386
  void this.toolCache?.set(name, config, serverTools);
365
387
 
366
- // Load resources and create resource tool (best-effort)
367
- if (serverSupportsResources(connection.capabilities)) {
368
- try {
369
- const [resources] = await Promise.all([
370
- listResources(connection),
371
- listResourceTemplates(connection),
372
- ]);
373
-
374
- if (this.#notificationsEnabled && connection.capabilities.resources?.subscribe) {
375
- const uris = resources.map(r => r.uri);
376
- const notificationEpoch = this.#notificationsEpoch;
377
- void subscribeToResources(connection, uris)
378
- .then(() => {
379
- const action = resolveSubscriptionPostAction(
380
- this.#notificationsEnabled,
381
- this.#notificationsEpoch,
382
- notificationEpoch,
383
- );
384
- if (action === "rollback") {
385
- void unsubscribeFromResources(connection, uris).catch(error => {
386
- logger.debug("Failed to rollback stale MCP resource subscription", {
387
- path: `mcp:${name}`,
388
- error,
389
- });
390
- });
391
- return;
392
- }
393
- if (action === "ignore") {
394
- return;
395
- }
396
- this.#subscribedResources.set(name, new Set(uris));
397
- })
398
- .catch(error => {
399
- logger.debug("Failed to subscribe to MCP resources", { path: `mcp:${name}`, error });
400
- });
401
- }
402
- } catch (error) {
403
- logger.debug("Failed to load MCP resources", { path: `mcp:${name}`, error });
404
- }
405
- }
406
-
407
- // Load prompts (best-effort)
408
- if (serverSupportsPrompts(connection.capabilities)) {
409
- try {
410
- await listPrompts(connection);
411
- this.#onPromptsChanged?.(name);
412
- } catch (error) {
413
- logger.debug("Failed to load MCP prompts", { path: `mcp:${name}`, error });
414
- }
415
- }
388
+ await this.#loadServerResourcesAndPrompts(name, connection);
416
389
  })
417
390
  .catch(error => {
418
391
  if (this.#pendingToolLoads.get(name) !== toolsPromise) return;
@@ -462,7 +435,8 @@ export class MCPManager {
462
435
  if (!value) continue;
463
436
  const { connection, serverTools } = value;
464
437
  connectedServers.add(name);
465
- allTools.push(...MCPTool.fromTools(connection, serverTools));
438
+ const reconnect = () => this.reconnectServer(name);
439
+ allTools.push(...MCPTool.fromTools(connection, serverTools, reconnect));
466
440
  } else if (task.tracked.status === "rejected") {
467
441
  const message =
468
442
  task.tracked.reason instanceof Error ? task.tracked.reason.message : String(task.tracked.reason);
@@ -472,7 +446,10 @@ export class MCPManager {
472
446
  const cached = cachedTools.get(name);
473
447
  if (cached) {
474
448
  const source = this.#sources.get(name);
475
- allTools.push(...DeferredMCPTool.fromTools(name, cached, () => this.waitForConnection(name), source));
449
+ const reconnect = () => this.reconnectServer(name);
450
+ allTools.push(
451
+ ...DeferredMCPTool.fromTools(name, cached, () => this.waitForConnection(name), source, reconnect),
452
+ );
476
453
  }
477
454
  }
478
455
  }
@@ -580,7 +557,12 @@ export class MCPManager {
580
557
  */
581
558
  getConnectionStatus(name: string): "connected" | "connecting" | "disconnected" {
582
559
  if (this.#connections.has(name)) return "connected";
583
- if (this.#pendingConnections.has(name) || this.#pendingToolLoads.has(name)) return "connecting";
560
+ if (
561
+ this.#pendingConnections.has(name) ||
562
+ this.#pendingToolLoads.has(name) ||
563
+ this.#pendingReconnections.has(name)
564
+ )
565
+ return "connecting";
584
566
  return "disconnected";
585
567
  }
586
568
 
@@ -599,6 +581,12 @@ export class MCPManager {
599
581
  if (connection) return connection;
600
582
  const pending = this.#pendingConnections.get(name);
601
583
  if (pending) return pending;
584
+ // If a reconnection is in flight, wait for it to complete
585
+ const reconnecting = this.#pendingReconnections.get(name);
586
+ if (reconnecting) {
587
+ const result = await reconnecting;
588
+ if (result) return result;
589
+ }
602
590
  throw new Error(`MCP server not connected: ${name}`);
603
591
  }
604
592
 
@@ -631,7 +619,9 @@ export class MCPManager {
631
619
  async disconnectServer(name: string): Promise<void> {
632
620
  this.#pendingConnections.delete(name);
633
621
  this.#pendingToolLoads.delete(name);
622
+ this.#pendingReconnections.delete(name);
634
623
  this.#sources.delete(name);
624
+ this.#serverConfigs.delete(name);
635
625
  this.#pendingResourceRefresh.delete(name);
636
626
 
637
627
  const connection = this.#connections.get(name);
@@ -643,6 +633,8 @@ export class MCPManager {
643
633
  this.#subscribedResources.delete(name);
644
634
 
645
635
  if (connection) {
636
+ // Detach onClose to prevent spurious reconnect from close()
637
+ connection.transport.onClose = undefined;
646
638
  await disconnectServer(connection);
647
639
  this.#connections.delete(name);
648
640
  }
@@ -660,18 +652,224 @@ export class MCPManager {
660
652
  * Disconnect from all servers.
661
653
  */
662
654
  async disconnectAll(): Promise<void> {
655
+ // Invalidate any in-flight reconnection attempts that outlive this call.
656
+ // They captured the old epoch; after increment they'll detect staleness.
657
+ this.#epoch++;
658
+ // Detach onClose before closing to prevent spurious reconnect attempts
659
+ for (const conn of this.#connections.values()) {
660
+ conn.transport.onClose = undefined;
661
+ }
663
662
  const promises = Array.from(this.#connections.values()).map(conn => disconnectServer(conn));
664
663
  await Promise.allSettled(promises);
665
664
 
666
665
  this.#pendingConnections.clear();
667
666
  this.#pendingToolLoads.clear();
667
+ this.#pendingReconnections.clear();
668
668
  this.#pendingResourceRefresh.clear();
669
669
  this.#sources.clear();
670
+ this.#serverConfigs.clear();
670
671
  this.#connections.clear();
671
672
  this.#tools = [];
672
673
  this.#subscribedResources.clear();
673
674
  }
674
675
 
676
+ /**
677
+ * Reconnect to a server after a connection failure.
678
+ * Tears down the stale connection, re-resolves auth, establishes a new
679
+ * connection, reloads tools, and notifies consumers.
680
+ * Concurrent calls for the same server share one reconnection attempt.
681
+ * Returns the new connection, or null if reconnection failed.
682
+ */
683
+ async reconnectServer(name: string): Promise<MCPServerConnection | null> {
684
+ const pending = this.#pendingReconnections.get(name);
685
+ if (pending) return pending;
686
+
687
+ const attempt = this.#doReconnect(name);
688
+ this.#pendingReconnections.set(name, attempt);
689
+ return attempt.finally(() => this.#pendingReconnections.delete(name));
690
+ }
691
+
692
+ async #doReconnect(name: string): Promise<MCPServerConnection | null> {
693
+ const oldConnection = this.#connections.get(name);
694
+ const config = oldConnection?.config ?? this.#serverConfigs.get(name);
695
+ const source = this.#sources.get(name) ?? oldConnection?._source;
696
+ if (!config) return null;
697
+
698
+ logger.debug("MCP reconnecting", { path: `mcp:${name}` });
699
+
700
+ // Close the old transport without removing tools or notifying consumers.
701
+ // Tools stay available (stale) while we establish the new connection.
702
+ // Fire-and-forget: don't await the close — HttpTransport.close() sends a
703
+ // DELETE with config.timeout (30s default), and blocking here delays the
704
+ // reconnect loop by that amount on every server restart.
705
+ const reconnectEpoch = this.#epoch;
706
+ if (oldConnection) {
707
+ // Detach onClose to prevent re-entrant reconnect from the close itself
708
+ oldConnection.transport.onClose = undefined;
709
+ void oldConnection.transport.close().catch(() => {});
710
+ this.#connections.delete(name);
711
+ }
712
+ this.#pendingConnections.delete(name);
713
+ this.#pendingToolLoads.delete(name);
714
+
715
+ // Retry with backoff — the server may still be starting up.
716
+ const delays = [500, 1000, 2000, 4000];
717
+ for (let attempt = 0; attempt <= delays.length; attempt++) {
718
+ if (this.#epoch !== reconnectEpoch) {
719
+ logger.debug("MCP reconnect aborted before attempt after configuration changed", {
720
+ path: `mcp:${name}`,
721
+ storedEpoch: reconnectEpoch,
722
+ currentEpoch: this.#epoch,
723
+ });
724
+ return null;
725
+ }
726
+ try {
727
+ const connection = await this.#connectAndWireServer(name, config, source, reconnectEpoch);
728
+ logger.debug("MCP reconnected", { path: `mcp:${name}`, tools: connection.tools?.length ?? 0 });
729
+ return connection;
730
+ } catch (error) {
731
+ if (this.#epoch !== reconnectEpoch) {
732
+ logger.debug("MCP reconnect aborted after configuration changed", {
733
+ path: `mcp:${name}`,
734
+ storedEpoch: reconnectEpoch,
735
+ currentEpoch: this.#epoch,
736
+ });
737
+ return null;
738
+ }
739
+
740
+ const msg = error instanceof Error ? error.message : String(error);
741
+ if (attempt < delays.length) {
742
+ logger.debug("MCP reconnect attempt failed, retrying", {
743
+ path: `mcp:${name}`,
744
+ attempt: attempt + 1,
745
+ error: msg,
746
+ });
747
+ await Bun.sleep(delays[attempt]);
748
+ } else {
749
+ logger.error("MCP reconnect failed after retries", { path: `mcp:${name}`, error: msg });
750
+ // Don't remove stale tools — keep them in the registry so they
751
+ // remain selected. Calls will fail with MCP errors, which
752
+ // triggers the tool-level reconnect, or the user can run
753
+ // /mcp reconnect <name> manually.
754
+ }
755
+ }
756
+ }
757
+ return null;
758
+ }
759
+
760
+ /** Establish a new connection to a server, wire handlers, load tools. */
761
+ async #connectAndWireServer(
762
+ name: string,
763
+ config: MCPServerConfig,
764
+ source: SourceMeta | undefined,
765
+ reconnectEpoch: number,
766
+ ): Promise<MCPServerConnection> {
767
+ const resolvedConfig = await this.#resolveAuthConfig(config);
768
+ const connection = await connectToServer(name, resolvedConfig, {
769
+ onNotification: (method, params) => {
770
+ this.#handleServerNotification(name, method, params);
771
+ },
772
+ onRequest: (method, params) => {
773
+ return this.#handleServerRequest(method, params);
774
+ },
775
+ });
776
+
777
+ connection.config = config;
778
+ if (source) connection._source = source;
779
+
780
+ // Bail out if the server was disconnected or the manager was reset
781
+ // while we were connecting (e.g. /mcp reload called disconnectAll).
782
+ if (!this.#serverConfigs.has(name) || this.#epoch !== reconnectEpoch) {
783
+ await connection.transport.close().catch(() => {});
784
+ throw new Error(`Server "${name}" was disconnected during reconnection`);
785
+ }
786
+
787
+ this.#connections.set(name, connection);
788
+
789
+ // Wire auth refresh for HTTP transports, and reconnect for any transport.
790
+ if (connection.transport instanceof HttpTransport && config.auth?.type === "oauth") {
791
+ connection.transport.onAuthError = async () => {
792
+ const refreshed = await this.#resolveAuthConfig(config, true);
793
+ if (refreshed.type === "http" || refreshed.type === "sse") {
794
+ return refreshed.headers ?? null;
795
+ }
796
+ return null;
797
+ };
798
+ }
799
+ connection.transport.onClose = () => {
800
+ logger.debug("MCP transport lost, triggering reconnect", { path: `mcp:${name}` });
801
+ void this.reconnectServer(name);
802
+ };
803
+ try {
804
+ const serverTools = await listTools(connection);
805
+ const reconnect = () => this.reconnectServer(name);
806
+ const customTools = MCPTool.fromTools(connection, serverTools, reconnect);
807
+ void this.toolCache?.set(name, config, serverTools);
808
+ this.#replaceServerTools(name, customTools);
809
+ this.#onToolsChanged?.(this.#tools);
810
+ void this.#loadServerResourcesAndPrompts(name, connection);
811
+ return connection;
812
+ } catch (error) {
813
+ // Clean up the connection to avoid zombie transports
814
+ connection.transport.onClose = undefined;
815
+ await connection.transport.close().catch(() => {});
816
+ this.#connections.delete(name);
817
+ throw error;
818
+ }
819
+ }
820
+
821
+ /**
822
+ * Best-effort loading of resources, resource subscriptions, and prompts.
823
+ * Shared between initial connection and reconnection.
824
+ */
825
+ async #loadServerResourcesAndPrompts(name: string, connection: MCPServerConnection): Promise<void> {
826
+ if (serverSupportsResources(connection.capabilities)) {
827
+ try {
828
+ const [resources] = await Promise.all([listResources(connection), listResourceTemplates(connection)]);
829
+
830
+ if (this.#notificationsEnabled && connection.capabilities.resources?.subscribe) {
831
+ const uris = resources.map(r => r.uri);
832
+ const notificationEpoch = this.#notificationsEpoch;
833
+ void subscribeToResources(connection, uris)
834
+ .then(() => {
835
+ const action = resolveSubscriptionPostAction(
836
+ this.#notificationsEnabled,
837
+ this.#notificationsEpoch,
838
+ notificationEpoch,
839
+ );
840
+ if (action === "rollback") {
841
+ void unsubscribeFromResources(connection, uris).catch(error => {
842
+ logger.debug("Failed to rollback stale MCP resource subscription", {
843
+ path: `mcp:${name}`,
844
+ error,
845
+ });
846
+ });
847
+ return;
848
+ }
849
+ if (action === "ignore") {
850
+ return;
851
+ }
852
+ this.#subscribedResources.set(name, new Set(uris));
853
+ })
854
+ .catch(error => {
855
+ logger.debug("Failed to subscribe to MCP resources", { path: `mcp:${name}`, error });
856
+ });
857
+ }
858
+ } catch (error) {
859
+ logger.debug("Failed to load MCP resources", { path: `mcp:${name}`, error });
860
+ }
861
+ }
862
+
863
+ if (serverSupportsPrompts(connection.capabilities)) {
864
+ try {
865
+ await listPrompts(connection);
866
+ this.#onPromptsChanged?.(name);
867
+ } catch (error) {
868
+ logger.debug("Failed to load MCP prompts", { path: `mcp:${name}`, error });
869
+ }
870
+ }
871
+ }
872
+
675
873
  /**
676
874
  * Refresh tools from a specific server.
677
875
  */
@@ -684,7 +882,8 @@ export class MCPManager {
684
882
 
685
883
  // Reload tools
686
884
  const serverTools = await listTools(connection);
687
- const customTools = MCPTool.fromTools(connection, serverTools);
885
+ const reconnect = () => this.reconnectServer(name);
886
+ const customTools = MCPTool.fromTools(connection, serverTools, reconnect);
688
887
  void this.toolCache?.set(name, connection.config, serverTools);
689
888
 
690
889
  // Replace tools from this server