@animalabs/agent-framework 0.5.7 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/dist/src/framework.d.ts +67 -0
  2. package/dist/src/framework.d.ts.map +1 -1
  3. package/dist/src/framework.js +469 -26
  4. package/dist/src/framework.js.map +1 -1
  5. package/dist/src/gate/event-gate.d.ts +44 -1
  6. package/dist/src/gate/event-gate.d.ts.map +1 -1
  7. package/dist/src/gate/event-gate.js +152 -8
  8. package/dist/src/gate/event-gate.js.map +1 -1
  9. package/dist/src/gate/gate-script-worker.d.ts +2 -0
  10. package/dist/src/gate/gate-script-worker.d.ts.map +1 -0
  11. package/dist/src/gate/gate-script-worker.js +69 -0
  12. package/dist/src/gate/gate-script-worker.js.map +1 -0
  13. package/dist/src/gate/gate-script.d.ts +44 -0
  14. package/dist/src/gate/gate-script.d.ts.map +1 -0
  15. package/dist/src/gate/gate-script.js +164 -0
  16. package/dist/src/gate/gate-script.js.map +1 -0
  17. package/dist/src/gate/types.d.ts +18 -3
  18. package/dist/src/gate/types.d.ts.map +1 -1
  19. package/dist/src/mcpl/channel-registry.d.ts +43 -1
  20. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  21. package/dist/src/mcpl/channel-registry.js +82 -8
  22. package/dist/src/mcpl/channel-registry.js.map +1 -1
  23. package/dist/src/mcpl/checkpoint-manager.d.ts +7 -0
  24. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
  25. package/dist/src/mcpl/checkpoint-manager.js +15 -0
  26. package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
  27. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  28. package/dist/src/mcpl/push-handler.js +1 -0
  29. package/dist/src/mcpl/push-handler.js.map +1 -1
  30. package/dist/src/mcpl/server-connection.d.ts +35 -30
  31. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  32. package/dist/src/mcpl/server-connection.js +145 -206
  33. package/dist/src/mcpl/server-connection.js.map +1 -1
  34. package/dist/src/mcpl/transport.d.ts +98 -0
  35. package/dist/src/mcpl/transport.d.ts.map +1 -0
  36. package/dist/src/mcpl/transport.js +281 -0
  37. package/dist/src/mcpl/transport.js.map +1 -0
  38. package/dist/src/mcpl/types.d.ts +42 -2
  39. package/dist/src/mcpl/types.d.ts.map +1 -1
  40. package/dist/src/mcpl/types.js.map +1 -1
  41. package/dist/src/module-registry.d.ts +2 -0
  42. package/dist/src/module-registry.d.ts.map +1 -1
  43. package/dist/src/module-registry.js +9 -2
  44. package/dist/src/module-registry.js.map +1 -1
  45. package/dist/src/prose-segments.d.ts +18 -0
  46. package/dist/src/prose-segments.d.ts.map +1 -0
  47. package/dist/src/prose-segments.js +39 -0
  48. package/dist/src/prose-segments.js.map +1 -0
  49. package/dist/src/runtime/liveness-watchdog-worker.d.ts +2 -0
  50. package/dist/src/runtime/liveness-watchdog-worker.d.ts.map +1 -0
  51. package/dist/src/runtime/liveness-watchdog-worker.js +57 -0
  52. package/dist/src/runtime/liveness-watchdog-worker.js.map +1 -0
  53. package/dist/src/runtime/liveness-watchdog.d.ts +36 -0
  54. package/dist/src/runtime/liveness-watchdog.d.ts.map +1 -0
  55. package/dist/src/runtime/liveness-watchdog.js +106 -0
  56. package/dist/src/runtime/liveness-watchdog.js.map +1 -0
  57. package/dist/src/types/agent.d.ts +10 -0
  58. package/dist/src/types/agent.d.ts.map +1 -1
  59. package/dist/src/types/framework.d.ts +14 -0
  60. package/dist/src/types/framework.d.ts.map +1 -1
  61. package/dist/src/types/module.d.ts +12 -0
  62. package/dist/src/types/module.d.ts.map +1 -1
  63. package/dist/test/channel-registry-routing.test.js +108 -1
  64. package/dist/test/channel-registry-routing.test.js.map +1 -1
  65. package/dist/test/checkpoint-attribution.test.d.ts +2 -0
  66. package/dist/test/checkpoint-attribution.test.d.ts.map +1 -0
  67. package/dist/test/checkpoint-attribution.test.js +40 -0
  68. package/dist/test/checkpoint-attribution.test.js.map +1 -0
  69. package/dist/test/event-gate-policy-mutation.test.d.ts +2 -0
  70. package/dist/test/event-gate-policy-mutation.test.d.ts.map +1 -0
  71. package/dist/test/event-gate-policy-mutation.test.js +137 -0
  72. package/dist/test/event-gate-policy-mutation.test.js.map +1 -0
  73. package/dist/test/event-gate-script.test.d.ts +2 -0
  74. package/dist/test/event-gate-script.test.d.ts.map +1 -0
  75. package/dist/test/event-gate-script.test.js +95 -0
  76. package/dist/test/event-gate-script.test.js.map +1 -0
  77. package/dist/test/event-gate-tags.test.d.ts +2 -0
  78. package/dist/test/event-gate-tags.test.d.ts.map +1 -0
  79. package/dist/test/event-gate-tags.test.js +76 -0
  80. package/dist/test/event-gate-tags.test.js.map +1 -0
  81. package/dist/test/liveness-watchdog.test.d.ts +2 -0
  82. package/dist/test/liveness-watchdog.test.d.ts.map +1 -0
  83. package/dist/test/liveness-watchdog.test.js +78 -0
  84. package/dist/test/liveness-watchdog.test.js.map +1 -0
  85. package/dist/test/mcpl-ws-transport.test.d.ts +2 -0
  86. package/dist/test/mcpl-ws-transport.test.d.ts.map +1 -0
  87. package/dist/test/mcpl-ws-transport.test.js +155 -0
  88. package/dist/test/mcpl-ws-transport.test.js.map +1 -0
  89. package/dist/test/prose-segments.test.d.ts +2 -0
  90. package/dist/test/prose-segments.test.d.ts.map +1 -0
  91. package/dist/test/prose-segments.test.js +52 -0
  92. package/dist/test/prose-segments.test.js.map +1 -0
  93. package/dist/test/trunk-channel-routing.test.d.ts +19 -0
  94. package/dist/test/trunk-channel-routing.test.d.ts.map +1 -0
  95. package/dist/test/trunk-channel-routing.test.js +175 -0
  96. package/dist/test/trunk-channel-routing.test.js.map +1 -0
  97. package/dist/tsconfig.tsbuildinfo +1 -1
  98. package/package.json +2 -2
  99. package/src/framework.ts +509 -24
  100. package/src/gate/event-gate.ts +153 -8
  101. package/src/gate/gate-script-worker.ts +73 -0
  102. package/src/gate/gate-script.ts +180 -0
  103. package/src/gate/types.ts +19 -3
  104. package/src/mcpl/channel-registry.ts +112 -8
  105. package/src/mcpl/checkpoint-manager.ts +16 -0
  106. package/src/mcpl/push-handler.ts +1 -0
  107. package/src/mcpl/server-connection.ts +153 -224
  108. package/src/mcpl/transport.ts +307 -0
  109. package/src/mcpl/types.ts +49 -2
  110. package/src/module-registry.ts +13 -2
  111. package/src/prose-segments.ts +42 -0
  112. package/src/runtime/liveness-watchdog-worker.ts +66 -0
  113. package/src/runtime/liveness-watchdog.ts +122 -0
  114. package/src/types/agent.ts +10 -0
  115. package/src/types/framework.ts +15 -0
  116. package/src/types/module.ts +13 -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,
