@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,17 +1,25 @@
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
- import { spawn } from 'node:child_process';
9
- import { createInterface } from 'node:readline';
10
11
  import { EventEmitter } from 'node:events';
12
+ import { openTransport } from './transport.js';
11
13
  import { McplMethod } from './types.js';
12
14
  /** Timeout for the initialize handshake in milliseconds.
13
15
  * Spring Boot + JDA servers can take 5-10s to boot, so 30s is safe. */
14
16
  const INITIALIZE_TIMEOUT_MS = 30_000;
17
+ /** Default per-request timeout in milliseconds (see McplServerConfig.requestTimeoutMs).
18
+ * A live-but-wedged server (accepts requests, never answers, never closes its
19
+ * transport) would otherwise freeze the awaiting agent turn forever — no
20
+ * endTurn, no further wakes, zero errors anywhere. 60s is generous for real
21
+ * tool work while still bounding the hang. */
22
+ const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
15
23
  /** MCP protocol version used in the initialize handshake. */
16
24
  const MCP_PROTOCOL_VERSION = '2024-11-05';
17
25
  /**
@@ -29,17 +37,28 @@ const MCP_PROTOCOL_VERSION = '2024-11-05';
29
37
  * - `'feature-sets-changed'` — Server sent `featureSets/changed`
30
38
  * - `'error'` — Connection-level error
31
39
  * - `'close'` — Connection closed
40
+ * - `'connect-failed'` — Initial connect failed; background retry scheduled
41
+ * - `'reconnect-failed'` — A background reconnect attempt failed; next retry scheduled
42
+ * - `'reconnect'` — Background reconnect succeeded
32
43
  */
