@animalabs/agent-framework 0.30.0 → 0.32.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 (189) hide show
  1. package/README.md +71 -8
  2. package/dist/src/agent.d.ts +29 -9
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +82 -17
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/mcp-server-core.d.ts.map +1 -1
  7. package/dist/src/api/mcp-server-core.js +10 -3
  8. package/dist/src/api/mcp-server-core.js.map +1 -1
  9. package/dist/src/code-execution/isolate/render.d.ts.map +1 -1
  10. package/dist/src/code-execution/isolate/render.js +7 -6
  11. package/dist/src/code-execution/isolate/render.js.map +1 -1
  12. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  13. package/dist/src/code-execution/isolate/scripts.js +5 -4
  14. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  15. package/dist/src/framework.d.ts +138 -102
  16. package/dist/src/framework.d.ts.map +1 -1
  17. package/dist/src/framework.js +1133 -752
  18. package/dist/src/framework.js.map +1 -1
  19. package/dist/src/gate/event-gate.d.ts +24 -17
  20. package/dist/src/gate/event-gate.d.ts.map +1 -1
  21. package/dist/src/gate/event-gate.js +198 -144
  22. package/dist/src/gate/event-gate.js.map +1 -1
  23. package/dist/src/gate/recipe-reconcile.d.ts.map +1 -1
  24. package/dist/src/gate/recipe-reconcile.js +6 -5
  25. package/dist/src/gate/recipe-reconcile.js.map +1 -1
  26. package/dist/src/host-presentation.d.ts.map +1 -1
  27. package/dist/src/host-presentation.js +5 -7
  28. package/dist/src/host-presentation.js.map +1 -1
  29. package/dist/src/index.d.ts +1 -0
  30. package/dist/src/index.d.ts.map +1 -1
  31. package/dist/src/index.js +1 -0
  32. package/dist/src/index.js.map +1 -1
  33. package/dist/src/inference-bounds.d.ts +37 -0
  34. package/dist/src/inference-bounds.d.ts.map +1 -0
  35. package/dist/src/inference-bounds.js +84 -0
  36. package/dist/src/inference-bounds.js.map +1 -0
  37. package/dist/src/line-reader.d.ts +7 -1
  38. package/dist/src/line-reader.d.ts.map +1 -1
  39. package/dist/src/line-reader.js +17 -6
  40. package/dist/src/line-reader.js.map +1 -1
  41. package/dist/src/mcpl/errors.d.ts +1 -0
  42. package/dist/src/mcpl/errors.d.ts.map +1 -1
  43. package/dist/src/mcpl/errors.js +3 -0
  44. package/dist/src/mcpl/errors.js.map +1 -1
  45. package/dist/src/mcpl/inbound-source.d.ts +2 -2
  46. package/dist/src/mcpl/inbound-source.js +2 -2
  47. package/dist/src/mcpl/inference-router.d.ts +11 -1
  48. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  49. package/dist/src/mcpl/inference-router.js +65 -9
  50. package/dist/src/mcpl/inference-router.js.map +1 -1
  51. package/dist/src/mcpl/modern-connection.d.ts +10 -7
  52. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  53. package/dist/src/mcpl/modern-connection.js +85 -74
  54. package/dist/src/mcpl/modern-connection.js.map +1 -1
  55. package/dist/src/mcpl/protocol-family.d.ts +1 -1
  56. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  57. package/dist/src/mcpl/protocol-family.js +45 -3
  58. package/dist/src/mcpl/protocol-family.js.map +1 -1
  59. package/dist/src/mcpl/push-coalescer.d.ts +2 -7
  60. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  61. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  62. package/dist/src/mcpl/references.d.ts +1 -2
  63. package/dist/src/mcpl/references.d.ts.map +1 -1
  64. package/dist/src/mcpl/references.js +3 -3
  65. package/dist/src/mcpl/references.js.map +1 -1
  66. package/dist/src/mcpl/server-connection.d.ts +14 -0
  67. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  68. package/dist/src/mcpl/server-connection.js +26 -22
  69. package/dist/src/mcpl/server-connection.js.map +1 -1
  70. package/dist/src/mcpl/tool-signature.d.ts +1 -0
  71. package/dist/src/mcpl/tool-signature.d.ts.map +1 -1
  72. package/dist/src/mcpl/tool-signature.js +16 -11
  73. package/dist/src/mcpl/tool-signature.js.map +1 -1
  74. package/dist/src/mcpl/transport.d.ts +44 -21
  75. package/dist/src/mcpl/transport.d.ts.map +1 -1
  76. package/dist/src/mcpl/transport.js +283 -64
  77. package/dist/src/mcpl/transport.js.map +1 -1
  78. package/dist/src/mcpl/types.d.ts +17 -1
  79. package/dist/src/mcpl/types.d.ts.map +1 -1
  80. package/dist/src/mcpl/types.js.map +1 -1
  81. package/dist/src/module-registry.d.ts +32 -4
  82. package/dist/src/module-registry.d.ts.map +1 -1
  83. package/dist/src/module-registry.js +189 -25
  84. package/dist/src/module-registry.js.map +1 -1
  85. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
  86. package/dist/src/modules/discord/discord-js-client.js +24 -16
  87. package/dist/src/modules/discord/discord-js-client.js.map +1 -1
  88. package/dist/src/modules/sender-name.d.ts +5 -4
  89. package/dist/src/modules/sender-name.d.ts.map +1 -1
  90. package/dist/src/modules/sender-name.js +7 -5
  91. package/dist/src/modules/sender-name.js.map +1 -1
  92. package/dist/src/modules/workspace/disk-agreement.d.ts +16 -8
  93. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  94. package/dist/src/modules/workspace/disk-agreement.js +33 -12
  95. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  96. package/dist/src/modules/workspace/index.d.ts +6 -3
  97. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  98. package/dist/src/modules/workspace/index.js +61 -15
  99. package/dist/src/modules/workspace/index.js.map +1 -1
  100. package/dist/src/modules/workspace/observe.d.ts +9 -1
  101. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  102. package/dist/src/modules/workspace/observe.js +50 -10
  103. package/dist/src/modules/workspace/observe.js.map +1 -1
  104. package/dist/src/modules/workspace/reconcile.d.ts +11 -1
  105. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  106. package/dist/src/modules/workspace/reconcile.js +19 -8
  107. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  108. package/dist/src/modules/workspace/types.d.ts +8 -0
  109. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  110. package/dist/src/modules/workspace/types.js.map +1 -1
  111. package/dist/src/modules/workspace/watcher.d.ts +7 -0
  112. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  113. package/dist/src/modules/workspace/watcher.js +7 -5
  114. package/dist/src/modules/workspace/watcher.js.map +1 -1
  115. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
  116. package/dist/src/recovery/discord-awareness-outbox.js +1 -9
  117. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
  118. package/dist/src/supplied-text.d.ts +2 -0
  119. package/dist/src/supplied-text.d.ts.map +1 -1
  120. package/dist/src/supplied-text.js +16 -7
  121. package/dist/src/supplied-text.js.map +1 -1
  122. package/dist/src/tool-result-guard.js +1 -1
  123. package/dist/src/tool-result-guard.js.map +1 -1
  124. package/dist/src/tune-out/coordinator.d.ts +2 -20
  125. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  126. package/dist/src/tune-out/coordinator.js +175 -70
  127. package/dist/src/tune-out/coordinator.js.map +1 -1
  128. package/dist/src/tune-out/tools.d.ts +14 -2
  129. package/dist/src/tune-out/tools.d.ts.map +1 -1
  130. package/dist/src/tune-out/tools.js.map +1 -1
  131. package/dist/src/types/agent.d.ts +6 -12
  132. package/dist/src/types/agent.d.ts.map +1 -1
  133. package/dist/src/types/framework.d.ts +65 -24
  134. package/dist/src/types/framework.d.ts.map +1 -1
  135. package/dist/src/types/module.d.ts +42 -3
  136. package/dist/src/types/module.d.ts.map +1 -1
  137. package/dist/src/types/trace.d.ts +4 -3
  138. package/dist/src/types/trace.d.ts.map +1 -1
  139. package/package.json +3 -3
  140. package/src/agent.ts +90 -25
  141. package/src/api/mcp-server-core.ts +10 -3
  142. package/src/code-execution/isolate/render.ts +7 -6
  143. package/src/code-execution/isolate/scripts.ts +5 -4
  144. package/src/framework.ts +1163 -770
  145. package/src/gate/event-gate.ts +220 -145
  146. package/src/gate/recipe-reconcile.ts +7 -5
  147. package/src/host-presentation.ts +4 -7
  148. package/src/index.ts +1 -0
  149. package/src/inference-bounds.ts +111 -0
  150. package/src/line-reader.ts +22 -5
  151. package/src/mcpl/errors.ts +4 -0
  152. package/src/mcpl/inbound-source.ts +2 -2
  153. package/src/mcpl/inference-router.ts +67 -10
  154. package/src/mcpl/modern-connection.ts +83 -68
  155. package/src/mcpl/protocol-family.ts +37 -4
  156. package/src/mcpl/push-coalescer.ts +2 -7
  157. package/src/mcpl/references.ts +3 -4
  158. package/src/mcpl/server-connection.ts +30 -21
  159. package/src/mcpl/tool-signature.ts +18 -11
  160. package/src/mcpl/transport.ts +261 -60
  161. package/src/mcpl/types.ts +18 -1
  162. package/src/module-registry.ts +178 -25
  163. package/src/modules/discord/discord-js-client.ts +33 -29
  164. package/src/modules/sender-name.ts +8 -5
  165. package/src/modules/workspace/disk-agreement.ts +39 -15
  166. package/src/modules/workspace/index.ts +65 -14
  167. package/src/modules/workspace/observe.ts +51 -10
  168. package/src/modules/workspace/reconcile.ts +22 -8
  169. package/src/modules/workspace/types.ts +5 -0
  170. package/src/modules/workspace/watcher.ts +8 -6
  171. package/src/recovery/discord-awareness-outbox.ts +1 -11
  172. package/src/supplied-text.ts +17 -6
  173. package/src/tool-result-guard.ts +1 -1
  174. package/src/tune-out/coordinator.ts +187 -85
  175. package/src/tune-out/tools.ts +14 -2
  176. package/src/types/agent.ts +6 -12
  177. package/src/types/framework.ts +66 -24
  178. package/src/types/module.ts +42 -3
  179. package/src/types/trace.ts +4 -3
  180. package/dist/src/reasoning-loss-notice.d.ts +0 -23
  181. package/dist/src/reasoning-loss-notice.d.ts.map +0 -1
  182. package/dist/src/reasoning-loss-notice.js +0 -52
  183. package/dist/src/reasoning-loss-notice.js.map +0 -1
  184. package/dist/src/reasoning-loss.d.ts +0 -24
  185. package/dist/src/reasoning-loss.d.ts.map +0 -1
  186. package/dist/src/reasoning-loss.js +0 -102
  187. package/dist/src/reasoning-loss.js.map +0 -1
  188. package/src/reasoning-loss-notice.ts +0 -64
  189. package/src/reasoning-loss.ts +0 -120