@@ -78,8 +81,9 @@ export class McplServerConnection extends EventEmitter {
78
81
  /** MCPL capabilities negotiated during the initialize handshake, or null if the server does not support MCPL. */
79
82
  capabilities: McplCapabilities | null;
80
83
 
81
- private process: ChildProcess;
82
- private readline: ReadlineInterface;
84
+ /** The active transport (stdio child or WebSocket). Null for a disconnected
85
+ * stub awaiting its first background reconnect. */
86
+ private transport: McplTransport | null;
83
87
  private nextRequestId = 1;
84
88
  private pendingRequests = new Map<string | number, PendingRequest>();
85
89
  private closed = false;
@@ -95,28 +99,22 @@ export class McplServerConnection extends EventEmitter {
95
99
  private reconnectIntervalMs = 5000;
96
100
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
97
101
 
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;
100
-
101
102
  /**
102
103
  * Private constructor. Use `McplServerConnection.connect()` instead.
103
104
  */
104
105
  private constructor(
105
106
  id: string,
106
107
  capabilities: McplCapabilities | null,
107
- childProcess: ChildProcess,
108
- readline: ReadlineInterface,
108
+ transport: McplTransport | null,
109
109
  ) {
110
110
  super();
111
111
  this.id = id;
112
112
  this.capabilities = capabilities;
113
- this.process = childProcess;
114
- this.readline = readline;
113
+ this.transport = transport;
115
114
 
116
- // Skip setup for disconnected stubs (process/readline are null)
117
- if (childProcess && readline) {
118
- this.setupMessageRouting();
119
- this.setupLifecycle();
115
+ // Skip setup for disconnected stubs (transport is null).
116
+ if (transport) {
117
+ this.wireTransport(transport);
120
118
  }
121
119
  }
122
120
 
@@ -155,159 +153,113 @@ export class McplServerConnection extends EventEmitter {
155
153
  // ==========================================================================
156
154
 
157
155
  /**
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
156
+ * Open the transport, perform the MCP initialize handshake with MCPL
186
157
  * capability negotiation, and return a ready-to-use connection.
187
158
  *
188
- * Throws if the process cannot be spawned or the handshake times out.
159
+ * Works over stdio (spawned child) or WebSocket, selected by `config` (see
160
+ * {@link openTransport}). Throws if the transport can't be opened, the server
161
+ * closes/errors before the handshake, or the handshake times out.
189
162
  */
190
163
  static async connect(
191
164
  config: McplServerConfig,
192
165
  hostCapabilities: McplHostCapabilities,
193
166
  ): Promise<McplServerConnection> {
194
- const child = spawn(config.command, config.args ?? [], {
195
- stdio: ['pipe', 'pipe', 'pipe'],
196
- env: { ...process.env, ...config.env },
197
- });
167
+ const { transport, capabilities } = await McplServerConnection.handshake(config, hostCapabilities);
198
168
 
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
- });
169
+ const connection = new McplServerConnection(config.id, capabilities, transport);
216
170
 
217
- // Set up readline for newline-delimited JSON on stdout
218
- const rl = createInterface({ input: child.stdout! });
171
+ // Store config for reconnection
172
+ if (config.reconnect) {
173
+ connection.config = config;
174
+ connection.hostCapabilities = hostCapabilities;
175
+ connection.reconnectEnabled = true;
176
+ connection.reconnectIntervalMs = config.reconnectIntervalMs ?? 5000;
177
+ }
219
178
 
220
- // -----------------------------------------------------------------------
221
- // Perform initialize handshake
222
- // -----------------------------------------------------------------------
179
+ return connection;
180
+ }
223
181
 
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');
182
+ /**
183
+ * Open a fresh transport and run the MCP/MCPL initialize handshake over it,
184
+ * returning the connected transport plus the negotiated capabilities. Shared
185
+ * by {@link connect} and {@link attemptReconnect} so both transports handshake
186
+ * identically. On any failure the transport is closed so a spawned child /
187
+ * open socket never leaks.
188
+ */
189
+ private static async handshake(
190
+ config: McplServerConfig,
191
+ hostCapabilities: McplHostCapabilities,
192
+ ): Promise<{ transport: McplTransport; capabilities: McplCapabilities | null }> {
193
+ const transport = await openTransport(config);
244
194
 
245
- // Step 2: Wait for the initialize response
246
- const initResponse = await Promise.race([
247
- new Promise<JsonRpcResponse>((resolve, reject) => {
195
+ try {
196
+ const initId = 0; // use id=0 for the handshake
197
+ const initRequest: JsonRpcRequest = {
198
+ jsonrpc: '2.0',
199
+ method: 'initialize',
200
+ id: initId,
201
+ params: {
202
+ protocolVersion: MCP_PROTOCOL_VERSION,
203
+ capabilities: { experimental: { mcpl: hostCapabilities } },
204
+ clientInfo: { name: 'agent-framework', version: '1.0.0' },
205
+ },
206
+ };
207
+
208
+ // Await the initialize response, racing an early transport close/error and
209
+ // a timeout. All three paths clean up their listeners.
210
+ const capabilities = await new Promise<McplCapabilities | null>((resolve, reject) => {
211
+ const cleanup = () => {
212
+ transport.off('line', onLine);
213
+ transport.off('close', onClose);
214
+ transport.off('error', onError);
215
+ clearTimeout(timer);
216
+ };
248
217
  const onLine = (line: string) => {
218
+ let msg: JsonRpcResponse;
249
219
  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
- }
220
+ msg = JSON.parse(line) as JsonRpcResponse;
259
221
  } catch {
260
- // Ignore non-JSON lines (e.g. logback output from Java servers)
222
+ return; // Ignore non-JSON lines (e.g. logback output from Java servers)
261
223
  }
224
+ if (msg.id !== initId) return;
225
+ cleanup();
226
+ if (msg.error) {
227
+ reject(new Error(`MCPL server "${config.id}" initialize error: ${msg.error.message}`));
228
+ return;
229
+ }
230
+ const result = msg.result as Record<string, unknown> | undefined;
231
+ const caps = result?.capabilities as Record<string, unknown> | undefined;
232
+ const experimental = caps?.experimental as Record<string, unknown> | undefined;
233
+ resolve((experimental?.mcpl as McplCapabilities) ?? null);
234
+ };
235
+ const onClose = (info: TransportCloseInfo) => {
236
+ cleanup();
237
+ reject(new Error(`MCPL server "${config.id}" transport closed before handshake completed (${info.reason ?? 'unknown'})`));
262
238
  };
263
- rl.on('line', onLine);
264
- }),
265
- earlyExitPromise,
266
- new Promise<never>((_resolve, reject) =>
267
- setTimeout(() => {
239
+ const onError = (err: Error) => {
240
+ cleanup();
241
+ reject(new Error(`MCPL server "${config.id}" transport error during handshake: ${err.message}`));
242
+ };
243
+ const timer = setTimeout(() => {
244
+ cleanup();
268
245
  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');
246
+ }, INITIALIZE_TIMEOUT_MS);
285
247
 
286
- // Remove the early-exit listener so normal close handling takes over
287
- child.removeAllListeners('exit');
288
- child.removeAllListeners('error');
248
+ transport.on('line', onLine);
249
+ transport.on('close', onClose);
250
+ transport.on('error', onError);
251
+ transport.writeLine(JSON.stringify(initRequest));
252
+ });
289
253
 
290
- const connection = new McplServerConnection(config.id, mcplCaps, child, rl);
254
+ // Send `initialized` notification (no id)
255
+ transport.writeLine(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }));
291
256
 
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 });
257
+ return { transport, capabilities };
258
+ } catch (err) {
259
+ // Never leak the child / socket if the handshake fails.
260
+ await transport.close().catch(() => { /* best effort */ });
261
+ throw err;
299
262
  }
300
- earlyStderrBuffer.length = 0;
301
-
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;
308
- }
309
-
310
- return connection;
311
263
  }