33
44
  export class McplServerConnection extends EventEmitter {
34
45
  /** Unique server identifier from config. */
35
46
  id;
36
47
  /** MCPL capabilities negotiated during the initialize handshake, or null if the server does not support MCPL. */
37
48
  capabilities;
38
- process;
39
- readline;
49
+ /** The active transport (stdio child or WebSocket). Null for a disconnected
50
+ * stub awaiting its first background reconnect. */
51
+ transport;
52
+ /** Whether the connection currently has a live transport. False for closed
53
+ * connections and disconnected reconnect-stubs awaiting a retry. */
54
+ get isConnected() {
55
+ return !this.closed && this.transport !== null;
56
+ }
40
57
  nextRequestId = 1;
41
58
  pendingRequests = new Map();
42
59
  closed = false;
60
+ /** Per-request timeout in ms (0 disables). See McplServerConfig.requestTimeoutMs. */
61
+ requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS;
43
62
  // Event buffering: events emitted before ready() are queued, not lost
44
63
  readyFlag = false;
45
64
  bufferedEvents = [];
@@ -48,22 +67,27 @@ export class McplServerConnection extends EventEmitter {
48
67
  hostCapabilities = null;
49
68
  reconnectEnabled = false;
50
69
  reconnectIntervalMs = 5000;
70
+ reconnectMaxIntervalMs = 300_000;
51
71
  reconnectTimer = null;
52
- /** Swaps the line handler on the current child's stderr pipe. Re-bound on reconnect. */
53
- rebindStderr = null;
72
+ /** Consecutive failed connect attempts since the last successful handshake.
73
+ * Drives the exponential backoff and is reported on reconnect events. */
74
+ reconnectAttempts = 0;
75
+ /** Whether a background reconnect loop will revive this connection after a
76
+ * drop. False once close() has been called (explicit close stops retrying). */
77
+ get willReconnect() {
78
+ return this.reconnectEnabled;
79
+ }
54
80
  /**
55
81
  * Private constructor. Use `McplServerConnection.connect()` instead.
56
82
  */
57
- constructor(id, capabilities, childProcess, readline) {
83
+ constructor(id, capabilities, transport) {
58
84
  super();
59
85
  this.id = id;
60
86
  this.capabilities = capabilities;
61
- this.process = childProcess;
62
- this.readline = readline;
63
- // Skip setup for disconnected stubs (process/readline are null)
64
- if (childProcess && readline) {
65
- this.setupMessageRouting();
66
- this.setupLifecycle();
87
+ this.transport = transport;
88
+ // Skip setup for disconnected stubs (transport is null).
89
+ if (transport) {
90
+ this.wireTransport(transport);
67
91
  }
68
92
  }
69
93
  // ==========================================================================
@@ -97,138 +121,103 @@ export class McplServerConnection extends EventEmitter {
97
121
  // Static factory
98
122
  // ==========================================================================
99
123
  /**
100
- * Wire stderr forwarding on a child process. Lines are forwarded to the
101
- * supplied callback. The callback can be swapped at any time via `rebind`
102
- * (used during reconnect, when we want already-wired pipes to target a new
103
- * emitter).
104
- */
105
- static wireStderrForwarding(child, initialLineHandler) {
106
- let onLine = initialLineHandler;
107
- let carry = '';
108
- child.stderr?.on('data', (chunk) => {
109
- const text = carry + chunk.toString('utf8');
110
- const lines = text.split('\n');
111
- carry = lines.pop() ?? '';
112
- for (const line of lines) {
113
- if (line.length > 0)
114
- onLine(line);
115
- }
116
- });
117
- return {
118
- rebind(next) {
119
- onLine = next;
120
- },
121
- };
122
- }
123
- /**
124
- * Spawn a server process, perform the MCP initialize handshake with MCPL
124
+ * Open the transport, perform the MCP initialize handshake with MCPL
125
125
  * capability negotiation, and return a ready-to-use connection.
126
126
  *
127
- * Throws if the process cannot be spawned or the handshake times out.
127
+ * Works over stdio (spawned child) or WebSocket, selected by `config` (see
128
+ * {@link openTransport}). Throws if the transport can't be opened, the server
129
+ * closes/errors before the handshake, or the handshake times out.
128
130
  */
129
131
  static async connect(config, hostCapabilities) {
130
- const child = spawn(config.command, config.args ?? [], {
131
- stdio: ['pipe', 'pipe', 'pipe'],
132
- env: { ...process.env, ...config.env },
133
- });
134
- // Capture child stderr so it isn't silently dropped. During the handshake
135
- // window (before the instance exists) lines are buffered; the buffer is
136
- // drained once we have an instance to emit 'stderr' events on.
137
- const earlyStderrBuffer = [];
138
- const attachStderr = McplServerConnection.wireStderrForwarding(child, (line) => earlyStderrBuffer.push(line));
139
- // Fail fast if the process exits before the handshake completes
140
- const earlyExitPromise = new Promise((_resolve, reject) => {
141
- child.on('error', (err) => {
142
- reject(new Error(`Failed to spawn MCPL server "${config.id}": ${err.message}`));
143
- });
144
- child.on('exit', (code) => {
145
- reject(new Error(`MCPL server "${config.id}" exited before handshake (code ${code})`));
146
- });
147
- });
148
- // Set up readline for newline-delimited JSON on stdout
149
- const rl = createInterface({ input: child.stdout });
150
- // -----------------------------------------------------------------------
151
- // Perform initialize handshake
152
- // -----------------------------------------------------------------------
153
- // Step 1: Send `initialize` request
154
- const initId = 0; // use id=0 for the handshake
155
- const initRequest = {
156
- jsonrpc: '2.0',
157
- method: 'initialize',
158
- id: initId,
159
- params: {
160
- protocolVersion: MCP_PROTOCOL_VERSION,
161
- capabilities: {
162
- experimental: {
163
- mcpl: hostCapabilities,
164
- },
165
- },
166
- clientInfo: {
167
- name: 'agent-framework',
168
- version: '1.0.0',
132
+ const { transport, capabilities } = await McplServerConnection.handshake(config, hostCapabilities);
133
+ const connection = new McplServerConnection(config.id, capabilities, transport);
134
+ connection.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
135
+ // Store config for reconnection
136
+ if (config.reconnect) {
137
+ connection.config = config;
138
+ connection.hostCapabilities = hostCapabilities;
139
+ connection.reconnectEnabled = true;
140
+ connection.reconnectIntervalMs = config.reconnectIntervalMs ?? 5000;
141
+ connection.reconnectMaxIntervalMs = config.reconnectMaxIntervalMs ?? 300_000;
142
+ }
143
+ return connection;
144
+ }
145
+ /**
146
+ * Open a fresh transport and run the MCP/MCPL initialize handshake over it,
147
+ * returning the connected transport plus the negotiated capabilities. Shared
148
+ * by {@link connect} and {@link attemptReconnect} so both transports handshake
149
+ * identically. On any failure the transport is closed so a spawned child /
150
+ * open socket never leaks.
151
+ */
152
+ static async handshake(config, hostCapabilities) {
153
+ const transport = await openTransport(config);
154
+ try {
155
+ const initId = 0; // use id=0 for the handshake
156
+ const initRequest = {
157
+ jsonrpc: '2.0',
158
+ method: 'initialize',
159
+ id: initId,
160
+ params: {
161
+ protocolVersion: MCP_PROTOCOL_VERSION,
162
+ capabilities: { experimental: { mcpl: hostCapabilities } },
163
+ clientInfo: { name: 'agent-framework', version: '1.0.0' },
169
164
  },
170
- },
171
- };
172
- child.stdin.write(JSON.stringify(initRequest) + '\n');
173
- // Step 2: Wait for the initialize response
174
- const initResponse = await Promise.race([
175
- new Promise((resolve, reject) => {
165
+ };
166
+ // Await the initialize response, racing an early transport close/error and
167
+ // a timeout. All three paths clean up their listeners.
168
+ const capabilities = await new Promise((resolve, reject) => {
169
+ const cleanup = () => {
170
+ transport.off('line', onLine);
171
+ transport.off('close', onClose);
172
+ transport.off('error', onError);
173
+ clearTimeout(timer);
174
+ };
176
175
  const onLine = (line) => {
176
+ let msg;
177
177
  try {
178
- const msg = JSON.parse(line);
179
- if (msg.id === initId) {
180
- rl.off('line', onLine);
181
- if (msg.error) {
182
- reject(new Error(`MCPL server "${config.id}" initialize error: ${msg.error.message}`));
183
- }
184
- else {
185
- resolve(msg);
186
- }
187
- }
178
+ msg = JSON.parse(line);
188
179
  }
189
180
  catch {
190
- // Ignore non-JSON lines (e.g. logback output from Java servers)
181
+ return; // Ignore non-JSON lines (e.g. logback output from Java servers)
191
182
  }
183
+ if (msg.id !== initId)
184
+ return;
185
+ cleanup();
186
+ if (msg.error) {
187
+ reject(new Error(`MCPL server "${config.id}" initialize error: ${msg.error.message}`));
188
+ return;
189
+ }
190
+ const result = msg.result;
191
+ const caps = result?.capabilities;
192
+ const experimental = caps?.experimental;
193
+ resolve(experimental?.mcpl ?? null);
192
194
  };
193
- rl.on('line', onLine);
194
- }),
195
- earlyExitPromise,
196
- new Promise((_resolve, reject) => setTimeout(() => {
197
- reject(new Error(`MCPL server "${config.id}" initialize handshake timed out`));
198
- }, INITIALIZE_TIMEOUT_MS)),
199
- ]);
200
- // Parse MCPL capabilities from the response
201
- const result = initResponse.result;
202
- const caps = result?.capabilities;
203
- const experimental = caps?.experimental;
204
- const mcplCaps = experimental?.mcpl ?? null;
205
- // Step 3: Send `initialized` notification (no id)
206
- const initializedNotification = {
207
- jsonrpc: '2.0',
208
- method: 'notifications/initialized',
209
- };
210
- child.stdin.write(JSON.stringify(initializedNotification) + '\n');
211
- // Remove the early-exit listener so normal close handling takes over
212
- child.removeAllListeners('exit');
213
- child.removeAllListeners('error');
214
- const connection = new McplServerConnection(config.id, mcplCaps, child, rl);
215
- // Swap the stderr handler to emit events on the instance, then drain
216
- // anything that arrived during the handshake window. Keep the rebind
217
- // handle on the instance so reconnect can re-target it later.
218
- connection.rebindStderr = attachStderr.rebind;
219
- attachStderr.rebind((line) => connection.emit('stderr', { line }));
220
- for (const line of earlyStderrBuffer) {
221
- connection.emit('stderr', { line });
195
+ const onClose = (info) => {
196
+ cleanup();
197
+ reject(new Error(`MCPL server "${config.id}" transport closed before handshake completed (${info.reason ?? 'unknown'})`));
198
+ };
199
+ const onError = (err) => {
200
+ cleanup();
201
+ reject(new Error(`MCPL server "${config.id}" transport error during handshake: ${err.message}`));
202
+ };
203
+ const timer = setTimeout(() => {
204
+ cleanup();
205
+ reject(new Error(`MCPL server "${config.id}" initialize handshake timed out`));
206
+ }, INITIALIZE_TIMEOUT_MS);
207
+ transport.on('line', onLine);
208
+ transport.on('close', onClose);
209
+ transport.on('error', onError);
210
+ transport.writeLine(JSON.stringify(initRequest));
211
+ });
212
+ // Send `initialized` notification (no id)
213
+ transport.writeLine(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }));
214
+ return { transport, capabilities };
222
215
  }
223
- earlyStderrBuffer.length = 0;
224
- // Store config for reconnection
225
- if (config.reconnect) {
226
- connection.config = config;
227
- connection.hostCapabilities = hostCapabilities;
228
- connection.reconnectEnabled = true;
229
- connection.reconnectIntervalMs = config.reconnectIntervalMs ?? 5000;
216
+ catch (err) {
217
+ // Never leak the child / socket if the handshake fails.
218
+ await transport.close().catch(() => { });
219
+ throw err;
230
220
  }
231
- return connection;
232
221
  }
233
222
  /**
234
223
  * Connect with reconnect support.
@@ -246,7 +235,12 @@ export class McplServerConnection extends EventEmitter {
246
235
  }
247
236
  // Non-blocking start: create a disconnected stub that will reconnect in background
248
237
  console.error(`MCPL server "${config.id}" initial connect failed, will retry:`, error.message);
249
- return McplServerConnection.createDisconnectedStub(config, hostCapabilities);
238
+ const stub = McplServerConnection.createDisconnectedStub(config, hostCapabilities);
239
+ // Surface the initial failure. The event is buffered by the emit()
240
+ // override until the host wires listeners and calls ready(), so it is
241
+ // not lost in the window before wireMcplEvents runs.
242
+ stub.emit('connect-failed', { error: error.message, attempt: 0 });
243
+ return stub;
250
244
  }
251
245
  }
252
246
  /**
@@ -255,28 +249,20 @@ export class McplServerConnection extends EventEmitter {
255
249
  * @internal
256
250
  */
257
251
  static createDisconnectedStub(config, hostCapabilities) {
258
- // Use null! for process/readline since the connection is closed
259
- const stub = new McplServerConnection(config.id, null, null, null);
252
+ // Null transport — the stub is closed until its first background reconnect.
253
+ const stub = new McplServerConnection(config.id, null, null);
260
254
  stub.closed = true;
255
+ stub.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
261
256
  stub.config = config;
262
257
  stub.hostCapabilities = hostCapabilities;
263
258
  stub.reconnectEnabled = true;
264
259
  stub.reconnectIntervalMs = config.reconnectIntervalMs ?? 5000;
260
+ stub.reconnectMaxIntervalMs = config.reconnectMaxIntervalMs ?? 300_000;
261
+ stub.reconnectAttempts = 1; // the initial connect already failed
265
262
  // Schedule background reconnect
266
263
  stub.scheduleReconnect();
267
264
  return stub;
268
265
  }
269
- /**
270
- * Extract internals for transfer during reconnection.
271
- * Strips the source connection — caller takes ownership of the process/readline.
272
- * @internal Used only by attemptReconnect().
273
- */
274
- extractInternals() {
275
- const result = { process: this.process, readline: this.readline };
276
- // Prevent the source connection from killing the transferred process
277
- this.closed = true;
278
- return result;
279
- }
280
266
  // ==========================================================================
281
267
  // Outbound requests (return Promises)
282
268
  // ==========================================================================
@@ -386,80 +372,73 @@ export class McplServerConnection extends EventEmitter {
386
372
  this.closed = true;
387
373
  // Reject all pending requests
388
374
  for (const [id, pending] of this.pendingRequests) {
375
+ if (pending.timer)
376
+ clearTimeout(pending.timer);
389
377
  pending.reject(new Error(`Connection to MCPL server "${this.id}" closed while awaiting response for ${pending.method} (id=${id})`));
390
378
  }
391
379
  this.pendingRequests.clear();
392
- // Close readline
393
- if (this.readline) {
394
- this.readline.close();
395
- }
396
- // Kill the child process
397
- if (this.process && !this.process.killed) {
398
- this.process.kill();
399
- }
400
- // Wait for process to actually exit
401
- if (this.process) {
402
- await new Promise((resolve) => {
403
- if (this.process.exitCode !== null || this.process.killed) {
404
- resolve();
405
- }
406
- else {
407
- this.process.once('exit', () => resolve());
408
- }
409
- });
380
+ // Tear down the transport (kills the child / closes the socket). The
381
+ // transport's own 'close' event is short-circuited by `this.closed` in
382
+ // setupLifecycle, so this is the single source of the connection 'close'.
383
+ if (this.transport) {
384
+ await this.transport.close();
410
385
  }
411
386
  this.emit('close');
412
387
  }
413
388
  /**
414
389
  * Schedule a background reconnection attempt.
390
+ *
391
+ * Delay grows exponentially with consecutive failures (base
392
+ * `reconnectIntervalMs`, doubling per failure, capped at
393
+ * `reconnectMaxIntervalMs`) with ±25% jitter so a fleet of servers that
394
+ * died together doesn't thundering-herd the same instant. The counter
395
+ * resets on a successful handshake, so a later disconnect starts over at
396
+ * the base interval.
415
397
  */
416
398
  scheduleReconnect() {
417
399
  if (!this.reconnectEnabled || this.reconnectTimer)
418
400
  return;
401
+ const exponent = Math.max(0, Math.min(this.reconnectAttempts - 1, 30));
402
+ const uncapped = this.reconnectIntervalMs * 2 ** exponent;
403
+ const capped = Math.min(uncapped, this.reconnectMaxIntervalMs);
404
+ const delay = capped * (0.75 + Math.random() * 0.5);
419
405
  this.reconnectTimer = setTimeout(() => {
420
406
  this.reconnectTimer = null;
421
407
  this.attemptReconnect();
422
- }, this.reconnectIntervalMs);
408
+ }, delay);
423
409
  }
424
410
  /**
425
- * Attempt to reconnect by spawning a new process and performing the handshake.
411
+ * Attempt to reconnect by opening a fresh transport and performing the
412
+ * handshake, then adopting that transport in place. Unlike the previous
413
+ * approach this builds no throwaway connection instance, so there are no
414
+ * leaked listeners on a dead object.
426
415
  */
427
416
  async attemptReconnect() {
428
417
  if (!this.reconnectEnabled || !this.config || !this.hostCapabilities)
429
418
  return;
419
+ // Ordinal of this attempt: 0 was the initial connect, so the Nth retry
420
+ // reports attempt N (reconnectAttempts counts failures so far).
421
+ const attempt = Math.max(1, this.reconnectAttempts);
430
422
  try {
431
- const fresh = await McplServerConnection.connect(this.config, this.hostCapabilities);
432
- const internals = fresh.extractInternals();
433
- // Transfer state from fresh connection to this instance
434
- this.process = internals.process;
435
- this.readline = internals.readline;
436
- this.capabilities = fresh.capabilities;
423
+ const { transport, capabilities } = await McplServerConnection.handshake(this.config, this.hostCapabilities);
424
+ // Adopt the new transport. Listeners were attached to external consumers
425
+ // on the first wire, so mark ready immediately (re-wire re-binds the
426
+ // transport-level handlers below).
427
+ this.transport = transport;
428
+ this.capabilities = capabilities;
437
429
  this.closed = false;
438
430
  this.nextRequestId = 1;
439
431
  this.pendingRequests.clear();
440
- // Re-target stderr forwarding from the throwaway `fresh` instance to `this`.
441
- //
442
- // Note: this follows the same "transfer internals, leak listeners on the
443
- // dead instance" pattern as the message-routing and lifecycle rewiring
444
- // below — `fresh`'s own readline 'line' handler and process 'exit'
445
- // handler stay registered on the transferred child, short-circuited by
446
- // `fresh.closed = true` set in extractInternals(). Rebinding fresh's
447
- // stderr listener (rather than removing it) keeps that invariant: if we
448
- // ever clean up the message-routing duplication, fresh's stderr data
449
- // listener closure-holds onLine and would need the same teardown.
450
- if (fresh.rebindStderr) {
451
- fresh.rebindStderr((line) => this.emit('stderr', { line }));
452
- this.rebindStderr = fresh.rebindStderr;
453
- }
454
- // Re-wire message routing and lifecycle on the new process
455
- this.setupMessageRouting();
456
- this.setupLifecycle();
457
- this.readyFlag = true; // Listeners already attached from initial wire
432
+ this.wireTransport(transport);
433
+ this.readyFlag = true;
458
434
  console.error(`MCPL server "${this.id}" reconnected successfully`);
459
- this.emit('reconnect');
435
+ this.reconnectAttempts = 0;
436
+ this.emit('reconnect', { attempts: attempt });
460
437
  }
461
438
  catch (error) {
462
439
  console.error(`MCPL server "${this.id}" reconnect failed:`, error.message);
440
+ this.reconnectAttempts = attempt + 1;
441
+ this.emit('reconnect-failed', { error: error.message, attempt });
463
442
  this.scheduleReconnect();
464
443
  }
465
444
  }
@@ -469,6 +448,13 @@ export class McplServerConnection extends EventEmitter {
469
448
  /**
470
449
  * Send a JSON-RPC request and return a promise that resolves with the result
471
450
  * or rejects with a JSON-RPC error.
451
+ *
452
+ * Every request carries a timeout (default 60s, configurable via
453
+ * McplServerConfig.requestTimeoutMs, 0 disables): a live-but-stuck server
454
+ * that accepts a request and never answers rejects the pending promise with
455
+ * a descriptive error instead of freezing the caller forever. For tools/call
456
+ * the framework maps the rejection to an isError tool_result, so a hung tool
457
+ * surfaces as a normal tool error and the turn completes.
472
458
  */
473
459
  sendRequest(method, params) {
474
460
  if (this.closed) {
@@ -482,8 +468,22 @@ export class McplServerConnection extends EventEmitter {
482
468
  params,
483
469
  };
484
470
  return new Promise((resolve, reject) => {
485
- this.pendingRequests.set(id, { resolve, reject, method });
486
- this.process.stdin.write(JSON.stringify(request) + '\n');
471
+ const pending = { resolve, reject, method };
472
+ if (this.requestTimeoutMs > 0) {
473
+ pending.timer = setTimeout(() => {
474
+ this.pendingRequests.delete(id);
475
+ reject(new Error(`MCPL server "${this.id}" did not respond to ${method} (id=${id}) ` +
476
+ `within ${this.requestTimeoutMs}ms — the server may be hung. ` +
477
+ `The request was abandoned; the connection remains open. Note the ` +
478
+ `tool may still have completed server-side (this is only a response ` +
479
+ `timeout, not a cancellation) — verify state before retrying, as a ` +
480
+ `blind retry of a stateful/side-effecting tool may duplicate it.`));
481
+ }, this.requestTimeoutMs);
482
+ // Don't hold the event loop open for the watchdog alone.
483
+ pending.timer.unref?.();
484
+ }
485
+ this.pendingRequests.set(id, pending);
486
+ this.transport.writeLine(JSON.stringify(request));
487
487
  });
488
488
  }
489
489
  /**
@@ -498,7 +498,7 @@ export class McplServerConnection extends EventEmitter {
498
498
  method,
499
499
  params,
500
500
  };
501
- this.process.stdin.write(JSON.stringify(notification) + '\n');
501
+ this.transport?.writeLine(JSON.stringify(notification));
502
502
  }
503
503
  // ==========================================================================
504
504
  // Private: inbound message routing
@@ -521,10 +521,22 @@ export class McplServerConnection extends EventEmitter {
521
521
  'host/command': 'host-command',
522
522
  };
523
523
  /**
524
- * Wire up readline to route incoming JSON-RPC messages.
524
+ * Bind all transport-level handlers (inbound routing, lifecycle, stderr) to a
525
+ * transport. Called from the constructor and again from attemptReconnect when
526
+ * a new transport is adopted.
527
+ */
528
+ wireTransport(transport) {
529
+ this.setupMessageRouting(transport);
530
+ this.setupLifecycle(transport);
531
+ // Surface transport diagnostics (stdio child stderr) as 'stderr' events,
532
+ // preserving the `{ line }` payload existing consumers expect.
533
+ transport.on('stderr', (line) => this.emit('stderr', { line }));
534
+ }
535
+ /**
536
+ * Wire up the transport's line stream to route incoming JSON-RPC messages.
525
537
  */
526
- setupMessageRouting() {
527
- this.readline.on('line', (line) => {
538
+ setupMessageRouting(transport) {
539
+ transport.on('line', (line) => {
528
540
  let msg;
529
541
  try {
530
542
  msg = JSON.parse(line);
@@ -564,9 +576,30 @@ export class McplServerConnection extends EventEmitter {
564
576
  handleResponse(response) {
565
577
  const pending = this.pendingRequests.get(response.id);
566
578
  if (!pending) {
579
+ // Orphaned response — the request already timed out (or was settled) and
580
+ // was removed from pendingRequests. Stateless late results are harmless to
581
+ // drop, but a late STATEFUL result carries a `state`/`checkpoint` the
582
+ // server has already advanced to: dropping it silently diverges the host's
583
+ // checkpoint tree from the server's, and every subsequent call then sends
584
+ // stale state. We can't safely re-inject it here (the dispatch context is
585
+ // gone), but we surface it so the divergence is greppable instead of
586
+ // invisible.
587
+ const result = response.result;
588
+ if (result && typeof result === 'object') {
589
+ const r = result;
590
+ if (r.state !== undefined || r.checkpoint !== undefined) {
591
+ this.emit('orphaned-response', {
592
+ id: response.id,
593
+ hadState: r.state !== undefined,
594
+ hadCheckpoint: r.checkpoint !== undefined,
595
+ });
596
+ }
597
+ }
567
598
  return; // Orphaned response — ignore
568
599
  }
569
600
  this.pendingRequests.delete(response.id);
601
+ if (pending.timer)
602
+ clearTimeout(pending.timer);
570
603
  if (response.error) {
571
604
  pending.reject(new Error(`MCPL server "${this.id}" returned error for ${pending.method}: [${response.error.code}] ${response.error.message}`));
572
605
  }
@@ -581,7 +614,7 @@ export class McplServerConnection extends EventEmitter {
581
614
  if (this.closed)
582
615
  return;
583
616
  const response = { jsonrpc: '2.0', id, result };
584
- this.process.stdin.write(JSON.stringify(response) + '\n');
617
+ this.transport?.writeLine(JSON.stringify(response));
585
618
  }
586
619
  /**
587
620
  * Send a JSON-RPC error response back to the server.
@@ -594,28 +627,32 @@ export class McplServerConnection extends EventEmitter {
594
627
  id,
595
628
  error: { code, message, data },
596
629
  };
597
- this.process.stdin.write(JSON.stringify(response) + '\n');
630
+ this.transport?.writeLine(JSON.stringify(response));
598
631
  }
599
632
  // ==========================================================================
600
633
  // Private: lifecycle
601
634
  // ==========================================================================
602
635
  /**
603
- * Set up process error/exit handlers.
636
+ * Set up transport error/close handlers. An unexpected transport close
637
+ * (child exit or WebSocket drop) rejects in-flight requests, emits 'close',
638
+ * and — when enabled — schedules a background reconnect.
604
639
  */
605
- setupLifecycle() {
606
- this.process.on('error', (err) => {
607
- this.emit('error', new Error(`MCPL server "${this.id}" process error: ${err.message}`));
640
+ setupLifecycle(transport) {
641
+ transport.on('error', (err) => {
642
+ this.emit('error', new Error(`MCPL server "${this.id}" transport error: ${err.message}`));
608
643
  });
609
- this.process.on('exit', (code, signal) => {
644
+ transport.on('close', (info) => {
610
645
  if (!this.closed) {
611
646
  this.closed = true;
612
647
  // Reject all pending requests
613
648
  for (const [id, pending] of this.pendingRequests) {
614
- pending.reject(new Error(`MCPL server "${this.id}" exited unexpectedly (code=${code}, signal=${signal}) while awaiting ${pending.method} (id=${id})`));
649
+ if (pending.timer)
650
+ clearTimeout(pending.timer);
651
+ pending.reject(new Error(`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})`));
615
652
  }
616
653
  this.pendingRequests.clear();
617
- this.emit('close', code, signal);
618
- // Schedule reconnect if enabled (unexpected exit triggers auto-reconnect)
654
+ this.emit('close', info.code ?? null, info.signal ?? null);
655
+ // Schedule reconnect if enabled (unexpected disconnect triggers auto-reconnect)
619
656
  if (this.reconnectEnabled) {
620
657
  this.scheduleReconnect();
621
658
  }