@animalabs/agent-framework 0.5.7 → 0.6.2

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 (179) hide show
  1. package/dist/src/agent.d.ts +4 -0
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +15 -0
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/framework.d.ts +243 -0
  6. package/dist/src/framework.d.ts.map +1 -1
  7. package/dist/src/framework.js +1378 -114
  8. package/dist/src/framework.js.map +1 -1
  9. package/dist/src/gate/event-gate.d.ts +64 -2
  10. package/dist/src/gate/event-gate.d.ts.map +1 -1
  11. package/dist/src/gate/event-gate.js +212 -12
  12. package/dist/src/gate/event-gate.js.map +1 -1
  13. package/dist/src/gate/gate-script-worker.d.ts +2 -0
  14. package/dist/src/gate/gate-script-worker.d.ts.map +1 -0
  15. package/dist/src/gate/gate-script-worker.js +69 -0
  16. package/dist/src/gate/gate-script-worker.js.map +1 -0
  17. package/dist/src/gate/gate-script.d.ts +44 -0
  18. package/dist/src/gate/gate-script.d.ts.map +1 -0
  19. package/dist/src/gate/gate-script.js +164 -0
  20. package/dist/src/gate/gate-script.js.map +1 -0
  21. package/dist/src/gate/types.d.ts +18 -3
  22. package/dist/src/gate/types.d.ts.map +1 -1
  23. package/dist/src/index.d.ts +3 -0
  24. package/dist/src/index.d.ts.map +1 -1
  25. package/dist/src/index.js +4 -0
  26. package/dist/src/index.js.map +1 -1
  27. package/dist/src/mcpl/channel-registry.d.ts +60 -2
  28. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  29. package/dist/src/mcpl/channel-registry.js +114 -9
  30. package/dist/src/mcpl/channel-registry.js.map +1 -1
  31. package/dist/src/mcpl/checkpoint-manager.d.ts +7 -0
  32. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
  33. package/dist/src/mcpl/checkpoint-manager.js +15 -0
  34. package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
  35. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  36. package/dist/src/mcpl/push-handler.js +5 -0
  37. package/dist/src/mcpl/push-handler.js.map +1 -1
  38. package/dist/src/mcpl/server-connection.d.ts +64 -30
  39. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  40. package/dist/src/mcpl/server-connection.js +247 -210
  41. package/dist/src/mcpl/server-connection.js.map +1 -1
  42. package/dist/src/mcpl/transport.d.ts +98 -0
  43. package/dist/src/mcpl/transport.d.ts.map +1 -0
  44. package/dist/src/mcpl/transport.js +281 -0
  45. package/dist/src/mcpl/transport.js.map +1 -0
  46. package/dist/src/mcpl/types.d.ts +59 -3
  47. package/dist/src/mcpl/types.d.ts.map +1 -1
  48. package/dist/src/mcpl/types.js.map +1 -1
  49. package/dist/src/module-registry.d.ts +17 -1
  50. package/dist/src/module-registry.d.ts.map +1 -1
  51. package/dist/src/module-registry.js +101 -6
  52. package/dist/src/module-registry.js.map +1 -1
  53. package/dist/src/prose-segments.d.ts +18 -0
  54. package/dist/src/prose-segments.d.ts.map +1 -0
  55. package/dist/src/prose-segments.js +39 -0
  56. package/dist/src/prose-segments.js.map +1 -0
  57. package/dist/src/runtime/liveness-watchdog-worker.d.ts +2 -0
  58. package/dist/src/runtime/liveness-watchdog-worker.d.ts.map +1 -0
  59. package/dist/src/runtime/liveness-watchdog-worker.js +57 -0
  60. package/dist/src/runtime/liveness-watchdog-worker.js.map +1 -0
  61. package/dist/src/runtime/liveness-watchdog.d.ts +36 -0
  62. package/dist/src/runtime/liveness-watchdog.d.ts.map +1 -0
  63. package/dist/src/runtime/liveness-watchdog.js +106 -0
  64. package/dist/src/runtime/liveness-watchdog.js.map +1 -0
  65. package/dist/src/types/agent.d.ts +47 -0
  66. package/dist/src/types/agent.d.ts.map +1 -1
  67. package/dist/src/types/framework.d.ts +14 -0
  68. package/dist/src/types/framework.d.ts.map +1 -1
  69. package/dist/src/types/module.d.ts +58 -0
  70. package/dist/src/types/module.d.ts.map +1 -1
  71. package/dist/src/types/trace.d.ts +42 -0
  72. package/dist/src/types/trace.d.ts.map +1 -1
  73. package/dist/test/agent-cache-ttl.test.d.ts +2 -0
  74. package/dist/test/agent-cache-ttl.test.d.ts.map +1 -0
  75. package/dist/test/agent-cache-ttl.test.js +25 -0
  76. package/dist/test/agent-cache-ttl.test.js.map +1 -0
  77. package/dist/test/channel-registry-routing.test.js +108 -1
  78. package/dist/test/channel-registry-routing.test.js.map +1 -1
  79. package/dist/test/checkpoint-attribution.test.d.ts +2 -0
  80. package/dist/test/checkpoint-attribution.test.d.ts.map +1 -0
  81. package/dist/test/checkpoint-attribution.test.js +40 -0
  82. package/dist/test/checkpoint-attribution.test.js.map +1 -0
  83. package/dist/test/conversation-fork-injection-scoping.test.d.ts +2 -0
  84. package/dist/test/conversation-fork-injection-scoping.test.d.ts.map +1 -0
  85. package/dist/test/conversation-fork-injection-scoping.test.js +184 -0
  86. package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -0
  87. package/dist/test/event-gate-policy-mutation.test.d.ts +2 -0
  88. package/dist/test/event-gate-policy-mutation.test.d.ts.map +1 -0
  89. package/dist/test/event-gate-policy-mutation.test.js +137 -0
  90. package/dist/test/event-gate-policy-mutation.test.js.map +1 -0
  91. package/dist/test/event-gate-script.test.d.ts +2 -0
  92. package/dist/test/event-gate-script.test.d.ts.map +1 -0
  93. package/dist/test/event-gate-script.test.js +95 -0
  94. package/dist/test/event-gate-script.test.js.map +1 -0
  95. package/dist/test/event-gate-tags.test.d.ts +2 -0
  96. package/dist/test/event-gate-tags.test.d.ts.map +1 -0
  97. package/dist/test/event-gate-tags.test.js +76 -0
  98. package/dist/test/event-gate-tags.test.js.map +1 -0
  99. package/dist/test/gather-context-timeout.test.d.ts +2 -0
  100. package/dist/test/gather-context-timeout.test.d.ts.map +1 -0
  101. package/dist/test/gather-context-timeout.test.js +120 -0
  102. package/dist/test/gather-context-timeout.test.js.map +1 -0
  103. package/dist/test/inference-exhaustion-rewind.test.d.ts +2 -0
  104. package/dist/test/inference-exhaustion-rewind.test.d.ts.map +1 -0
  105. package/dist/test/inference-exhaustion-rewind.test.js +181 -0
  106. package/dist/test/inference-exhaustion-rewind.test.js.map +1 -0
  107. package/dist/test/inference-failure-observability.test.js +4 -0
  108. package/dist/test/inference-failure-observability.test.js.map +1 -1
  109. package/dist/test/liveness-watchdog.test.d.ts +2 -0
  110. package/dist/test/liveness-watchdog.test.d.ts.map +1 -0
  111. package/dist/test/liveness-watchdog.test.js +78 -0
  112. package/dist/test/liveness-watchdog.test.js.map +1 -0
  113. package/dist/test/mcpl-reconnect-events.test.d.ts +2 -0
  114. package/dist/test/mcpl-reconnect-events.test.d.ts.map +1 -0
  115. package/dist/test/mcpl-reconnect-events.test.js +119 -0
  116. package/dist/test/mcpl-reconnect-events.test.js.map +1 -0
  117. package/dist/test/mcpl-reconnect-reregistration.test.d.ts +2 -0
  118. package/dist/test/mcpl-reconnect-reregistration.test.d.ts.map +1 -0
  119. package/dist/test/mcpl-reconnect-reregistration.test.js +181 -0
  120. package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -0
  121. package/dist/test/mcpl-request-timeout.test.d.ts +2 -0
  122. package/dist/test/mcpl-request-timeout.test.d.ts.map +1 -0
  123. package/dist/test/mcpl-request-timeout.test.js +123 -0
  124. package/dist/test/mcpl-request-timeout.test.js.map +1 -0
  125. package/dist/test/mcpl-runtime-lifecycle.test.d.ts +7 -0
  126. package/dist/test/mcpl-runtime-lifecycle.test.d.ts.map +1 -0
  127. package/dist/test/mcpl-runtime-lifecycle.test.js +142 -0
  128. package/dist/test/mcpl-runtime-lifecycle.test.js.map +1 -0
  129. package/dist/test/mcpl-ws-transport.test.d.ts +2 -0
  130. package/dist/test/mcpl-ws-transport.test.d.ts.map +1 -0
  131. package/dist/test/mcpl-ws-transport.test.js +155 -0
  132. package/dist/test/mcpl-ws-transport.test.js.map +1 -0
  133. package/dist/test/prose-segments.test.d.ts +2 -0
  134. package/dist/test/prose-segments.test.d.ts.map +1 -0
  135. package/dist/test/prose-segments.test.js +52 -0
  136. package/dist/test/prose-segments.test.js.map +1 -0
  137. package/dist/test/refusal-rewind.test.d.ts +2 -0
  138. package/dist/test/refusal-rewind.test.d.ts.map +1 -0
  139. package/dist/test/refusal-rewind.test.js +108 -0
  140. package/dist/test/refusal-rewind.test.js.map +1 -0
  141. package/dist/test/state-scaling-e2e.test.d.ts +22 -0
  142. package/dist/test/state-scaling-e2e.test.d.ts.map +1 -0
  143. package/dist/test/state-scaling-e2e.test.js +200 -0
  144. package/dist/test/state-scaling-e2e.test.js.map +1 -0
  145. package/dist/test/state-scaling.test.d.ts +12 -0
  146. package/dist/test/state-scaling.test.d.ts.map +1 -0
  147. package/dist/test/state-scaling.test.js +243 -0
  148. package/dist/test/state-scaling.test.js.map +1 -0
  149. package/dist/test/tool-result-content.test.d.ts +2 -0
  150. package/dist/test/tool-result-content.test.d.ts.map +1 -0
  151. package/dist/test/tool-result-content.test.js +37 -0
  152. package/dist/test/tool-result-content.test.js.map +1 -0
  153. package/dist/test/trunk-channel-routing.test.d.ts +19 -0
  154. package/dist/test/trunk-channel-routing.test.d.ts.map +1 -0
  155. package/dist/test/trunk-channel-routing.test.js +175 -0
  156. package/dist/test/trunk-channel-routing.test.js.map +1 -0
  157. package/dist/tsconfig.tsbuildinfo +1 -1
  158. package/package.json +4 -3
  159. package/src/agent.ts +16 -0
  160. package/src/framework.ts +1576 -121
  161. package/src/gate/event-gate.ts +209 -12
  162. package/src/gate/gate-script-worker.ts +73 -0
  163. package/src/gate/gate-script.ts +180 -0
  164. package/src/gate/types.ts +19 -3
  165. package/src/index.ts +10 -0
  166. package/src/mcpl/channel-registry.ts +143 -8
  167. package/src/mcpl/checkpoint-manager.ts +16 -0
  168. package/src/mcpl/push-handler.ts +5 -0
  169. package/src/mcpl/server-connection.ts +262 -227
  170. package/src/mcpl/transport.ts +307 -0
  171. package/src/mcpl/types.ts +68 -3
  172. package/src/module-registry.ts +126 -6
  173. package/src/prose-segments.ts +42 -0
  174. package/src/runtime/liveness-watchdog-worker.ts +66 -0
  175. package/src/runtime/liveness-watchdog.ts +122 -0
  176. package/src/types/agent.ts +48 -0
  177. package/src/types/framework.ts +15 -0
  178. package/src/types/module.ts +65 -0
  179. package/src/types/trace.ts +51 -0