@@ -22,11 +22,11 @@ import { spawn, type ChildProcess } from 'node:child_process';
22
22
  import { EventEmitter } from 'node:events';
23
23
  import type { Readable } from 'node:stream';
24
24
  import { StringDecoder } from 'node:string_decoder';
25
- import { escapeLineSeparators, readLines } from '../line-reader.js';
25
+ import { MAX_LINE_CHARS, escapeLineSeparators, readLines } from '../line-reader.js';
26
26
  import WebSocket from 'ws';
27
27
 
28
28
  import type { McplServerConfig } from './types.js';
29
- import { WITHHELD_TEXT, describeCredentialError, headerCarries, maskedError, noteDialSent, redactServerText, redactUrl, resolveServerBinding, serverTextRedactor, tokenTransportProblem, urlAsDialed, type Redaction } from './protocol-family.js';
29
+ import { WITHHELD_TEXT, describeCredentialError, headerCarries, maskedError, noteDialSent, redactServerText, redactUrl, resolveServerBinding, serverConfigProblems, serverTextRedactor, tokenTransportProblem, urlAsDialed, type Redaction } from './protocol-family.js';
30
30
  import { assertBooleanSetting } from '../config-checks.js';
31
31
  import { logDetail } from './log-value.js';
32
32
 
@@ -39,6 +39,11 @@ export interface TransportCloseInfo {
39
39
  /** True when `reason` is the peer's own text, a WebSocket server's close
40
40
  * reason, rather than the transport's words. */
41
41
  reasonFromPeer?: boolean;
42
+ /** True when a stdio transport ended its child itself, after an error on
43
+ * the child's stdin or stdout or a line on its stdout longer than the
44
+ * bound: the exit's signal or code is then this transport's doing, not a
45
+ * stop asked of the server. */
46
+ pipeFailed?: boolean;
42
47
  }