312
264
 
313
265
  /**
@@ -342,8 +294,8 @@ export class McplServerConnection extends EventEmitter {
342
294
  config: McplServerConfig,
343
295
  hostCapabilities: McplHostCapabilities,
344
296
  ): 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);
297
+ // Null transport — the stub is closed until its first background reconnect.
298
+ const stub = new McplServerConnection(config.id, null, null);
347
299
  stub.closed = true;
348
300
  stub.config = config;
349
301
  stub.hostCapabilities = hostCapabilities;
@@ -356,18 +308,6 @@ export class McplServerConnection extends EventEmitter {
356
308
  return stub;
357
309
  }
358
310
 
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
311
  // ==========================================================================
372
312
  // Outbound requests (return Promises)
373
313
  // ==========================================================================
@@ -505,25 +445,11 @@ export class McplServerConnection extends EventEmitter {
505
445
  }
506
446
  this.pendingRequests.clear();
507
447
 
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
- });
448
+ // Tear down the transport (kills the child / closes the socket). The
449
+ // transport's own 'close' event is short-circuited by `this.closed` in
450
+ // setupLifecycle, so this is the single source of the connection 'close'.
451
+ if (this.transport) {
452
+ await this.transport.close();
527
453
  }
528
454
 
529
455
  this.emit('close');
@@ -542,42 +468,30 @@ export class McplServerConnection extends EventEmitter {
542
468
  }
543
469
 
544
470
  /**
545
- * Attempt to reconnect by spawning a new process and performing the handshake.
471
+ * Attempt to reconnect by opening a fresh transport and performing the
472
+ * handshake, then adopting that transport in place. Unlike the previous
473
+ * approach this builds no throwaway connection instance, so there are no
474
+ * leaked listeners on a dead object.
546
475
  */