@@ -1,15 +1,18 @@
1
1
  /**
2
2
  * McplServerConnection — manages a single JSON-RPC 2.0 connection to an MCPL server.
3
3
  *
4
- * Spawns a child process (stdio transport), performs the MCP initialize handshake
5
- * with MCPL capability negotiation, and provides typed methods for all outbound
6
- * MCPL messages plus EventEmitter events for inbound messages.
4
+ * Connects over a pluggable {@link McplTransport} (a spawned stdio child OR a
5
+ * WebSocket), performs the MCP initialize handshake with MCPL capability
6
+ * negotiation, and provides typed methods for all outbound MCPL messages plus
7
+ * EventEmitter events for inbound messages. The handshake, message routing, and
8
+ * every send path are transport-agnostic — they operate on NDJSON lines, so
9
+ * stdio and WebSocket behave identically above the transport seam.
7
10
  */
8
11
 
9
- import { spawn, type ChildProcess } from 'node:child_process';
10
- import { createInterface, type Interface as ReadlineInterface } from 'node:readline';
11
12
  import { EventEmitter } from 'node:events';
12
13
 
14
+ import { openTransport, type McplTransport, type TransportCloseInfo } from './transport.js';
15
+
13
16
  import type {
14
17
  McplServerConfig,
15
18
  McplCapabilities,
@@ -43,6 +46,13 @@ import { McplMethod } from './types.js';
43
46
  * Spring Boot + JDA servers can take 5-10s to boot, so 30s is safe. */
44
47
  const INITIALIZE_TIMEOUT_MS = 30_000;
45
48
 
49
+ /** Default per-request timeout in milliseconds (see McplServerConfig.requestTimeoutMs).
50
+ * A live-but-wedged server (accepts requests, never answers, never closes its
51
+ * transport) would otherwise freeze the awaiting agent turn forever — no
52
+ * endTurn, no further wakes, zero errors anywhere. 60s is generous for real
53
+ * tool work while still bounding the hang. */
54
+ const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
55
+
46
56
  /** MCP protocol version used in the initialize handshake. */
47
57
  const MCP_PROTOCOL_VERSION = '2024-11-05';
48
58
 
@@ -53,6 +63,8 @@ interface PendingRequest {
53
63
  resolve: (result: unknown) => void;
54
64
  reject: (error: Error) => void;
55
65
  method: string;
66
+ /** Per-request timeout timer; cleared on response/close/timeout. */
67
+ timer?: ReturnType<typeof setTimeout>;
56
68
  }
57
69
 
58
70
  /**
@@ -70,6 +82,9 @@ interface PendingRequest {
70
82
  * - `'feature-sets-changed'` — Server sent `featureSets/changed`
71
83
  * - `'error'` — Connection-level error
72
84
  * - `'close'` — Connection closed
85
+ * - `'connect-failed'` — Initial connect failed; background retry scheduled
86
+ * - `'reconnect-failed'` — A background reconnect attempt failed; next retry scheduled
87
+ * - `'reconnect'` — Background reconnect succeeded
73
88
  */
74
89
  export class McplServerConnection extends EventEmitter {
75
90
  /** Unique server identifier from config. */
@@ -78,12 +93,23 @@ export class McplServerConnection extends EventEmitter {
78
93
  /** MCPL capabilities negotiated during the initialize handshake, or null if the server does not support MCPL. */
79
94
  capabilities: McplCapabilities | null;
80
95
 
81
- private process: ChildProcess;
82
- private readline: ReadlineInterface;
96
+ /** The active transport (stdio child or WebSocket). Null for a disconnected
97
+ * stub awaiting its first background reconnect. */
98
+ private transport: McplTransport | null;
99
+
100
+ /** Whether the connection currently has a live transport. False for closed
101
+ * connections and disconnected reconnect-stubs awaiting a retry. */
102
+ get isConnected(): boolean {
103
+ return !this.closed && this.transport !== null;
104
+ }
105
+
83
106
  private nextRequestId = 1;
84
107
  private pendingRequests = new Map<string | number, PendingRequest>();
85
108
  private closed = false;
86
109
 
110
+ /** Per-request timeout in ms (0 disables). See McplServerConfig.requestTimeoutMs. */
111
+ private requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS;
112
+
87
113
  // Event buffering: events emitted before ready() are queued, not lost
88
114
  private readyFlag = false;
89
115
  private bufferedEvents: Array<{ event: string; args: unknown[] }> = [];
@@ -93,10 +119,17 @@ export class McplServerConnection extends EventEmitter {
93
119
  private hostCapabilities: McplHostCapabilities | null = null;
94
120
  private reconnectEnabled = false;
95
121
  private reconnectIntervalMs = 5000;
122
+ private reconnectMaxIntervalMs = 300_000;
96
123
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
124
+ /** Consecutive failed connect attempts since the last successful handshake.
125
+ * Drives the exponential backoff and is reported on reconnect events. */
126
+ private reconnectAttempts = 0;
97
127
 
98
- /** Swaps the line handler on the current child's stderr pipe. Re-bound on reconnect. */
99
- private rebindStderr: ((next: (line: string) => void) => void) | null = null;
128
+ /** Whether a background reconnect loop will revive this connection after a
129
+ * drop. False once close() has been called (explicit close stops retrying). */
130
+ get willReconnect(): boolean {
131
+ return this.reconnectEnabled;
132
+ }
100
133
 
101
134
  /**
102
135
  * Private constructor. Use `McplServerConnection.connect()` instead.
@@ -104,19 +137,16 @@ export class McplServerConnection extends EventEmitter {
104
137
  private constructor(
105
138
  id: string,
106
139
  capabilities: McplCapabilities | null,
107
- childProcess: ChildProcess,
108
- readline: ReadlineInterface,
140
+ transport: McplTransport | null,
109
141
  ) {
110
142
  super();
111
143
  this.id = id;
112
144
  this.capabilities = capabilities;
113
- this.process = childProcess;
114
- this.readline = readline;
145
+ this.transport = transport;
115
146
 
116
- // Skip setup for disconnected stubs (process/readline are null)
117
- if (childProcess && readline) {
118
- this.setupMessageRouting();
119
- this.setupLifecycle();
147
+ // Skip setup for disconnected stubs (transport is null).
148
+ if (transport) {
149
+ this.wireTransport(transport);
120
150
  }
121
151
  }
122
152
 
@@ -155,159 +185,115 @@ export class McplServerConnection extends EventEmitter {
155
185
  // ==========================================================================
156
186
 
157
187
  /**
158
- * Wire stderr forwarding on a child process. Lines are forwarded to the
159
- * supplied callback. The callback can be swapped at any time via `rebind`
160
- * (used during reconnect, when we want already-wired pipes to target a new
161
- * emitter).
162
- */
163
- private static wireStderrForwarding(
164
- child: ChildProcess,
165
- initialLineHandler: (line: string) => void,
166
- ): { rebind: (next: (line: string) => void) => void } {
167
- let onLine = initialLineHandler;
168
- let carry = '';
169
- child.stderr?.on('data', (chunk: Buffer) => {
170
- const text = carry + chunk.toString('utf8');
171
- const lines = text.split('\n');
172
- carry = lines.pop() ?? '';
173
- for (const line of lines) {
174
- if (line.length > 0) onLine(line);
175
- }
176
- });
177
- return {
178
- rebind(next) {
179
- onLine = next;
180
- },
181
- };
182
- }
183
-
184
- /**
185
- * Spawn a server process, perform the MCP initialize handshake with MCPL
188
+ * Open the transport, perform the MCP initialize handshake with MCPL
186
189
  * capability negotiation, and return a ready-to-use connection.
187
190
  *
188
- * Throws if the process cannot be spawned or the handshake times out.
191
+ * Works over stdio (spawned child) or WebSocket, selected by `config` (see
192
+ * {@link openTransport}). Throws if the transport can't be opened, the server
193
+ * closes/errors before the handshake, or the handshake times out.
189
194
  */
190
195
  static async connect(
191
196
  config: McplServerConfig,
192
197
  hostCapabilities: McplHostCapabilities,
193
198
  ): Promise<McplServerConnection> {
194
- const child = spawn(config.command, config.args ?? [], {
195
- stdio: ['pipe', 'pipe', 'pipe'],
196
- env: { ...process.env, ...config.env },
197
- });
199
+ const { transport, capabilities } = await McplServerConnection.handshake(config, hostCapabilities);
198
200
 
199
- // Capture child stderr so it isn't silently dropped. During the handshake
200
- // window (before the instance exists) lines are buffered; the buffer is
201
- // drained once we have an instance to emit 'stderr' events on.
202
- const earlyStderrBuffer: string[] = [];
203
- const attachStderr = McplServerConnection.wireStderrForwarding(child, (line) =>
204
- earlyStderrBuffer.push(line),
205
- );
206
-
207
- // Fail fast if the process exits before the handshake completes
208
- const earlyExitPromise = new Promise<never>((_resolve, reject) => {
209
- child.on('error', (err) => {
210
- reject(new Error(`Failed to spawn MCPL server "${config.id}": ${err.message}`));
211
- });
212
- child.on('exit', (code) => {
213
- reject(new Error(`MCPL server "${config.id}" exited before handshake (code ${code})`));
214
- });
215
- });
201
+ const connection = new McplServerConnection(config.id, capabilities, transport);
202
+ connection.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
216
203
 
217
- // Set up readline for newline-delimited JSON on stdout
218
- const rl = createInterface({ input: child.stdout! });
204
+ // Store config for reconnection
205
+ if (config.reconnect) {
206
+ connection.config = config;
207
+ connection.hostCapabilities = hostCapabilities;
208
+ connection.reconnectEnabled = true;
209
+ connection.reconnectIntervalMs = config.reconnectIntervalMs ?? 5000;
210
+ connection.reconnectMaxIntervalMs = config.reconnectMaxIntervalMs ?? 300_000;
211
+ }
219
212
 
220
- // -----------------------------------------------------------------------
221
- // Perform initialize handshake
222
- // -----------------------------------------------------------------------
213
+ return connection;
214
+ }
223
215
 
224
- // Step 1: Send `initialize` request
225
- const initId = 0; // use id=0 for the handshake
226
- const initRequest: JsonRpcRequest = {
227
- jsonrpc: '2.0',
228
- method: 'initialize',
229
- id: initId,
230
- params: {
231
- protocolVersion: MCP_PROTOCOL_VERSION,
232
- capabilities: {
233
- experimental: {
234
- mcpl: hostCapabilities,
235
- },
236
- },
237
- clientInfo: {
238
- name: 'agent-framework',
239
- version: '1.0.0',
240
- },
241
- },
242
- };
243
- child.stdin!.write(JSON.stringify(initRequest) + '\n');
216
+ /**
217
+ * Open a fresh transport and run the MCP/MCPL initialize handshake over it,
218
+ * returning the connected transport plus the negotiated capabilities. Shared
219
+ * by {@link connect} and {@link attemptReconnect} so both transports handshake
220
+ * identically. On any failure the transport is closed so a spawned child /
221
+ * open socket never leaks.
222
+ */
223
+ private static async handshake(
224
+ config: McplServerConfig,
225
+ hostCapabilities: McplHostCapabilities,
226
+ ): Promise<{ transport: McplTransport; capabilities: McplCapabilities | null }> {
227
+ const transport = await openTransport(config);
244
228
 
245
- // Step 2: Wait for the initialize response
246
- const initResponse = await Promise.race([
247
- new Promise<JsonRpcResponse>((resolve, reject) => {
229
+ try {
230
+ const initId = 0; // use id=0 for the handshake
231
+ const initRequest: JsonRpcRequest = {
232
+ jsonrpc: '2.0',
233
+ method: 'initialize',
234
+ id: initId,
235
+ params: {
236
+ protocolVersion: MCP_PROTOCOL_VERSION,
237
+ capabilities: { experimental: { mcpl: hostCapabilities } },
238
+ clientInfo: { name: 'agent-framework', version: '1.0.0' },
239
+ },
240
+ };
241
+
242
+ // Await the initialize response, racing an early transport close/error and
243
+ // a timeout. All three paths clean up their listeners.
244
+ const capabilities = await new Promise<McplCapabilities | null>((resolve, reject) => {
245
+ const cleanup = () => {
246
+ transport.off('line', onLine);
247
+ transport.off('close', onClose);
248
+ transport.off('error', onError);
249
+ clearTimeout(timer);
250
+ };
248
251
  const onLine = (line: string) => {
252
+ let msg: JsonRpcResponse;
249
253
  try {
250
- const msg = JSON.parse(line) as JsonRpcResponse;
251
- if (msg.id === initId) {
252
- rl.off('line', onLine);
253
- if (msg.error) {
254
- reject(new Error(`MCPL server "${config.id}" initialize error: ${msg.error.message}`));
255
- } else {
256
- resolve(msg);
257
- }
258
- }
254
+ msg = JSON.parse(line) as JsonRpcResponse;
259
255
  } catch {
260
- // Ignore non-JSON lines (e.g. logback output from Java servers)
256
+ return; // Ignore non-JSON lines (e.g. logback output from Java servers)
257
+ }
258
+ if (msg.id !== initId) return;
259
+ cleanup();
260
+ if (msg.error) {
261
+ reject(new Error(`MCPL server "${config.id}" initialize error: ${msg.error.message}`));
262
+ return;
261
263
  }
264
+ const result = msg.result as Record<string, unknown> | undefined;
265
+ const caps = result?.capabilities as Record<string, unknown> | undefined;
266
+ const experimental = caps?.experimental as Record<string, unknown> | undefined;
267
+ resolve((experimental?.mcpl as McplCapabilities) ?? null);
268
+ };
269
+ const onClose = (info: TransportCloseInfo) => {
270
+ cleanup();
271
+ reject(new Error(`MCPL server "${config.id}" transport closed before handshake completed (${info.reason ?? 'unknown'})`));
272
+ };
273
+ const onError = (err: Error) => {
274
+ cleanup();
275
+ reject(new Error(`MCPL server "${config.id}" transport error during handshake: ${err.message}`));
262
276
  };
263
- rl.on('line', onLine);
264
- }),
265
- earlyExitPromise,
266
- new Promise<never>((_resolve, reject) =>
267
- setTimeout(() => {
277
+ const timer = setTimeout(() => {
278
+ cleanup();
268
279
  reject(new Error(`MCPL server "${config.id}" initialize handshake timed out`));
269
- }, INITIALIZE_TIMEOUT_MS),
270
- ),
271
- ]);
272
-
273
- // Parse MCPL capabilities from the response
274
- const result = initResponse.result as Record<string, unknown> | undefined;
275
- const caps = result?.capabilities as Record<string, unknown> | undefined;
276
- const experimental = caps?.experimental as Record<string, unknown> | undefined;
277
- const mcplCaps = (experimental?.mcpl as McplCapabilities) ?? null;
278
-
279
- // Step 3: Send `initialized` notification (no id)
280
- const initializedNotification: JsonRpcRequest = {
281
- jsonrpc: '2.0',
282
- method: 'notifications/initialized',
283
- };
284
- child.stdin!.write(JSON.stringify(initializedNotification) + '\n');
285
-
286
- // Remove the early-exit listener so normal close handling takes over
287
- child.removeAllListeners('exit');
288
- child.removeAllListeners('error');
280
+ }, INITIALIZE_TIMEOUT_MS);
289
281
 
290
- const connection = new McplServerConnection(config.id, mcplCaps, child, rl);
282
+ transport.on('line', onLine);
283
+ transport.on('close', onClose);
284
+ transport.on('error', onError);
285
+ transport.writeLine(JSON.stringify(initRequest));
286
+ });
291
287
 
292
- // Swap the stderr handler to emit events on the instance, then drain
293
- // anything that arrived during the handshake window. Keep the rebind
294
- // handle on the instance so reconnect can re-target it later.
295
- connection.rebindStderr = attachStderr.rebind;
296
- attachStderr.rebind((line: string) => connection.emit('stderr', { line }));
297
- for (const line of earlyStderrBuffer) {
298
- connection.emit('stderr', { line });
299
- }
300
- earlyStderrBuffer.length = 0;
288
+ // Send `initialized` notification (no id)
289
+ transport.writeLine(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }));
301
290
 
302
- // Store config for reconnection
303
- if (config.reconnect) {
304
- connection.config = config;
305
- connection.hostCapabilities = hostCapabilities;
306
- connection.reconnectEnabled = true;
307
- connection.reconnectIntervalMs = config.reconnectIntervalMs ?? 5000;
291
+ return { transport, capabilities };
292
+ } catch (err) {
293
+ // Never leak the child / socket if the handshake fails.
294
+ await transport.close().catch(() => { /* best effort */ });
295
+ throw err;
308
296
  }
309
-
310
- return connection;
311
297
  }
312
298
 
313
299
  /**
@@ -329,7 +315,12 @@ export class McplServerConnection extends EventEmitter {
329
315
 
330
316
  // Non-blocking start: create a disconnected stub that will reconnect in background
331
317
  console.error(`MCPL server "${config.id}" initial connect failed, will retry:`, (error as Error).message);
332
- return McplServerConnection.createDisconnectedStub(config, hostCapabilities);
318
+ const stub = McplServerConnection.createDisconnectedStub(config, hostCapabilities);
319
+ // Surface the initial failure. The event is buffered by the emit()
320
+ // override until the host wires listeners and calls ready(), so it is
321
+ // not lost in the window before wireMcplEvents runs.
322
+ stub.emit('connect-failed', { error: (error as Error).message, attempt: 0 });
323
+ return stub;
333
324
  }
334
325
  }
335
326
 
@@ -342,13 +333,16 @@ export class McplServerConnection extends EventEmitter {
342
333
  config: McplServerConfig,
343
334
  hostCapabilities: McplHostCapabilities,
344
335
  ): McplServerConnection {
345
- // Use null! for process/readline since the connection is closed
346
- const stub = new McplServerConnection(config.id, null, null! as ChildProcess, null! as ReadlineInterface);
336
+ // Null transport — the stub is closed until its first background reconnect.
337
+ const stub = new McplServerConnection(config.id, null, null);
347
338
  stub.closed = true;
339
+ stub.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
348
340
  stub.config = config;
349
341
  stub.hostCapabilities = hostCapabilities;
350
342
  stub.reconnectEnabled = true;
351
343
  stub.reconnectIntervalMs = config.reconnectIntervalMs ?? 5000;
344
+ stub.reconnectMaxIntervalMs = config.reconnectMaxIntervalMs ?? 300_000;
345
+ stub.reconnectAttempts = 1; // the initial connect already failed
352
346
 
353
347
  // Schedule background reconnect
354
348
  stub.scheduleReconnect();
@@ -356,18 +350,6 @@ export class McplServerConnection extends EventEmitter {
356
350
  return stub;
357
351
  }
358
352
 
359
- /**
360
- * Extract internals for transfer during reconnection.
361
- * Strips the source connection — caller takes ownership of the process/readline.
362
- * @internal Used only by attemptReconnect().
363
- */
364
- extractInternals(): { process: ChildProcess; readline: ReadlineInterface } {
365
- const result = { process: this.process, readline: this.readline };
366
- // Prevent the source connection from killing the transferred process
367
- this.closed = true;
368
- return result;
369
- }
370
-
371
353
  // ==========================================================================
372
354
  // Outbound requests (return Promises)
373
355
  // ==========================================================================
@@ -501,29 +483,16 @@ export class McplServerConnection extends EventEmitter {
501
483
 
502
484
  // Reject all pending requests
503
485
  for (const [id, pending] of this.pendingRequests) {
486
+ if (pending.timer) clearTimeout(pending.timer);
504
487
  pending.reject(new Error(`Connection to MCPL server "${this.id}" closed while awaiting response for ${pending.method} (id=${id})`));
505
488
  }
506
489
  this.pendingRequests.clear();
507
490
 
508
- // Close readline
509
- if (this.readline) {
510
- this.readline.close();
511
- }
512
-
513
- // Kill the child process
514
- if (this.process && !this.process.killed) {
515
- this.process.kill();
516
- }
517
-
518
- // Wait for process to actually exit
519
- if (this.process) {
520
- await new Promise<void>((resolve) => {
521
- if (this.process.exitCode !== null || this.process.killed) {
522
- resolve();
523
- } else {
524
- this.process.once('exit', () => resolve());
525
- }
526
- });
491
+ // Tear down the transport (kills the child / closes the socket). The
492
+ // transport's own 'close' event is short-circuited by `this.closed` in
493
+ // setupLifecycle, so this is the single source of the connection 'close'.
494
+ if (this.transport) {
495
+ await this.transport.close();
527
496
  }
528
497
 
529
498
  this.emit('close');
@@ -531,58 +500,65 @@ export class McplServerConnection extends EventEmitter {
531
500
 
532
501
  /**
533
502
  * Schedule a background reconnection attempt.
503
+ *
504
+ * Delay grows exponentially with consecutive failures (base
505
+ * `reconnectIntervalMs`, doubling per failure, capped at
506
+ * `reconnectMaxIntervalMs`) with ±25% jitter so a fleet of servers that
507
+ * died together doesn't thundering-herd the same instant. The counter
508
+ * resets on a successful handshake, so a later disconnect starts over at
509
+ * the base interval.
534
510
  */
535
511
  private scheduleReconnect(): void {
536
512
  if (!this.reconnectEnabled || this.reconnectTimer) return;
537
513
 
514
+ const exponent = Math.max(0, Math.min(this.reconnectAttempts - 1, 30));
515
+ const uncapped = this.reconnectIntervalMs * 2 ** exponent;
516
+ const capped = Math.min(uncapped, this.reconnectMaxIntervalMs);
517
+ const delay = capped * (0.75 + Math.random() * 0.5);
518
+
538
519
  this.reconnectTimer = setTimeout(() => {
539
520
  this.reconnectTimer = null;
540
521
  this.attemptReconnect();
541
- }, this.reconnectIntervalMs);
522
+ }, delay);
542
523
  }
543
524
 
544
525
  /**
545
- * Attempt to reconnect by spawning a new process and performing the handshake.
526
+ * Attempt to reconnect by opening a fresh transport and performing the
527
+ * handshake, then adopting that transport in place. Unlike the previous
528
+ * approach this builds no throwaway connection instance, so there are no
529
+ * leaked listeners on a dead object.
546
530
  */
547
531
  private async attemptReconnect(): Promise<void> {
548
532
  if (!this.reconnectEnabled || !this.config || !this.hostCapabilities) return;
549
533
 
534
+ // Ordinal of this attempt: 0 was the initial connect, so the Nth retry
535
+ // reports attempt N (reconnectAttempts counts failures so far).
536
+ const attempt = Math.max(1, this.reconnectAttempts);
537
+
550
538
  try {
551
- const fresh = await McplServerConnection.connect(this.config, this.hostCapabilities);
552
- const internals = fresh.extractInternals();
539
+ const { transport, capabilities } = await McplServerConnection.handshake(
540
+ this.config,
541
+ this.hostCapabilities,
542
+ );
553
543
 
554
- // Transfer state from fresh connection to this instance
555
- this.process = internals.process;
556
- this.readline = internals.readline;
557
- this.capabilities = fresh.capabilities;
544
+ // Adopt the new transport. Listeners were attached to external consumers
545
+ // on the first wire, so mark ready immediately (re-wire re-binds the
546
+ // transport-level handlers below).
547
+ this.transport = transport;
548
+ this.capabilities = capabilities;
558
549
  this.closed = false;
559
550
  this.nextRequestId = 1;
560
551
  this.pendingRequests.clear();
561
-
562
- // Re-target stderr forwarding from the throwaway `fresh` instance to `this`.
563
- //
564
- // Note: this follows the same "transfer internals, leak listeners on the
565
- // dead instance" pattern as the message-routing and lifecycle rewiring
566
- // below — `fresh`'s own readline 'line' handler and process 'exit'
567
- // handler stay registered on the transferred child, short-circuited by
568
- // `fresh.closed = true` set in extractInternals(). Rebinding fresh's
569
- // stderr listener (rather than removing it) keeps that invariant: if we
570
- // ever clean up the message-routing duplication, fresh's stderr data
571
- // listener closure-holds onLine and would need the same teardown.
572
- if (fresh.rebindStderr) {
573
- fresh.rebindStderr((line: string) => this.emit('stderr', { line }));
574
- this.rebindStderr = fresh.rebindStderr;
575
- }
576
-
577
- // Re-wire message routing and lifecycle on the new process
578
- this.setupMessageRouting();
579
- this.setupLifecycle();
580
- this.readyFlag = true; // Listeners already attached from initial wire
552
+ this.wireTransport(transport);
553
+ this.readyFlag = true;
581
554
 
582
555
  console.error(`MCPL server "${this.id}" reconnected successfully`);
583
- this.emit('reconnect');
556
+ this.reconnectAttempts = 0;
557
+ this.emit('reconnect', { attempts: attempt });
584
558
  } catch (error) {
585
559
  console.error(`MCPL server "${this.id}" reconnect failed:`, (error as Error).message);
560
+ this.reconnectAttempts = attempt + 1;
561
+ this.emit('reconnect-failed', { error: (error as Error).message, attempt });
586
562
  this.scheduleReconnect();
587
563
  }
588
564
  }
@@ -594,6 +570,13 @@ export class McplServerConnection extends EventEmitter {
594
570
  /**
595
571
  * Send a JSON-RPC request and return a promise that resolves with the result
596
572
  * or rejects with a JSON-RPC error.
573
+ *
574
+ * Every request carries a timeout (default 60s, configurable via
575
+ * McplServerConfig.requestTimeoutMs, 0 disables): a live-but-stuck server
576
+ * that accepts a request and never answers rejects the pending promise with
577
+ * a descriptive error instead of freezing the caller forever. For tools/call
578
+ * the framework maps the rejection to an isError tool_result, so a hung tool
579
+ * surfaces as a normal tool error and the turn completes.
597
580
  */
598
581
  private sendRequest(method: string, params: Record<string, unknown>): Promise<unknown> {
599
582
  if (this.closed) {
@@ -609,8 +592,24 @@ export class McplServerConnection extends EventEmitter {
609
592
  };
610
593
 
611
594
  return new Promise((resolve, reject) => {
612
- this.pendingRequests.set(id, { resolve, reject, method });
613
- this.process.stdin!.write(JSON.stringify(request) + '\n');
595
+ const pending: PendingRequest = { resolve, reject, method };
596
+ if (this.requestTimeoutMs > 0) {
597
+ pending.timer = setTimeout(() => {
598
+ this.pendingRequests.delete(id);
599
+ reject(new Error(
600
+ `MCPL server "${this.id}" did not respond to ${method} (id=${id}) ` +
601
+ `within ${this.requestTimeoutMs}ms — the server may be hung. ` +
602
+ `The request was abandoned; the connection remains open. Note the ` +
603
+ `tool may still have completed server-side (this is only a response ` +
604
+ `timeout, not a cancellation) — verify state before retrying, as a ` +
605
+ `blind retry of a stateful/side-effecting tool may duplicate it.`,
606
+ ));
607
+ }, this.requestTimeoutMs);
608
+ // Don't hold the event loop open for the watchdog alone.
609
+ pending.timer.unref?.();
610
+ }
611
+ this.pendingRequests.set(id, pending);
612
+ this.transport!.writeLine(JSON.stringify(request));
614
613
  });
615
614
  }
616
615
 
@@ -627,7 +626,7 @@ export class McplServerConnection extends EventEmitter {
627
626
  method,
628
627
  params,
629
628
  };
630
- this.process.stdin!.write(JSON.stringify(notification) + '\n');
629
+ this.transport?.writeLine(JSON.stringify(notification));
631
630
  }
632
631
 
633
632
  // ==========================================================================
@@ -653,10 +652,23 @@ export class McplServerConnection extends EventEmitter {
653
652
  };
654
653
 
655
654
  /**
656
- * Wire up readline to route incoming JSON-RPC messages.
655
+ * Bind all transport-level handlers (inbound routing, lifecycle, stderr) to a
656
+ * transport. Called from the constructor and again from attemptReconnect when
657
+ * a new transport is adopted.
658
+ */
659
+ private wireTransport(transport: McplTransport): void {
660
+ this.setupMessageRouting(transport);
661
+ this.setupLifecycle(transport);
662
+ // Surface transport diagnostics (stdio child stderr) as 'stderr' events,
663
+ // preserving the `{ line }` payload existing consumers expect.
664
+ transport.on('stderr', (line: string) => this.emit('stderr', { line }));
665
+ }
666
+
667
+ /**
668
+ * Wire up the transport's line stream to route incoming JSON-RPC messages.
657
669
  */
658
- private setupMessageRouting(): void {
659
- this.readline.on('line', (line) => {
670
+ private setupMessageRouting(transport: McplTransport): void {
671
+ transport.on('line', (line: string) => {
660
672
  let msg: JsonRpcRequest | JsonRpcResponse;
661
673
  try {
662
674
  msg = JSON.parse(line);
@@ -699,10 +711,30 @@ export class McplServerConnection extends EventEmitter {
699
711
  private handleResponse(response: JsonRpcResponse): void {
700
712
  const pending = this.pendingRequests.get(response.id);
701
713
  if (!pending) {
714
+ // Orphaned response — the request already timed out (or was settled) and
715
+ // was removed from pendingRequests. Stateless late results are harmless to
716
+ // drop, but a late STATEFUL result carries a `state`/`checkpoint` the
717
+ // server has already advanced to: dropping it silently diverges the host's
718
+ // checkpoint tree from the server's, and every subsequent call then sends
719
+ // stale state. We can't safely re-inject it here (the dispatch context is
720
+ // gone), but we surface it so the divergence is greppable instead of
721
+ // invisible.
722
+ const result = response.result;
723
+ if (result && typeof result === 'object') {
724
+ const r = result as { state?: unknown; checkpoint?: unknown };
725
+ if (r.state !== undefined || r.checkpoint !== undefined) {
726
+ this.emit('orphaned-response', {
727
+ id: response.id,
728
+ hadState: r.state !== undefined,
729
+ hadCheckpoint: r.checkpoint !== undefined,
730
+ });
731
+ }
732
+ }
702
733
  return; // Orphaned response — ignore
703
734
  }
704
735
 
705
736
  this.pendingRequests.delete(response.id);
737
+ if (pending.timer) clearTimeout(pending.timer);
706
738
 
707
739
  if (response.error) {
708
740
  pending.reject(
@@ -719,7 +751,7 @@ export class McplServerConnection extends EventEmitter {
719
751
  private sendResponse(id: string | number, result: unknown): void {
720
752
  if (this.closed) return;
721
753
  const response: JsonRpcResponse = { jsonrpc: '2.0', id, result };
722
- this.process.stdin!.write(JSON.stringify(response) + '\n');
754
+ this.transport?.writeLine(JSON.stringify(response));
723
755
  }
724
756
 
725
757
  /**
@@ -732,7 +764,7 @@ export class McplServerConnection extends EventEmitter {
732
764
  id,
733
765
  error: { code, message, data },
734
766
  };
735
- this.process.stdin!.write(JSON.stringify(response) + '\n');
767
+ this.transport?.writeLine(JSON.stringify(response));
736
768
  }
737
769
 
738
770
  // ==========================================================================
@@ -740,30 +772,33 @@ export class McplServerConnection extends EventEmitter {
740
772
  // ==========================================================================
741
773
 
742
774
  /**
743
- * Set up process error/exit handlers.
775
+ * Set up transport error/close handlers. An unexpected transport close
776
+ * (child exit or WebSocket drop) rejects in-flight requests, emits 'close',
777
+ * and — when enabled — schedules a background reconnect.
744
778
  */
745
- private setupLifecycle(): void {
746
- this.process.on('error', (err) => {
747
- this.emit('error', new Error(`MCPL server "${this.id}" process error: ${err.message}`));
779
+ private setupLifecycle(transport: McplTransport): void {
780
+ transport.on('error', (err: Error) => {
781
+ this.emit('error', new Error(`MCPL server "${this.id}" transport error: ${err.message}`));
748
782
  });
749
783
 
750
- this.process.on('exit', (code, signal) => {
784
+ transport.on('close', (info: TransportCloseInfo) => {
751
785
  if (!this.closed) {
752
786
  this.closed = true;
753
787
 
754
788
  // Reject all pending requests
755
789
  for (const [id, pending] of this.pendingRequests) {
790
+ if (pending.timer) clearTimeout(pending.timer);
756
791
  pending.reject(
757
792
  new Error(
758
- `MCPL server "${this.id}" exited unexpectedly (code=${code}, signal=${signal}) while awaiting ${pending.method} (id=${id})`,
793
+ `MCPL server "${this.id}" disconnected unexpectedly (code=${info.code ?? 'n/a'}, signal=${info.signal ?? 'n/a'}, reason=${info.reason ?? 'unknown'}) while awaiting ${pending.method} (id=${id})`,
759
794
  ),
760
795
  );
761
796
  }
762
797
  this.pendingRequests.clear();
763
798
 
764
- this.emit('close', code, signal);
799
+ this.emit('close', info.code ?? null, info.signal ?? null);
765
800
 
766
- // Schedule reconnect if enabled (unexpected exit triggers auto-reconnect)
801
+ // Schedule reconnect if enabled (unexpected disconnect triggers auto-reconnect)
767
802
  if (this.reconnectEnabled) {
768
803
  this.scheduleReconnect();
769
804
  }