43
48
 
44
49
  /**
@@ -49,20 +54,16 @@ export interface TransportCloseInfo {
49
54
  * - `'stderr'` `(line: string)` diagnostic line (stdio child stderr; never WS)
50
55
  * - `'error'` `(err: Error)` transport-level error
51
56
  * - `'close'` `(info: TransportCloseInfo)` transport closed (once)
52
- * - `'unreaped'` `(failure: Error)` a stdio child whose pipe failed
53
- * outlived SIGKILL too: the transport stays open, owning the child, and
54
- * close() retries the reap (never WS)
55
- *
56
- * Inbound `'line'`/`'stderr'` events are buffered until the first matching
57
- * listener attaches (and re-buffered whenever there are none), so no message is
58
- * lost across the handshake→routing handoff regardless of attach timing.
57
+ * - `'unreaped'` `(failure: Error)` a stdio child this transport was
58
+ * ending outlived SIGKILL too: the transport stays open, owning the
59
+ * child, and close() retries the reap (never WS)
59
60
  */
60
61
  export abstract class McplTransport extends EventEmitter {
61
62
  abstract readonly kind: 'stdio' | 'websocket';
62
63
 
63
64
  private _closed = false;
64
65
  private lineBuffer: string[] = [];
65
- private stderrBuffer: string[] = [];
66
+ private readonly heldStderr = new HeldStderr();
66
67
 
67
68
  /** The server's id from its config, so a line the transport logs names it.
68
69
  * The stdio and WebSocket transports always give it; a test's stand-in
@@ -75,9 +76,8 @@ export abstract class McplTransport extends EventEmitter {
75
76
  const pending = this.lineBuffer;
76
77
  this.lineBuffer = [];
77
78
  queueMicrotask(() => { for (const l of pending) this.emit('line', l); });
78
- } else if (event === 'stderr' && this.stderrBuffer.length > 0) {
79
- const pending = this.stderrBuffer;
80
- this.stderrBuffer = [];
79
+ } else if (event === 'stderr' && !this.heldStderr.empty) {
80
+ const pending = this.heldStderr.take();
81
81
  queueMicrotask(() => { for (const l of pending) this.emit('stderr', l); });
82
82
  }
83
83
  });
@@ -112,14 +112,7 @@ export abstract class McplTransport extends EventEmitter {
112
112
  return '';
113
113
  }
114
114
 
115
- /**
116
- * The error a stdio child's stdin or stdout failed with, once one has. The
117
- * transport is ending the child then, and stays open until it exits, but
118
- * writes nothing more to it: writeLine drops each line, and both engines
119
- * refuse a request as not sent. Undefined while writes go out, and always
120
- * for a transport that has no pipes.
121
- */
122
- get pipeFailure(): Error | undefined {
115
+ get stdioStopCause(): Error | undefined {
123
116
  return undefined;
124
117
  }
125
118
 
@@ -141,7 +134,7 @@ export abstract class McplTransport extends EventEmitter {
141
134
  /** Deliver a diagnostic line, buffering it if nobody is listening yet. */
142
135
  protected pushStderr(line: string): void {
143
136
  if (this.listenerCount('stderr') > 0) this.emit('stderr', line);
144
- else this.stderrBuffer.push(line);
137
+ else this.heldStderr.hold(line);
145
138
  }
146
139
 
147
140
  /**
@@ -277,8 +270,8 @@ export const STDERR_END_WAIT_MS = 1_000;
277
270
 
278
271
  export class StdioTransport extends McplTransport {
279
272
  readonly kind = 'stdio' as const;
280
- /** The first error on the child's stdin or stdout, named in the close reason. */
281
- private pipeError: Error | undefined;
273
+ private stopCause: Error | undefined;
274
+ private endedChild = false;
282
275
  /** The stream's last STDERR_WINDOW_CHARS, raw. */
283
276
  private stderrWindow = '';
284
277
  /** True once the child's stderr has ended or closed. */
@@ -303,8 +296,8 @@ export class StdioTransport extends McplTransport {
303
296
  return (cut ? '…' : '') + kept;
304
297
  }
305
298
 
306
- override get pipeFailure(): Error | undefined {
307
- return this.pipeError;
299
+ override get stdioStopCause(): Error | undefined {
300
+ return this.stopCause;
308
301
  }
309
302
 
310
303
  override stderrEnded(ms: number): Promise<void> {
@@ -337,25 +330,48 @@ export class StdioTransport extends McplTransport {
337
330
  ) {
338
331
  super(serverId);
339
332
 
340
- this.stopLines = readLines(stdout, (line) => this.pushLine(line));
333
+ const lineBound = testLineBound ?? MAX_LINE_CHARS;
334
+ this.stopLines = readLines(stdout, {
335
+ line: (line) => this.pushLine(line),
336
+ tooLong: (maxChars) => this.stopStdio(new Error(`the server wrote a line on stdout longer than ${maxChars.toLocaleString('en-US')} UTF-16 code units`), false),
337
+ }, lineBound);
341
338
 
342
- // Stitch stderr chunks into whole lines (a chunk can split a line, and a
343
- // character's bytes), and keep the stream's last characters for
344
- // stderrTail.
345
339
  let carry = '';
340
+ let dropping = false;
341
+ const dropped = `[a stderr line longer than ${lineBound.toLocaleString('en-US')} UTF-16 code units was dropped]`;
346
342
  const decoder = new StringDecoder('utf8');
347
343
  const received = (text: string) => {
348
344
  const window = this.stderrWindow + text;
349
345
  this.stderrWindow = window.slice(-STDERR_WINDOW_CHARS);
350
- const lines = (carry + text).split('\n');
351
- carry = lines.pop() ?? '';
352
- for (const l of lines) if (l.length > 0) this.pushStderr(l);
346
+ let rest = text;
347
+ if (dropping) {
348
+ const end = rest.indexOf('\n');
349
+ if (end === -1) return;
350
+ dropping = false;
351
+ rest = rest.slice(end + 1);
352
+ }
353
+ if (rest.includes('\n')) {
354
+ const lines = (carry + rest).split('\n');
355
+ carry = lines.pop() ?? '';
356
+ for (const l of lines) {
357
+ if (l.length > lineBound) this.pushStderr(dropped);
358
+ else if (l.length > 0) this.pushStderr(l);
359
+ }
360
+ } else {
361
+ carry += rest;
362
+ }
363
+ if (carry.length > lineBound) {
364
+ carry = '';
365
+ dropping = true;
366
+ this.pushStderr(dropped);
367
+ }
353
368
  };
354
369
  this.child.stderr?.on('data', (chunk: Buffer) => received(decoder.write(chunk)));
355
370
  // A last line with no newline is complete once the stream ends.
356
371
  this.child.stderr?.on('end', () => {
357
372
  received(decoder.end());
358
373
  if (carry.length > 0) this.pushStderr(carry);
374
+ carry = '';
359
375
  this.stderrDone = true;
360
376
  });
361
377
  // A stream destroyed by an error closes without ending: nothing more comes.
@@ -364,13 +380,17 @@ export class StdioTransport extends McplTransport {
364
380
  this.child.on('error', (err) => this.reportError(err));
365
381
  // The child's stdin and stdout carry the protocol, so after an error on
366
382
  // either, a write's EPIPE or a read error, this transport can't reach the
367
- // child even if it's still running. The handler ends the child as close()
368
- // does, SIGTERM and then SIGKILL, in the one attempt close() shares, and
369
- // the exit's close names the error, so the connection handles it as any
370
- // other exit. The close is the report: no 'error' is emitted, nothing is
371
- // thrown, and nothing more is written meanwhile (pipeFailure). The first
372
- // error starts that one attempt, and a later one, from either pipe, adds
373
- // nothing to it. A child that outlives SIGKILL too stays this
383
+ // child even if it's still running. Nothing more is written (stdioStopCause),
384
+ // and the handler first gives the child its exit grace to exit on its
385
+ // own: a child already dead when the write failed, as one a stop's signal
386
+ // reached first, exits within it, and its close carries its own code and
387
+ // signal. One still running then is ended as close() ends it, SIGTERM and
388
+ // then SIGKILL, in the one attempt close() shares, and only that close is
389
+ // pipeFailed, since its signal is this transport's. Either way the exit's
390
+ // close names the error, so the connection handles it as any other exit.
391
+ // The close is the report: no 'error' is emitted and nothing is thrown.
392
+ // The first error starts the wait, and a later one, from either pipe,
393
+ // adds nothing to it. A child that outlives SIGKILL too stays this
374
394
  // transport's: 'unreaped' says so, unless the host is closing it, whose
375
395
  // close() reports the failure itself. A child that stops reading without
376
396
  // closing its stdin raises no error: the writes are buffered, and only
@@ -378,15 +398,8 @@ export class StdioTransport extends McplTransport {
378
398
  // Bun a child that closes its stdin isn't reported either: Bun emits no
379
399
  // 'error' for the writes to it (checked on 1.3.10, 1.4.3 and 1.4.4's
380
400
  // canary), so request timeouts are its only bound too.
381
- const endOnPipeError = (err: Error) => {
382
- if (this.pipeError) return;
383
- this.pipeError = err;
384
- void this.terminate().catch((failure: unknown) => {
385
- if (!this.closed) this.emit('unreaped', failure instanceof Error ? failure : new Error(String(failure)));
386
- });
387
- };
388
- this.child.stdin?.on('error', endOnPipeError);
389
- this.child.stdout?.on('error', endOnPipeError);
401
+ this.child.stdin?.on('error', (err) => this.stopStdio(err, true));
402
+ this.child.stdout?.on('error', (err) => this.stopStdio(err, true));
390
403
  // stderr carries diagnostics only: a read error on it is reported, to the
391
404
  // listeners or else in the log, and never thrown. It ends nothing once
392
405
  // the connection is wired; during a handshake the listener is the
@@ -395,10 +408,24 @@ export class StdioTransport extends McplTransport {
395
408
  this.child.on('exit', (code, signal) => this.markClosed({
396
409
  code,
397
410
  signal,
398
- reason: this.pipeError ? `child process exited, and its pipe failed with ${this.pipeError.message}` : 'child process exited',
411
+ reason: this.stopCause ? `child process exited, and this host had stopped using the server's stdio (${this.stopCause.message})` : 'child process exited',
412
+ ...(this.endedChild ? { pipeFailed: true } : {}),
399
413
  }));
400
414
  }
401
415
 
416
+ private stopStdio(cause: Error, exitGraceFirst: boolean): void {
417
+ if (this.stopCause) return;
418
+ this.stopCause = cause;
419
+ const exitedFirst = exitGraceFirst ? this.exitWithin(this.exitGrace()) : Promise.resolve(this.exited());
420
+ void exitedFirst.then((gone) => {
421
+ if (gone || this.terminating) return;
422
+ this.endedChild = true;
423
+ return this.terminate();
424
+ }).catch((failure: unknown) => {
425
+ if (!this.closed) this.emit('unreaped', failure instanceof Error ? failure : new Error(String(failure)));
426
+ });
427
+ }
428
+
402
429
  /**
403
430
  * Spawn `config.command` and resolve with a ready stdio transport once the
404
431
  * child has spawned. Rejects if the command is missing (a config bug) or the
@@ -455,9 +482,7 @@ export class StdioTransport extends McplTransport {
455
482
  }
456
483
 
457
484
  writeLine(json: string): void {
458
- // Nothing more goes to a child whose stdin or stdout has failed: this
459
- // transport is ending it.
460
- if (this.pipeError) return;
485
+ if (this.stopCause) return;
461
486
  // stdin may be gone if the child died between the closed-check and here.
462
487
  this.child.stdin?.write(escapeLineSeparators(json) + '\n');
463
488
  }
@@ -493,6 +518,22 @@ export class StdioTransport extends McplTransport {
493
518
  await this.terminate();
494
519
  }
495
520
 
521
+ /** How long the child gets to exit after a pipe error, after SIGTERM and
522
+ * after SIGKILL: {@link STDIO_EXIT_GRACE_MS}, or a test's grace for it. */
523
+ private exitGrace(): number {
524
+ return testExitGraces.get(this.child) ?? STDIO_EXIT_GRACE_MS;
525
+ }
526
+
527
+ /** Whether the child exits within `ms`: true at once if it has. */
528
+ private exitWithin(ms: number): Promise<boolean> {
529
+ if (this.exited()) return Promise.resolve(true);
530
+ return new Promise((resolve) => {
531
+ const onExit = () => { clearTimeout(timer); resolve(true); };
532
+ const timer = setTimeout(() => { this.child.off('exit', onExit); resolve(false); }, ms);
533
+ this.child.once('exit', onExit);
534
+ });
535
+ }
536
+
496
537
  /** Whether the child has exited, or never started (a spawn error). */
497
538
  private exited(): boolean {
498
539
  return this.child.pid === undefined || this.child.exitCode !== null || this.child.signalCode !== null;
@@ -518,26 +559,82 @@ export class StdioTransport extends McplTransport {
518
559
  private async escalate(): Promise<void> {
519
560
  const child = this.child;
520
561
  if (this.exited()) return;
562
+ const grace = this.exitGrace();
521
563
  const exited = new Promise<void>((resolve) => child.once('exit', () => resolve()));
522
564
  const exitWithin = (ms: number) => new Promise<boolean>((resolve) => {
523
565
  const timer = setTimeout(() => resolve(false), ms);
524
566
  void exited.then(() => { clearTimeout(timer); resolve(true); });
525
567
  });
526
568
  child.kill('SIGTERM');
527
- if (await exitWithin(STDIO_EXIT_GRACE_MS)) return;
569
+ if (await exitWithin(grace)) return;
528
570
  const named = `stdio child ${child.pid} of MCP server "${this.serverId}"`;
529
- console.error(`[mcpl] ${named} ignored SIGTERM for ${STDIO_EXIT_GRACE_MS}ms — sending SIGKILL`);
571
+ console.error(`[mcpl] ${named} ignored SIGTERM for ${grace}ms — sending SIGKILL`);
530
572
  child.kill('SIGKILL');
531
- if (await exitWithin(STDIO_EXIT_GRACE_MS)) return;
573
+ if (await exitWithin(grace)) return;
532
574
  throw new Error(
533
- `${named} did not exit within ${STDIO_EXIT_GRACE_MS}ms of SIGKILL; it could not be reaped`,
575
+ `${named} did not exit within ${grace}ms of SIGKILL; it could not be reaped`,
534
576
  );
535
577
  }
536
578
  }
537
579
 
538
- /** How long a stdio child gets to exit after SIGTERM, and again after SIGKILL. */
580
+ /** How long a stdio child gets to exit on its own after a pipe error, after
581
+ * SIGTERM, and again after SIGKILL. */
539
582
  const STDIO_EXIT_GRACE_MS = 2_000;
540
583
 
584
+ const testExitGraces = new WeakMap<ChildProcess, number>();
585
+
586
+ /**
587
+ * For tests only: `child` gets `ms` in place of {@link STDIO_EXIT_GRACE_MS}.
588
+ * Set it only on a child that can exit by no means, such as one whose
589
+ * `kill()` is a no-op and that nothing else ends while it's closed: a short
590
+ * grace there races no exit, and only brings "could not be reaped" sooner.
591
+ */
592
+ export function setStdioExitGraceForTests(child: ChildProcess, ms: number): void {
593
+ testExitGraces.set(child, ms);
594
+ }
595
+
596
+ let testLineBound: number | undefined;
597
+
598
+ const MAX_HELD_STDERR_LINES = 200;
599
+
600
+ export class HeldStderr {
601
+ private lines: string[] = [];
602
+ private chars = 0;
603
+ private dropped = 0;
604
+
605
+ get empty(): boolean {
606
+ return this.lines.length === 0 && this.dropped === 0;
607
+ }
608
+
609
+ hold(line: string): void {
610
+ // V8 can make a line split from a longer string a slice that keeps the
611
+ // whole string alive, so a held line is copied to own only its characters.
612
+ this.lines.push(Buffer.from(line, 'utf16le').toString('utf16le'));
613
+ this.chars += line.length;
614
+ const ceiling = testLineBound ?? MAX_LINE_CHARS;
615
+ while (this.lines.length > MAX_HELD_STDERR_LINES || this.chars > ceiling) {
616
+ this.chars -= this.lines.shift()!.length;
617
+ this.dropped++;
618
+ }
619
+ }
620
+
621
+ take(): string[] {
622
+ const held = this.lines;
623
+ const taken = this.dropped > 0
624
+ ? [`[${this.dropped.toLocaleString('en-US')} earlier stderr line${this.dropped === 1 ? ' was' : 's were'} dropped: unread stderr is held to ${MAX_HELD_STDERR_LINES} lines and ${(testLineBound ?? MAX_LINE_CHARS).toLocaleString('en-US')} UTF-16 code units]`, ...held]
625
+ : held;
626
+ this.lines = [];
627
+ this.chars = 0;
628
+ this.dropped = 0;
629
+ return taken;
630
+ }
631
+ }
632
+
633
+ /** @internal */
634
+ export function setStdioLineBoundForTests(maxChars: number | undefined): void {
635
+ testLineBound = maxChars;
636
+ }
637
+
541
638
  // ---------------------------------------------------------------------------
542
639
  // WebSocket
543
640
  // ---------------------------------------------------------------------------
@@ -594,6 +691,8 @@ export class WebSocketTransport extends McplTransport {
594
691
  * at the open timeout.
595
692
  */
596
693
  static async open(config: McplServerConfig, signal?: AbortSignal): Promise<WebSocketTransport> {
694
+ const credentialRequired = checkedCredentialRequired(config);
695
+ if (credentialRequired === true) return WebSocketTransport.openStrict(config, signal);
597
696
  // Resolve the per-dial credential first: the provider runs on every
598
697
  // connect AND reconnect, so rotation happens at the transport layer and
599
698
  // nothing above it ever holds a credential.
@@ -691,6 +790,97 @@ export class WebSocketTransport extends McplTransport {
691
790
  });
692
791
  }
693
792
 
793
+ /** Separate opt-in path: the legacy dial above keeps its objects, reads,
794
+ * receivers and error behavior. Capture policy before calling host code;
795
+ * only noteDialSent uses the original config after awaiting (as a WeakMap
796
+ * key, without reading it). No provider result is stored on either config. */
797
+ private static async openStrict(config: McplServerConfig, signal?: AbortSignal): Promise<WebSocketTransport> {
798
+ const captured: McplServerConfig = {
799
+ id: config.id, url: config.url, tokenTransport: config.tokenTransport,
800
+ credentialRequired: true, token: config.token, accessProvider: config.accessProvider,
801
+ command: config.command, transport: config.transport, protocol: config.protocol,
802
+ };
803
+ const id = captured.id;
804
+ const fail = (reason: 'provider-failed' | 'credential-rejected' | 'engine-unsupported' | 'connection-failed') =>
805
+ new Error(`MCPL server "${id}": ${reason}`);
806
+ let problems: string[];
807
+ try { problems = serverConfigProblems(captured); }
808
+ catch { throw fail('connection-failed'); }
809
+ if (problems.length) throw fail('connection-failed');
810
+ const provider = captured.accessProvider;
811
+ if (typeof provider !== 'function') throw fail('provider-failed');
812
+ const followRedirects = false;
813
+ let url: string;
814
+ try { url = buildWebSocketUrl(captured, null); }
815
+ catch { throw fail('connection-failed'); }
816
+ if (signal?.aborted) throw fail('connection-failed');
817
+ let token: unknown;
818
+ try {
819
+ // Preserve the provider's receiver, without re-reading any config input.
820
+ const provided = Promise.resolve(provider.call(config));
821
+ provided.catch(() => { /* ignored after abandonment */ });
822
+ token = signal ? await raceAbort(provided, signal) : await provided;
823
+ } catch {
824
+ if (signal?.aborted) throw fail('connection-failed');
825
+ throw fail('provider-failed');
826
+ }
827
+ if (signal?.aborted) throw fail('connection-failed');
828
+ if (typeof token !== 'string' || !headerCarries(token)) throw fail('provider-failed');
829
+ noteDialSent(config, { url, token });
830
+ let ws: WebSocket;
831
+ try {
832
+ ws = new WebSocket(url, { headers: { Authorization: `Bearer ${token}` }, followRedirects });
833
+ } catch { throw fail('connection-failed'); }
834
+ const swallowLateErrors = () => { /* settled; retry loop owns recovery */ };
835
+ return new Promise<WebSocketTransport>((resolve, reject) => {
836
+ let settled = false;
837
+ const abandon = (reason: 'connection-failed' | 'credential-rejected' | 'engine-unsupported' = 'connection-failed',
838
+ request?: { destroy?: () => void; abort?: () => void }, response?: { destroy: () => void }): void => {
839
+ if (settled) return;
840
+ settled = true;
841
+ clearTimeout(timer);
842
+ signal?.removeEventListener('abort', onAbort);
843
+ ws.removeAllListeners();
844
+ ws.on('error', swallowLateErrors);
845
+ // The status listener suppresses ws's default abort. Discard the body
846
+ // without inspecting it, and use the request API this engine offers.
847
+ try { response?.destroy(); } catch { /* already gone */ }
848
+ try {
849
+ if (typeof request?.destroy === 'function') request.destroy();
850
+ else request?.abort?.(); // Some clients expose abort(), not destroy().
851
+ } catch { /* already gone */ }
852
+ try { ws.terminate(); } catch { /* already gone */ }
853
+ reject(fail(reason));
854
+ };
855
+ const timer = setTimeout(() => abandon(), WS_OPEN_TIMEOUT_MS);
856
+ const onAbort = (): void => abandon();
857
+ if (signal?.aborted) { onAbort(); return; }
858
+ else signal?.addEventListener('abort', onAbort, { once: true });
859
+ // Installed only on this explicit strict path. No peer text is a status.
860
+ ws.once('unexpected-response', (request, response) => {
861
+ const status = response?.statusCode;
862
+ // A refused upgrade without structured status cannot classify auth.
863
+ // Generic socket errors still mean connection-failed, never parsed text.
864
+ abandon(typeof status !== 'number' || !Number.isFinite(status) ? 'engine-unsupported'
865
+ : status === 401 || status === 403 ? 'credential-rejected' : 'connection-failed', request, response);
866
+ });
867
+ ws.once('open', () => {
868
+ if (settled) return;
869
+ settled = true;
870
+ clearTimeout(timer);
871
+ signal?.removeEventListener('abort', onAbort);
872
+ ws.removeAllListeners('unexpected-response');
873
+ const transport = new WebSocketTransport(ws, captured, url);
874
+ // Strict transport-created errors carry only id and a fixed code;
875
+ // don't project a native error or its cause into them.
876
+ ws.removeAllListeners('error');
877
+ ws.on('error', () => transport.reportError(fail('connection-failed')));
878
+ resolve(transport);
879
+ });
880
+ ws.once('error', () => abandon());
881
+ });
882
+ }
883
+
694
884
  writeLine(json: string): void {
695
885
  if (this.ws.readyState === WebSocket.OPEN) {
696
886
  this.ws.send(escapeLineSeparators(json));
@@ -824,15 +1014,25 @@ type BooleanSetting = {
824
1014
  * boolean setting added to McplServerConfig doesn't compile until it has
825
1015
  * an entry here, so the check below covers it too. */
826
1016
  const SERVER_CONFIG_BOOLEANS: Record<BooleanSetting, string> = {
1017
+ credentialRequired: 'true requires a provider credential on every WebSocket dial without fallback; false or leaving it out keeps legacy behavior',
827
1018
  inheritEnv: 'true passes the server the host\'s whole environment, and false or leaving it out passes only the allowlisted and LC_* host variables',
828
1019
  reconnect: 'true retries in the background when the server can\'t be reached or its connection drops, and false or leaving it out doesn\'t',
829
1020
  allowHostCommands: 'true lets the server run the host\'s admin commands, such as undo, hide, unstick and quiesce, and false or leaving it out refuses them',
830
1021
  toolHints: 'true advertises MCPL tool hints (RFC-014) to an MCPL server, so its dynamic tools are announced in the conversation and called through toolsets--act; leaving it out does the same wherever the name toolsets is free; false lists every tool as before and advertises none',
831
1022
  };
832
1023
 
1024
+ /** Direct transport callers bypass config validation, but not this check. */
1025
+ function checkedCredentialRequired(config: McplServerConfig): boolean | undefined {
1026
+ const value = config.credentialRequired;
1027
+ if (value !== undefined && value !== null && typeof value !== 'boolean') {
1028
+ assertBooleanSetting(`MCPL server "${config.id}" credentialRequired`, value, SERVER_CONFIG_BOOLEANS.credentialRequired);
1029
+ }
1030
+ return value;
1031
+ }
1032
+
833
1033
  /**
834
- * Refuse a server config whose `inheritEnv`, `reconnect`, `allowHostCommands`
835
- * or `toolHints` holds anything but true or false. A config read from JSON can carry a quoted
1034
+ * Refuse a server config whose `inheritEnv`, `reconnect`, `allowHostCommands`,
1035
+ * `credentialRequired` or `toolHints` holds anything but true or false. A config read from JSON can carry a quoted
836
1036
  * "false" where the type says boolean, and every reader of these settings takes
837
1037
  * only `true` as on, so the quotes would hide what the value does. null counts
838
1038
  * as not set, so a generated config that writes an unset setting as null
@@ -864,6 +1064,7 @@ export function assertServerConfigBooleans(config: McplServerConfig): void {
864
1064
  * performed the MCPL handshake.
865
1065
  */
866
1066
  export async function openTransport(config: McplServerConfig, signal?: AbortSignal): Promise<McplTransport> {
1067
+ if (checkedCredentialRequired(config) === true) return WebSocketTransport.open(config, signal);
867
1068
  const binding = resolveServerBinding(config);
868
1069
  if (binding.family !== 'legacy') {
869
1070
  throw new Error(`MCP server "${config.id}" is configured for modern MCP; the legacy transports can't open it`);
package/src/mcpl/types.ts CHANGED
@@ -313,10 +313,27 @@ export interface McplServerConfig {
313
313
  */
314
314
  tokenTransport?: 'query' | 'header';
315
315
 
316
+ /**
317
+ * Require a provider credential on every WebSocket dial, including reconnect.
318
+ * Only true enables this; false, null and omission preserve optional credentials.
319
+ * Requires tokenTransport 'header', no static token (even empty), and no URL
320
+ * token parameter, userinfo or fragment. HTTP, stdio and modern servers cannot
321
+ * use this option. Cleartext ws:// is allowed only to localhost or a literal
322
+ * loopback address.
323
+ * Missing, throwing, empty or non-sendable providers fail without connecting;
324
+ * printable ASCII credentials with no space at either end have no length floor.
325
+ * Redirects are never followed. Refused upgrades need a finite numeric status:
326
+ * 401/403 yield credential-rejected; missing status yields engine-unsupported.
327
+ * Provider failures yield provider-failed; other dial failures yield
328
+ * connection-failed. Diagnostic redaction is unchanged.
329
+ */
330
+ credentialRequired?: boolean;
331
+
316
332
  /**
317
333
  * Lazily resolves the connection credential at EVERY dial — first connect
318
334
  * and each background reconnect — overriding `token` when it returns a
319
- * value (null/throw falls back to `token`). Host-attached plumbing, never
335
+ * value (null/throw falls back to `token`, except under `credentialRequired`,
336
+ * where they fail the dial). Host-attached plumbing, never
320
337
  * serialized: the point is that credentials stay out of the agent's
321
338
  * context entirely (the agent asks for access by name; the host fetches
322
339
  * something fresh each redial, which also makes short-lived credentials