547
476
  private async attemptReconnect(): Promise<void> {
548
477
  if (!this.reconnectEnabled || !this.config || !this.hostCapabilities) return;
549
478
 
550
479
  try {
551
- const fresh = await McplServerConnection.connect(this.config, this.hostCapabilities);
552
- const internals = fresh.extractInternals();
480
+ const { transport, capabilities } = await McplServerConnection.handshake(
481
+ this.config,
482
+ this.hostCapabilities,
483
+ );
553
484
 
554
- // Transfer state from fresh connection to this instance
555
- this.process = internals.process;
556
- this.readline = internals.readline;
557
- this.capabilities = fresh.capabilities;
485
+ // Adopt the new transport. Listeners were attached to external consumers
486
+ // on the first wire, so mark ready immediately (re-wire re-binds the
487
+ // transport-level handlers below).
488
+ this.transport = transport;
489
+ this.capabilities = capabilities;
558
490
  this.closed = false;
559
491
  this.nextRequestId = 1;
560
492
  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
493
+ this.wireTransport(transport);
494
+ this.readyFlag = true;
581
495
 
582
496
  console.error(`MCPL server "${this.id}" reconnected successfully`);
583
497
  this.emit('reconnect');
@@ -610,7 +524,7 @@ export class McplServerConnection extends EventEmitter {
610
524
 
611
525
  return new Promise((resolve, reject) => {
612
526
  this.pendingRequests.set(id, { resolve, reject, method });
613
- this.process.stdin!.write(JSON.stringify(request) + '\n');
527
+ this.transport!.writeLine(JSON.stringify(request));
614
528
  });
615
529
  }
616
530
 
@@ -627,7 +541,7 @@ export class McplServerConnection extends EventEmitter {
627
541
  method,
628
542
  params,
629
543
  };
630
- this.process.stdin!.write(JSON.stringify(notification) + '\n');
544
+ this.transport?.writeLine(JSON.stringify(notification));
631
545
  }
632
546
 
633
547
  // ==========================================================================
@@ -653,10 +567,23 @@ export class McplServerConnection extends EventEmitter {
653
567
  };
654
568
 
655
569
  /**
656
- * Wire up readline to route incoming JSON-RPC messages.
570
+ * Bind all transport-level handlers (inbound routing, lifecycle, stderr) to a
571
+ * transport. Called from the constructor and again from attemptReconnect when
572
+ * a new transport is adopted.
573
+ */
574
+ private wireTransport(transport: McplTransport): void {
575
+ this.setupMessageRouting(transport);
576
+ this.setupLifecycle(transport);
577
+ // Surface transport diagnostics (stdio child stderr) as 'stderr' events,
578
+ // preserving the `{ line }` payload existing consumers expect.
579
+ transport.on('stderr', (line: string) => this.emit('stderr', { line }));
580
+ }
581
+
582
+ /**
583
+ * Wire up the transport's line stream to route incoming JSON-RPC messages.
657
584
  */
658
- private setupMessageRouting(): void {
659
- this.readline.on('line', (line) => {
585
+ private setupMessageRouting(transport: McplTransport): void {
586
+ transport.on('line', (line: string) => {
660
587
  let msg: JsonRpcRequest | JsonRpcResponse;
661
588
  try {
662
589
  msg = JSON.parse(line);
@@ -719,7 +646,7 @@ export class McplServerConnection extends EventEmitter {
719
646
  private sendResponse(id: string | number, result: unknown): void {
720
647
  if (this.closed) return;
721
648
  const response: JsonRpcResponse = { jsonrpc: '2.0', id, result };
722
- this.process.stdin!.write(JSON.stringify(response) + '\n');
649
+ this.transport?.writeLine(JSON.stringify(response));
723
650
  }
724
651
 
725
652
  /**
@@ -732,7 +659,7 @@ export class McplServerConnection extends EventEmitter {
732
659
  id,
733
660
  error: { code, message, data },
734
661
  };
735
- this.process.stdin!.write(JSON.stringify(response) + '\n');
662
+ this.transport?.writeLine(JSON.stringify(response));
736
663
  }
737
664
 
738
665
  // ==========================================================================
@@ -740,14 +667,16 @@ export class McplServerConnection extends EventEmitter {
740
667
  // ==========================================================================
741
668
 
742
669
  /**
743
- * Set up process error/exit handlers.
670
+ * Set up transport error/close handlers. An unexpected transport close
671
+ * (child exit or WebSocket drop) rejects in-flight requests, emits 'close',
672
+ * and — when enabled — schedules a background reconnect.
744
673
  */
745
- private setupLifecycle(): void {
746
- this.process.on('error', (err) => {
747
- this.emit('error', new Error(`MCPL server "${this.id}" process error: ${err.message}`));
674
+ private setupLifecycle(transport: McplTransport): void {
675
+ transport.on('error', (err: Error) => {
676
+ this.emit('error', new Error(`MCPL server "${this.id}" transport error: ${err.message}`));
748
677
  });
749
678
 
750
- this.process.on('exit', (code, signal) => {
679
+ transport.on('close', (info: TransportCloseInfo) => {
751
680
  if (!this.closed) {
752
681
  this.closed = true;
753
682
 
@@ -755,15 +684,15 @@ export class McplServerConnection extends EventEmitter {
755
684
  for (const [id, pending] of this.pendingRequests) {
756
685
  pending.reject(
757
686
  new Error(
758
- `MCPL server "${this.id}" exited unexpectedly (code=${code}, signal=${signal}) while awaiting ${pending.method} (id=${id})`,
687
+ `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
688
  ),
760
689
  );
761
690
  }
762
691
  this.pendingRequests.clear();
763
692
 
764
- this.emit('close', code, signal);
693
+ this.emit('close', info.code ?? null, info.signal ?? null);
765
694
 
766
- // Schedule reconnect if enabled (unexpected exit triggers auto-reconnect)
695
+ // Schedule reconnect if enabled (unexpected disconnect triggers auto-reconnect)
767
696
  if (this.reconnectEnabled) {
768
697
  this.scheduleReconnect();
769
698
  }