@gajae-code/coding-agent 0.5.2 → 0.5.3

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.
Files changed (78) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/types/async/job-manager.d.ts +6 -0
  3. package/dist/types/dap/client.d.ts +2 -1
  4. package/dist/types/edit/read-file.d.ts +6 -0
  5. package/dist/types/eval/js/context-manager.d.ts +3 -0
  6. package/dist/types/eval/js/executor.d.ts +1 -0
  7. package/dist/types/exec/bash-executor.d.ts +2 -0
  8. package/dist/types/gjc-runtime/tmux-sessions.d.ts +7 -1
  9. package/dist/types/lsp/types.d.ts +2 -0
  10. package/dist/types/modes/bridge/bridge-mode.d.ts +1 -0
  11. package/dist/types/modes/components/model-selector.d.ts +2 -0
  12. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  13. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +1 -0
  14. package/dist/types/modes/components/tool-execution.d.ts +1 -0
  15. package/dist/types/runtime/process-lifecycle.d.ts +108 -0
  16. package/dist/types/runtime-mcp/transports/stdio.d.ts +1 -0
  17. package/dist/types/runtime-mcp/types.d.ts +2 -0
  18. package/dist/types/session/agent-session.d.ts +17 -1
  19. package/dist/types/session/artifacts.d.ts +4 -1
  20. package/dist/types/session/streaming-output.d.ts +5 -0
  21. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +76 -0
  22. package/dist/types/tools/bash.d.ts +1 -0
  23. package/dist/types/tools/browser/tab-supervisor.d.ts +9 -0
  24. package/dist/types/tools/sqlite-reader.d.ts +2 -1
  25. package/package.json +7 -7
  26. package/src/async/job-manager.ts +153 -39
  27. package/src/config/file-lock.ts +9 -1
  28. package/src/dap/client.ts +105 -64
  29. package/src/dap/session.ts +44 -7
  30. package/src/edit/read-file.ts +19 -1
  31. package/src/eval/js/context-manager.ts +228 -65
  32. package/src/eval/js/executor.ts +2 -0
  33. package/src/eval/js/index.ts +1 -0
  34. package/src/eval/js/worker-core.ts +10 -6
  35. package/src/eval/py/executor.ts +68 -19
  36. package/src/eval/py/kernel.ts +46 -22
  37. package/src/eval/py/runner.py +68 -14
  38. package/src/exec/bash-executor.ts +49 -13
  39. package/src/gjc-runtime/tmux-gc.ts +86 -37
  40. package/src/gjc-runtime/tmux-sessions.ts +44 -6
  41. package/src/internal-urls/artifact-protocol.ts +10 -1
  42. package/src/internal-urls/docs-index.generated.ts +2 -2
  43. package/src/lsp/client.ts +64 -26
  44. package/src/lsp/index.ts +2 -1
  45. package/src/lsp/lspmux.ts +33 -9
  46. package/src/lsp/types.ts +2 -0
  47. package/src/modes/bridge/bridge-mode.ts +21 -0
  48. package/src/modes/components/assistant-message.ts +10 -2
  49. package/src/modes/components/bash-execution.ts +5 -1
  50. package/src/modes/components/eval-execution.ts +5 -1
  51. package/src/modes/components/model-selector.ts +34 -2
  52. package/src/modes/components/oauth-selector.ts +5 -0
  53. package/src/modes/components/runtime-mcp-add-wizard.ts +58 -7
  54. package/src/modes/components/skill-message.ts +24 -16
  55. package/src/modes/components/tool-execution.ts +6 -0
  56. package/src/modes/controllers/extension-ui-controller.ts +33 -6
  57. package/src/modes/controllers/input-controller.ts +5 -0
  58. package/src/modes/controllers/selector-controller.ts +6 -1
  59. package/src/modes/utils/ui-helpers.ts +5 -2
  60. package/src/runtime/process-lifecycle.ts +400 -0
  61. package/src/runtime-mcp/manager.ts +164 -50
  62. package/src/runtime-mcp/transports/http.ts +12 -11
  63. package/src/runtime-mcp/transports/stdio.ts +64 -38
  64. package/src/runtime-mcp/types.ts +3 -0
  65. package/src/sdk.ts +27 -0
  66. package/src/session/agent-session.ts +168 -22
  67. package/src/session/artifacts.ts +17 -2
  68. package/src/session/blob-store.ts +36 -2
  69. package/src/session/session-manager.ts +29 -13
  70. package/src/session/streaming-output.ts +54 -3
  71. package/src/slash-commands/builtin-registry.ts +30 -3
  72. package/src/slash-commands/helpers/fast-status-report.ts +111 -0
  73. package/src/tools/archive-reader.ts +10 -1
  74. package/src/tools/bash.ts +11 -4
  75. package/src/tools/browser/tab-supervisor.ts +22 -0
  76. package/src/tools/browser.ts +38 -4
  77. package/src/tools/read.ts +11 -12
  78. package/src/tools/sqlite-reader.ts +19 -5
@@ -58,6 +58,19 @@ type TrackedPromise<T> = {
58
58
  };
59
59
 
60
60
  const STARTUP_TIMEOUT_MS = 250;
61
+ const STARTUP_TIMEOUT_GRACE_MS = 250;
62
+ const MAX_STARTUP_TIMEOUT_MS = 1_500;
63
+
64
+ function resolveStartupTimeoutMs(configs: MCPServerConfig[]): number {
65
+ const configuredTimeouts = configs
66
+ .map(config => config.timeout)
67
+ .filter((timeout): timeout is number => typeof timeout === "number" && Number.isFinite(timeout) && timeout > 0);
68
+ if (configuredTimeouts.length === 0) return STARTUP_TIMEOUT_MS;
69
+ return Math.min(
70
+ MAX_STARTUP_TIMEOUT_MS,
71
+ Math.max(STARTUP_TIMEOUT_MS, Math.max(...configuredTimeouts) + STARTUP_TIMEOUT_GRACE_MS),
72
+ );
73
+ }
61
74
 
62
75
  function trackPromise<T>(promise: Promise<T>): TrackedPromise<T> {
63
76
  const tracked: TrackedPromise<T> = { promise, status: "pending" };
@@ -74,8 +87,20 @@ function trackPromise<T>(promise: Promise<T>): TrackedPromise<T> {
74
87
  return tracked;
75
88
  }
76
89
 
77
- function delay(ms: number): Promise<void> {
78
- return Bun.sleep(ms);
90
+ function delay(ms: number, signal?: AbortSignal): Promise<void> {
91
+ if (!signal) return Bun.sleep(ms);
92
+ if (signal.aborted) return Promise.reject(signal.reason ?? new Error("Aborted"));
93
+ return new Promise((resolve, reject) => {
94
+ const timer = setTimeout(() => {
95
+ signal.removeEventListener("abort", onAbort);
96
+ resolve();
97
+ }, ms);
98
+ const onAbort = () => {
99
+ clearTimeout(timer);
100
+ reject(signal.reason ?? new Error("Aborted"));
101
+ };
102
+ signal.addEventListener("abort", onAbort, { once: true });
103
+ });
79
104
  }
80
105
 
81
106
  /**
@@ -166,11 +191,27 @@ export class MCPManager {
166
191
  #pendingResourceRefresh = new Map<string, { connection: MCPServerConnection; promise: Promise<void> }>();
167
192
  #pendingReconnections = new Map<string, Promise<MCPServerConnection | null>>();
168
193
  #disconnectEpochs = new Map<string, number>();
194
+ #reconnectBackoffs = new Map<string, AbortController>();
169
195
  /** Preserved configs for reconnection after connection loss. */
170
196
  #serverConfigs = new Map<string, MCPServerConfig>();
171
197
  /** Monotonic epoch incremented on disconnectAll to invalidate stale reconnections. */
172
198
  #epoch = 0;
173
199
 
200
+ #isCurrentConnection(
201
+ name: string,
202
+ _config: MCPServerConfig,
203
+ globalEpoch: number,
204
+ disconnectEpoch: number,
205
+ connection: MCPServerConnection,
206
+ ): boolean {
207
+ return (
208
+ this.#serverConfigs.has(name) &&
209
+ this.#epoch === globalEpoch &&
210
+ (this.#disconnectEpochs.get(name) ?? 0) === disconnectEpoch &&
211
+ this.#connections.get(name) === connection
212
+ );
213
+ }
214
+
174
215
  constructor(
175
216
  private cwd: string,
176
217
  private toolCache: MCPToolCache | null = null,
@@ -305,6 +346,8 @@ export class MCPManager {
305
346
  tracked: TrackedPromise<ToolLoadResult>;
306
347
  toolsPromise: Promise<ToolLoadResult>;
307
348
  connectionAbort: AbortController;
349
+ connectionEpoch: number;
350
+ disconnectEpoch: number;
308
351
  };
309
352
 
310
353
  const errors = new Map<string, string>();
@@ -312,6 +355,7 @@ export class MCPManager {
312
355
  const allTools: CustomTool<TSchema, MCPToolDetails>[] = [];
313
356
  const reportedErrors = new Set<string>();
314
357
  let allowBackgroundLogging = false;
358
+ let shouldPublishToolSnapshot = true;
315
359
 
316
360
  // Prepare connection tasks
317
361
  const connectionTasks: ConnectionTask[] = [];
@@ -352,6 +396,7 @@ export class MCPManager {
352
396
  this.#serverConfigs.set(name, config);
353
397
 
354
398
  const connectionEpoch = this.#epoch;
399
+ const disconnectEpoch = this.#disconnectEpochs.get(name) ?? 0;
355
400
  const connectionAbort = new AbortController();
356
401
  this.#pendingConnectionControllers.set(name, connectionAbort);
357
402
  // Resolve auth config before connecting, but do so per-server in parallel.
@@ -375,7 +420,10 @@ export class MCPManager {
375
420
  connection._source = sources[name];
376
421
  }
377
422
  const stillPending = this.#pendingConnections.get(name) === connectionPromise;
378
- const stillCurrent = this.#epoch === connectionEpoch && this.#serverConfigs.get(name) === config;
423
+ const stillCurrent =
424
+ this.#epoch === connectionEpoch &&
425
+ (this.#disconnectEpochs.get(name) ?? 0) === disconnectEpoch &&
426
+ this.#serverConfigs.get(name) === config;
379
427
  if (stillPending) {
380
428
  this.#pendingConnections.delete(name);
381
429
  this.#pendingConnectionControllers.delete(name);
@@ -419,24 +467,45 @@ export class MCPManager {
419
467
  this.#pendingConnections.set(name, connectionPromise);
420
468
 
421
469
  const toolsPromise = connectionPromise.then(async connection => {
422
- const serverTools = await listTools(connection);
470
+ let serverTools: Awaited<ReturnType<typeof listTools>>;
471
+ try {
472
+ serverTools = await listTools(connection);
473
+ } catch (error) {
474
+ connection.transport.onClose = undefined;
475
+ if (this.#connections.get(name) === connection) this.#connections.delete(name);
476
+ void connection.transport.close().catch(() => {});
477
+ throw error;
478
+ }
479
+ if (!this.#isCurrentConnection(name, config, connectionEpoch, disconnectEpoch, connection)) {
480
+ connection.transport.onClose = undefined;
481
+ await connection.transport.close().catch(() => {});
482
+ throw new Error(`Server "${name}" was disconnected during tool loading`);
483
+ }
423
484
  return { connection, serverTools };
424
485
  });
425
486
  this.#pendingToolLoads.set(name, toolsPromise);
426
487
 
427
488
  const tracked = trackPromise(toolsPromise);
428
- connectionTasks.push({ name, config, tracked, toolsPromise, connectionAbort });
489
+ connectionTasks.push({
490
+ name,
491
+ config,
492
+ tracked,
493
+ toolsPromise,
494
+ connectionAbort,
495
+ connectionEpoch,
496
+ disconnectEpoch,
497
+ });
429
498
 
430
499
  void toolsPromise
431
500
  .then(async ({ connection, serverTools }) => {
432
501
  if (this.#pendingToolLoads.get(name) !== toolsPromise) return;
502
+ if (!this.#isCurrentConnection(name, config, connectionEpoch, disconnectEpoch, connection)) return;
433
503
  this.#pendingToolLoads.delete(name);
434
504
  const reconnect = () => this.reconnectServer(name);
435
505
  const customTools = MCPTool.fromTools(connection, serverTools, reconnect);
436
506
  this.#replaceServerTools(name, customTools);
437
507
  this.#onToolsChanged?.(this.#tools);
438
508
  void this.toolCache?.set(name, config, serverTools);
439
-
440
509
  await this.#loadServerResourcesAndPrompts(name, connection);
441
510
  })
442
511
  .catch(error => {
@@ -454,9 +523,10 @@ export class MCPManager {
454
523
  }
455
524
 
456
525
  if (connectionTasks.length > 0) {
526
+ const startupTimeoutMs = resolveStartupTimeoutMs(connectionTasks.map(task => task.config));
457
527
  await Promise.race([
458
528
  Promise.allSettled(connectionTasks.map(task => task.tracked.promise)),
459
- delay(STARTUP_TIMEOUT_MS),
529
+ delay(startupTimeoutMs),
460
530
  ]);
461
531
 
462
532
  const cachedTools = new Map<string, MCPToolDefinition[]>();
@@ -498,6 +568,13 @@ export class MCPManager {
498
568
  const value = task.tracked.value;
499
569
  if (!value) continue;
500
570
  const { connection, serverTools } = value;
571
+ if (this.#pendingToolLoads.has(name) && this.#pendingToolLoads.get(name) !== task.toolsPromise) continue;
572
+ if (
573
+ !this.#isCurrentConnection(name, task.config, task.connectionEpoch, task.disconnectEpoch, connection)
574
+ ) {
575
+ shouldPublishToolSnapshot = false;
576
+ continue;
577
+ }
501
578
  connectedServers.add(name);
502
579
  const reconnect = () => this.reconnectServer(name);
503
580
  allTools.push(...MCPTool.fromTools(connection, serverTools, reconnect));
@@ -506,6 +583,9 @@ export class MCPManager {
506
583
  task.tracked.reason instanceof Error ? task.tracked.reason.message : String(task.tracked.reason);
507
584
  errors.set(name, message);
508
585
  reportedErrors.add(name);
586
+ if ((this.#disconnectEpochs.get(name) ?? 0) !== task.disconnectEpoch) {
587
+ shouldPublishToolSnapshot = false;
588
+ }
509
589
  } else {
510
590
  const cached = cachedTools.get(name);
511
591
  if (cached) {
@@ -524,7 +604,7 @@ export class MCPManager {
524
604
  sortMCPToolsByName(allTools);
525
605
 
526
606
  // Update cached tools
527
- this.#tools = allTools;
607
+ if (shouldPublishToolSnapshot) this.#tools = allTools;
528
608
  allowBackgroundLogging = true;
529
609
 
530
610
  return {
@@ -692,6 +772,8 @@ export class MCPManager {
692
772
  this.#disconnectEpochs.set(name, nextEpoch);
693
773
  this.#pendingConnectionControllers.get(name)?.abort(new Error(`MCP server disconnected: ${name}`));
694
774
  this.#pendingConnectionControllers.delete(name);
775
+ this.#reconnectBackoffs.get(name)?.abort(new Error(`MCP server disconnected: ${name}`));
776
+ this.#reconnectBackoffs.delete(name);
695
777
  this.#pendingConnections.delete(name);
696
778
  this.#pendingToolLoads.delete(name);
697
779
  this.#pendingReconnections.delete(name);
@@ -742,6 +824,10 @@ export class MCPManager {
742
824
  this.#pendingConnectionControllers.clear();
743
825
  this.#pendingConnections.clear();
744
826
  this.#pendingToolLoads.clear();
827
+ for (const controller of this.#reconnectBackoffs.values()) {
828
+ controller.abort(new Error("MCP manager disconnected"));
829
+ }
830
+ this.#reconnectBackoffs.clear();
745
831
  this.#pendingReconnections.clear();
746
832
  this.#pendingResourceRefresh.clear();
747
833
  this.#sources.clear();
@@ -764,7 +850,9 @@ export class MCPManager {
764
850
 
765
851
  const attempt = this.#doReconnect(name);
766
852
  this.#pendingReconnections.set(name, attempt);
767
- return attempt.finally(() => this.#pendingReconnections.delete(name));
853
+ return attempt.finally(() => {
854
+ if (this.#pendingReconnections.get(name) === attempt) this.#pendingReconnections.delete(name);
855
+ });
768
856
  }
769
857
 
770
858
  async #doReconnect(name: string): Promise<MCPServerConnection | null> {
@@ -777,60 +865,73 @@ export class MCPManager {
777
865
 
778
866
  // Close the old transport without removing tools or notifying consumers.
779
867
  // Tools stay available (stale) while we establish the new connection.
780
- // Fire-and-forget: don't await the close — HttpTransport.close() sends a
781
- // DELETE with config.timeout (30s default), and blocking here delays the
782
- // reconnect loop by that amount on every server restart.
783
- const reconnectEpoch = this.#epoch;
868
+ const reconnectEpoch = this.#disconnectEpochs.get(name) ?? 0;
784
869
  if (oldConnection) {
785
870
  // Detach onClose to prevent re-entrant reconnect from the close itself
786
871
  oldConnection.transport.onClose = undefined;
787
- void oldConnection.transport.close().catch(() => {});
872
+ const closePromise = oldConnection.transport.close().catch(() => {});
873
+ if (oldConnection.transport.closeBeforeReconnect) {
874
+ await closePromise;
875
+ } else {
876
+ // Fire-and-forget: don't await HTTP/SSE close — HttpTransport.close()
877
+ // sends a DELETE with config.timeout (30s default), and blocking here
878
+ // delays the reconnect loop by that amount on every server restart.
879
+ void closePromise;
880
+ }
788
881
  this.#connections.delete(name);
789
882
  }
790
883
  this.#pendingConnections.delete(name);
884
+ const backoffAbort = new AbortController();
885
+ this.#reconnectBackoffs.set(name, backoffAbort);
791
886
  this.#pendingToolLoads.delete(name);
792
887
 
793
- // Retry with backoff — the server may still be starting up.
794
- const delays = [500, 1000, 2000, 4000];
795
- for (let attempt = 0; attempt <= delays.length; attempt++) {
796
- if (this.#epoch !== reconnectEpoch) {
797
- logger.debug("MCP reconnect aborted before attempt after configuration changed", {
798
- path: `mcp:${name}`,
799
- storedEpoch: reconnectEpoch,
800
- currentEpoch: this.#epoch,
801
- });
802
- return null;
803
- }
804
- try {
805
- const connection = await this.#connectAndWireServer(name, config, source, reconnectEpoch);
806
- logger.debug("MCP reconnected", { path: `mcp:${name}`, tools: connection.tools?.length ?? 0 });
807
- return connection;
808
- } catch (error) {
809
- if (this.#epoch !== reconnectEpoch) {
810
- logger.debug("MCP reconnect aborted after configuration changed", {
888
+ try {
889
+ // Retry with backoff the server may still be starting up.
890
+ const delays = [500, 1000, 2000, 4000];
891
+ for (let attempt = 0; attempt <= delays.length; attempt++) {
892
+ if ((this.#disconnectEpochs.get(name) ?? 0) !== reconnectEpoch || backoffAbort.signal.aborted) {
893
+ logger.debug("MCP reconnect aborted before attempt after server disconnected", {
811
894
  path: `mcp:${name}`,
812
895
  storedEpoch: reconnectEpoch,
813
- currentEpoch: this.#epoch,
896
+ currentEpoch: this.#disconnectEpochs.get(name) ?? 0,
814
897
  });
815
898
  return null;
816
899
  }
900
+ try {
901
+ const connection = await this.#connectAndWireServer(name, config, source, this.#epoch, reconnectEpoch);
902
+ logger.debug("MCP reconnected", { path: `mcp:${name}`, tools: connection.tools?.length ?? 0 });
903
+ return connection;
904
+ } catch (error) {
905
+ if ((this.#disconnectEpochs.get(name) ?? 0) !== reconnectEpoch || backoffAbort.signal.aborted) {
906
+ logger.debug("MCP reconnect aborted after server disconnected", {
907
+ path: `mcp:${name}`,
908
+ storedEpoch: reconnectEpoch,
909
+ currentEpoch: this.#disconnectEpochs.get(name) ?? 0,
910
+ });
911
+ return null;
912
+ }
817
913
 
818
- const msg = error instanceof Error ? error.message : String(error);
819
- if (attempt < delays.length) {
820
- logger.debug("MCP reconnect attempt failed, retrying", {
821
- path: `mcp:${name}`,
822
- attempt: attempt + 1,
823
- error: msg,
824
- });
825
- await Bun.sleep(delays[attempt]);
826
- } else {
827
- logger.error("MCP reconnect failed after retries", { path: `mcp:${name}`, error: msg });
828
- // Don't remove stale tools — keep them in the registry so they
829
- // remain selected. Calls will fail with MCP errors, which
830
- // triggers the tool-level reconnect, or the user can run
831
- // /mcp reconnect <name> manually.
914
+ const msg = error instanceof Error ? error.message : String(error);
915
+ if (attempt < delays.length) {
916
+ logger.debug("MCP reconnect attempt failed, retrying", {
917
+ path: `mcp:${name}`,
918
+ attempt: attempt + 1,
919
+ error: msg,
920
+ });
921
+ await delay(delays[attempt], backoffAbort.signal).catch(() => undefined);
922
+ } else {
923
+ logger.error("MCP reconnect failed after retries", { path: `mcp:${name}`, error: msg });
924
+ // Don't remove stale tools — keep them in the registry so they
925
+ // remain selected. Calls will fail with MCP errors, which
926
+ // triggers the tool-level reconnect, or the user can run
927
+ // /mcp reconnect <name> manually.
928
+ }
832
929
  }
833
930
  }
931
+ } finally {
932
+ if (this.#reconnectBackoffs.get(name) === backoffAbort) {
933
+ this.#reconnectBackoffs.delete(name);
934
+ }
834
935
  }
835
936
  return null;
836
937
  }
@@ -840,7 +941,8 @@ export class MCPManager {
840
941
  name: string,
841
942
  config: MCPServerConfig,
842
943
  source: SourceMeta | undefined,
843
- reconnectEpoch: number,
944
+ globalEpoch: number,
945
+ disconnectEpoch: number,
844
946
  ): Promise<MCPServerConnection> {
845
947
  const resolvedConfig = await this.#resolveAuthConfig(config);
846
948
  const connectionAbort = new AbortController();
@@ -867,7 +969,11 @@ export class MCPManager {
867
969
 
868
970
  // Bail out if the server was disconnected or the manager was reset
869
971
  // while we were connecting (e.g. /mcp reload called disconnectAll).
870
- if (!this.#serverConfigs.has(name) || this.#epoch !== reconnectEpoch) {
972
+ if (
973
+ !this.#serverConfigs.has(name) ||
974
+ this.#epoch !== globalEpoch ||
975
+ (this.#disconnectEpochs.get(name) ?? 0) !== disconnectEpoch
976
+ ) {
871
977
  await connection.transport.close().catch(() => {});
872
978
  throw new Error(`Server "${name}" was disconnected during reconnection`);
873
979
  }
@@ -890,6 +996,11 @@ export class MCPManager {
890
996
  };
891
997
  try {
892
998
  const serverTools = await listTools(connection);
999
+ if (!this.#isCurrentConnection(name, config, globalEpoch, disconnectEpoch, connection)) {
1000
+ connection.transport.onClose = undefined;
1001
+ await connection.transport.close().catch(() => {});
1002
+ throw new Error(`Server "${name}" was disconnected during tool loading`);
1003
+ }
893
1004
  const reconnect = () => this.reconnectServer(name);
894
1005
  const customTools = MCPTool.fromTools(connection, serverTools, reconnect);
895
1006
  void this.toolCache?.set(name, config, serverTools);
@@ -901,7 +1012,7 @@ export class MCPManager {
901
1012
  // Clean up the connection to avoid zombie transports
902
1013
  connection.transport.onClose = undefined;
903
1014
  await connection.transport.close().catch(() => {});
904
- this.#connections.delete(name);
1015
+ if (this.#connections.get(name) === connection) this.#connections.delete(name);
905
1016
  throw error;
906
1017
  }
907
1018
  }
@@ -941,12 +1052,15 @@ export class MCPManager {
941
1052
  async refreshServerTools(name: string): Promise<void> {
942
1053
  const connection = this.#connections.get(name);
943
1054
  if (!connection) return;
1055
+ const globalEpoch = this.#epoch;
1056
+ const disconnectEpoch = this.#disconnectEpochs.get(name) ?? 0;
944
1057
 
945
1058
  // Clear cached tools
946
1059
  connection.tools = undefined;
947
1060
 
948
1061
  // Reload tools
949
1062
  const serverTools = await listTools(connection);
1063
+ if (!this.#isCurrentConnection(name, connection.config, globalEpoch, disconnectEpoch, connection)) return;
950
1064
  const reconnect = () => this.reconnectServer(name);
951
1065
  const customTools = MCPTool.fromTools(connection, serverTools, reconnect);
952
1066
  void this.toolCache?.set(name, connection.config, serverTools);
@@ -99,6 +99,7 @@ export class HttpTransport implements MCPTransport {
99
99
  }
100
100
 
101
101
  if (response.status === 405 || !response.ok || !response.body) {
102
+ await response.body?.cancel().catch(() => {});
102
103
  this.#sseConnection = this.#sseConnection === sseConnection ? null : this.#sseConnection;
103
104
  return;
104
105
  }
@@ -209,8 +210,6 @@ export class HttpTransport implements MCPTransport {
209
210
  signal: operationSignal,
210
211
  });
211
212
 
212
- clearTimeout(timeoutId);
213
-
214
213
  // Check for session ID in response
215
214
  const newSessionId = response.headers.get("Mcp-Session-Id");
216
215
  if (newSessionId) {
@@ -247,7 +246,6 @@ export class HttpTransport implements MCPTransport {
247
246
 
248
247
  return result.result as T;
249
248
  } catch (error) {
250
- clearTimeout(timeoutId);
251
249
  if (error instanceof Error && error.name === "AbortError") {
252
250
  if (options?.signal?.aborted) {
253
251
  throw error;
@@ -255,6 +253,8 @@ export class HttpTransport implements MCPTransport {
255
253
  throw new Error(`Request timeout after ${timeout}ms`);
256
254
  }
257
255
  throw error;
256
+ } finally {
257
+ clearTimeout(timeoutId);
258
258
  }
259
259
  }
260
260
 
@@ -273,12 +273,12 @@ export class HttpTransport implements MCPTransport {
273
273
  const { promise, resolve, reject } = Promise.withResolvers<T>();
274
274
  let captured = false;
275
275
 
276
- // Drain the SSE stream from a single iterator. We resolve the deferred
277
- // promise as soon as the matching response arrives, then keep iterating
278
- // in the background to pick up piggybacked notifications/requests.
279
- // Re-reading `response.body` after `for await` breaks would lock the
280
- // stream a second time and surface as "ReadableStream already has a
281
- // controller", so we must not exit the loop early.
276
+ // Drain this per-request SSE response from a single iterator. Once the
277
+ // matching response arrives, resolve/reject and abort the reader so the
278
+ // response body is cancelled instead of lingering in the background.
279
+ // Re-reading `response.body` would lock the stream a second time and surface
280
+ // as "ReadableStream already has a controller", so the iterator owns the
281
+ // stream until it is aborted, completes, or errors.
282
282
  const drainController = abortController;
283
283
  this.#streamControllers.add(drainController);
284
284
  const drain = async (): Promise<void> => {
@@ -293,13 +293,13 @@ export class HttpTransport implements MCPTransport {
293
293
  ("result" in message || "error" in message)
294
294
  ) {
295
295
  captured = true;
296
- clearTimeout(timeoutId);
296
+ drainController.abort();
297
297
  if (message.error) {
298
298
  reject(new Error(`MCP error ${message.error.code}: ${message.error.message}`));
299
299
  } else {
300
300
  resolve(message.result as T);
301
301
  }
302
- continue;
302
+ return;
303
303
  }
304
304
  if (!this.#connected) continue;
305
305
  this.#dispatchSSEMessage(message);
@@ -322,6 +322,7 @@ export class HttpTransport implements MCPTransport {
322
322
  } finally {
323
323
  clearTimeout(timeoutId);
324
324
  this.#streamControllers.delete(drainController);
325
+ await response.body?.cancel().catch(() => {});
325
326
  }
326
327
  };
327
328