@animalabs/agent-framework 0.29.0 → 0.31.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 (236) hide show
  1. package/README.md +71 -8
  2. package/dist/src/admission/admit.d.ts +90 -0
  3. package/dist/src/admission/admit.d.ts.map +1 -0
  4. package/dist/src/admission/admit.js +43 -0
  5. package/dist/src/admission/admit.js.map +1 -0
  6. package/dist/src/agent.d.ts +50 -9
  7. package/dist/src/agent.d.ts.map +1 -1
  8. package/dist/src/agent.js +125 -23
  9. package/dist/src/agent.js.map +1 -1
  10. package/dist/src/api/mcp-server-core.d.ts.map +1 -1
  11. package/dist/src/api/mcp-server-core.js +10 -3
  12. package/dist/src/api/mcp-server-core.js.map +1 -1
  13. package/dist/src/author-presentation.d.ts +50 -0
  14. package/dist/src/author-presentation.d.ts.map +1 -0
  15. package/dist/src/author-presentation.js +58 -0
  16. package/dist/src/author-presentation.js.map +1 -0
  17. package/dist/src/code-execution/isolate/render.d.ts.map +1 -1
  18. package/dist/src/code-execution/isolate/render.js +7 -6
  19. package/dist/src/code-execution/isolate/render.js.map +1 -1
  20. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  21. package/dist/src/code-execution/isolate/scripts.js +5 -4
  22. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  23. package/dist/src/dendrite/index.d.ts +6 -0
  24. package/dist/src/dendrite/index.d.ts.map +1 -0
  25. package/dist/src/dendrite/index.js +3 -0
  26. package/dist/src/dendrite/index.js.map +1 -0
  27. package/dist/src/dendrite/presets.d.ts +60 -0
  28. package/dist/src/dendrite/presets.d.ts.map +1 -0
  29. package/dist/src/dendrite/presets.js +117 -0
  30. package/dist/src/dendrite/presets.js.map +1 -0
  31. package/dist/src/dendrite/registry.d.ts +260 -0
  32. package/dist/src/dendrite/registry.d.ts.map +1 -0
  33. package/dist/src/dendrite/registry.js +742 -0
  34. package/dist/src/dendrite/registry.js.map +1 -0
  35. package/dist/src/dendrite/types.d.ts +392 -0
  36. package/dist/src/dendrite/types.d.ts.map +1 -0
  37. package/dist/src/dendrite/types.js +24 -0
  38. package/dist/src/dendrite/types.js.map +1 -0
  39. package/dist/src/framework.d.ts +806 -118
  40. package/dist/src/framework.d.ts.map +1 -1
  41. package/dist/src/framework.js +4180 -1121
  42. package/dist/src/framework.js.map +1 -1
  43. package/dist/src/gate/event-gate.d.ts +36 -21
  44. package/dist/src/gate/event-gate.d.ts.map +1 -1
  45. package/dist/src/gate/event-gate.js +223 -149
  46. package/dist/src/gate/event-gate.js.map +1 -1
  47. package/dist/src/gate/recipe-reconcile.d.ts.map +1 -1
  48. package/dist/src/gate/recipe-reconcile.js +6 -5
  49. package/dist/src/gate/recipe-reconcile.js.map +1 -1
  50. package/dist/src/host-presentation.d.ts +4 -0
  51. package/dist/src/host-presentation.d.ts.map +1 -1
  52. package/dist/src/host-presentation.js +12 -10
  53. package/dist/src/host-presentation.js.map +1 -1
  54. package/dist/src/index.d.ts +6 -2
  55. package/dist/src/index.d.ts.map +1 -1
  56. package/dist/src/index.js +7 -1
  57. package/dist/src/index.js.map +1 -1
  58. package/dist/src/inference-bounds.d.ts +37 -0
  59. package/dist/src/inference-bounds.d.ts.map +1 -0
  60. package/dist/src/inference-bounds.js +84 -0
  61. package/dist/src/inference-bounds.js.map +1 -0
  62. package/dist/src/line-reader.d.ts +7 -1
  63. package/dist/src/line-reader.d.ts.map +1 -1
  64. package/dist/src/line-reader.js +17 -6
  65. package/dist/src/line-reader.js.map +1 -1
  66. package/dist/src/mcpl/channel-registry.d.ts +84 -9
  67. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  68. package/dist/src/mcpl/channel-registry.js +136 -58
  69. package/dist/src/mcpl/channel-registry.js.map +1 -1
  70. package/dist/src/mcpl/errors.d.ts +1 -0
  71. package/dist/src/mcpl/errors.d.ts.map +1 -1
  72. package/dist/src/mcpl/errors.js +3 -0
  73. package/dist/src/mcpl/errors.js.map +1 -1
  74. package/dist/src/mcpl/inbound-source.d.ts +2 -2
  75. package/dist/src/mcpl/inbound-source.js +2 -2
  76. package/dist/src/mcpl/inference-router.d.ts +11 -1
  77. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  78. package/dist/src/mcpl/inference-router.js +65 -9
  79. package/dist/src/mcpl/inference-router.js.map +1 -1
  80. package/dist/src/mcpl/modern-connection.d.ts +10 -7
  81. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  82. package/dist/src/mcpl/modern-connection.js +85 -74
  83. package/dist/src/mcpl/modern-connection.js.map +1 -1
  84. package/dist/src/mcpl/protocol-family.d.ts +1 -1
  85. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  86. package/dist/src/mcpl/protocol-family.js +45 -3
  87. package/dist/src/mcpl/protocol-family.js.map +1 -1
  88. package/dist/src/mcpl/push-coalescer.d.ts +11 -7
  89. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  90. package/dist/src/mcpl/push-coalescer.js +21 -0
  91. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  92. package/dist/src/mcpl/references.d.ts +1 -2
  93. package/dist/src/mcpl/references.d.ts.map +1 -1
  94. package/dist/src/mcpl/references.js +3 -3
  95. package/dist/src/mcpl/references.js.map +1 -1
  96. package/dist/src/mcpl/server-connection.d.ts +14 -0
  97. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  98. package/dist/src/mcpl/server-connection.js +26 -22
  99. package/dist/src/mcpl/server-connection.js.map +1 -1
  100. package/dist/src/mcpl/tool-signature.d.ts +1 -0
  101. package/dist/src/mcpl/tool-signature.d.ts.map +1 -1
  102. package/dist/src/mcpl/tool-signature.js +16 -11
  103. package/dist/src/mcpl/tool-signature.js.map +1 -1
  104. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  105. package/dist/src/mcpl/toolsets-module.js +7 -0
  106. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  107. package/dist/src/mcpl/transport.d.ts +44 -21
  108. package/dist/src/mcpl/transport.d.ts.map +1 -1
  109. package/dist/src/mcpl/transport.js +283 -64
  110. package/dist/src/mcpl/transport.js.map +1 -1
  111. package/dist/src/mcpl/types.d.ts +17 -1
  112. package/dist/src/mcpl/types.d.ts.map +1 -1
  113. package/dist/src/mcpl/types.js.map +1 -1
  114. package/dist/src/module-registry.d.ts +32 -4
  115. package/dist/src/module-registry.d.ts.map +1 -1
  116. package/dist/src/module-registry.js +189 -25
  117. package/dist/src/module-registry.js.map +1 -1
  118. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
  119. package/dist/src/modules/discord/discord-js-client.js +24 -16
  120. package/dist/src/modules/discord/discord-js-client.js.map +1 -1
  121. package/dist/src/modules/sender-name.d.ts +5 -4
  122. package/dist/src/modules/sender-name.d.ts.map +1 -1
  123. package/dist/src/modules/sender-name.js +7 -5
  124. package/dist/src/modules/sender-name.js.map +1 -1
  125. package/dist/src/modules/workspace/disk-agreement.d.ts +16 -8
  126. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  127. package/dist/src/modules/workspace/disk-agreement.js +33 -12
  128. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  129. package/dist/src/modules/workspace/index.d.ts +6 -3
  130. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  131. package/dist/src/modules/workspace/index.js +61 -15
  132. package/dist/src/modules/workspace/index.js.map +1 -1
  133. package/dist/src/modules/workspace/observe.d.ts +9 -1
  134. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  135. package/dist/src/modules/workspace/observe.js +50 -10
  136. package/dist/src/modules/workspace/observe.js.map +1 -1
  137. package/dist/src/modules/workspace/reconcile.d.ts +11 -1
  138. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  139. package/dist/src/modules/workspace/reconcile.js +19 -8
  140. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  141. package/dist/src/modules/workspace/types.d.ts +8 -0
  142. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  143. package/dist/src/modules/workspace/types.js.map +1 -1
  144. package/dist/src/modules/workspace/watcher.d.ts +7 -0
  145. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  146. package/dist/src/modules/workspace/watcher.js +7 -5
  147. package/dist/src/modules/workspace/watcher.js.map +1 -1
  148. package/dist/src/notice-tags.d.ts +2 -0
  149. package/dist/src/notice-tags.d.ts.map +1 -1
  150. package/dist/src/notice-tags.js +2 -0
  151. package/dist/src/notice-tags.js.map +1 -1
  152. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
  153. package/dist/src/recovery/discord-awareness-outbox.js +1 -9
  154. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
  155. package/dist/src/silent-heartbeat.d.ts +4 -2
  156. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  157. package/dist/src/silent-heartbeat.js +7 -4
  158. package/dist/src/silent-heartbeat.js.map +1 -1
  159. package/dist/src/stored-injections.d.ts +4 -0
  160. package/dist/src/stored-injections.d.ts.map +1 -1
  161. package/dist/src/stored-injections.js +6 -0
  162. package/dist/src/stored-injections.js.map +1 -1
  163. package/dist/src/supplied-text.d.ts +2 -0
  164. package/dist/src/supplied-text.d.ts.map +1 -1
  165. package/dist/src/supplied-text.js +16 -7
  166. package/dist/src/supplied-text.js.map +1 -1
  167. package/dist/src/tool-result-guard.js +1 -1
  168. package/dist/src/tool-result-guard.js.map +1 -1
  169. package/dist/src/tune-out/coordinator.d.ts +368 -16
  170. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  171. package/dist/src/tune-out/coordinator.js +1224 -136
  172. package/dist/src/tune-out/coordinator.js.map +1 -1
  173. package/dist/src/tune-out/tools.d.ts +115 -7
  174. package/dist/src/tune-out/tools.d.ts.map +1 -1
  175. package/dist/src/tune-out/tools.js +55 -18
  176. package/dist/src/tune-out/tools.js.map +1 -1
  177. package/dist/src/types/agent.d.ts +27 -1
  178. package/dist/src/types/agent.d.ts.map +1 -1
  179. package/dist/src/types/framework.d.ts +153 -15
  180. package/dist/src/types/framework.d.ts.map +1 -1
  181. package/dist/src/types/module.d.ts +42 -3
  182. package/dist/src/types/module.d.ts.map +1 -1
  183. package/dist/src/types/trace.d.ts +140 -3
  184. package/dist/src/types/trace.d.ts.map +1 -1
  185. package/package.json +4 -4
  186. package/src/admission/admit.ts +113 -0
  187. package/src/agent.ts +138 -31
  188. package/src/api/mcp-server-core.ts +10 -3
  189. package/src/author-presentation.ts +81 -0
  190. package/src/code-execution/isolate/render.ts +7 -6
  191. package/src/code-execution/isolate/scripts.ts +5 -4
  192. package/src/dendrite/index.ts +32 -0
  193. package/src/dendrite/presets.ts +149 -0
  194. package/src/dendrite/registry.ts +808 -0
  195. package/src/dendrite/types.ts +397 -0
  196. package/src/framework.ts +4344 -1052
  197. package/src/gate/event-gate.ts +251 -150
  198. package/src/gate/recipe-reconcile.ts +7 -5
  199. package/src/host-presentation.ts +14 -10
  200. package/src/index.ts +16 -2
  201. package/src/inference-bounds.ts +111 -0
  202. package/src/line-reader.ts +22 -5
  203. package/src/mcpl/channel-registry.ts +170 -60
  204. package/src/mcpl/errors.ts +4 -0
  205. package/src/mcpl/inbound-source.ts +2 -2
  206. package/src/mcpl/inference-router.ts +67 -10
  207. package/src/mcpl/modern-connection.ts +83 -68
  208. package/src/mcpl/protocol-family.ts +37 -4
  209. package/src/mcpl/push-coalescer.ts +21 -7
  210. package/src/mcpl/references.ts +3 -4
  211. package/src/mcpl/server-connection.ts +30 -21
  212. package/src/mcpl/tool-signature.ts +18 -11
  213. package/src/mcpl/toolsets-module.ts +7 -0
  214. package/src/mcpl/transport.ts +261 -60
  215. package/src/mcpl/types.ts +18 -1
  216. package/src/module-registry.ts +178 -25
  217. package/src/modules/discord/discord-js-client.ts +33 -29
  218. package/src/modules/sender-name.ts +8 -5
  219. package/src/modules/workspace/disk-agreement.ts +39 -15
  220. package/src/modules/workspace/index.ts +65 -14
  221. package/src/modules/workspace/observe.ts +51 -10
  222. package/src/modules/workspace/reconcile.ts +22 -8
  223. package/src/modules/workspace/types.ts +5 -0
  224. package/src/modules/workspace/watcher.ts +8 -6
  225. package/src/notice-tags.ts +2 -0
  226. package/src/recovery/discord-awareness-outbox.ts +1 -11
  227. package/src/silent-heartbeat.ts +7 -4
  228. package/src/stored-injections.ts +7 -0
  229. package/src/supplied-text.ts +17 -6
  230. package/src/tool-result-guard.ts +1 -1
  231. package/src/tune-out/coordinator.ts +1374 -141
  232. package/src/tune-out/tools.ts +166 -13
  233. package/src/types/agent.ts +30 -1
  234. package/src/types/framework.ts +157 -15
  235. package/src/types/module.ts +42 -3
  236. package/src/types/trace.ts +154 -3
@@ -66,8 +66,8 @@ import {
66
66
  type TransportSendOptions,
67
67
  } from '@modelcontextprotocol/client';
68
68
 
69
- import { StdioTransport, type McplTransport } from './transport.js';
70
- import { McplProtocolVersionError, McplRequestError, McplUnreapedLaunchError } from './server-connection.js';
69
+ import { HeldStderr, STDERR_END_WAIT_MS, StdioTransport, type McplTransport, type TransportCloseInfo } from './transport.js';
70
+ import { McplProtocolVersionError, McplRequestError, McplUnreapedLaunchError, stderrTailOf, withStderr } from './server-connection.js';
71
71
  import { LEGACY_MCP_PROTOCOL_VERSION, MAX_TIMER_MS, MODERN_MCP_PROTOCOL_VERSION, checkServerConfig, describeCredentialError, logDroppedLine, maskedError, noteDialSent, redactServerText, redactedData, serverTextRedactor, type ServerTextRedactor } from './protocol-family.js';
72
72
  import type { McplServerConfig, McpToolDefinition } from './types.js';
73
73
  import { boundedForLog } from './errors.js';
@@ -79,8 +79,6 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
79
79
  const CONNECT_TIMEOUT_MS = 30_000;
80
80
  /** Inventory listing has its own bound: it is host work, not a resident's call. */
81
81
  const LIST_TIMEOUT_MS = 30_000;
82
- /** Startup stderr kept for the first listener. */
83
- const STDERR_BACKLOG_LINES = 200;
84
82
 
85
83
  /** A modern tool result as the framework consumes it. `structuredContent` is
86
84
  * kept by presence: `false`, `0` and `null` are values, not absence. */
@@ -113,18 +111,12 @@ interface WireRecord {
113
111
  continued: boolean;
114
112
  }
115
113
 
116
- /**
117
- * A send that Connectome's own transport refused before writing anything (its
118
- * stdio line is closed): nothing of that request reached the server. `pipe`:
119
- * the child's stdin or stdout had failed, so this host can no longer write to
120
- * that server at all.
121
- */
122
114
  class NotWrittenError extends Error {
123
- readonly pipe: boolean;
124
- constructor(message: string, pipe = false) {
115
+ readonly stdioStopped: boolean;
116
+ constructor(message: string, stdioStopped = false) {
125
117
  super(message);
126
118
  this.name = 'NotWrittenError';
127
- this.pipe = pipe;
119
+ this.stdioStopped = stdioStopped;
128
120
  }
129
121
  }
130
122
 
@@ -309,6 +301,9 @@ class SpawnerStdioTransport implements Transport {
309
301
  onerror?: (error: Error) => void;
310
302
  onmessage?: <T extends JSONRPCMessage>(message: T, extra?: MessageExtraInfo) => void;
311
303
  private line: McplTransport | null = null;
304
+ /** How the child ended, once its line has closed: the line's reason, and
305
+ * the child's exit code or signal. */
306
+ closeInfo: TransportCloseInfo | undefined;
312
307
  /** The spawn start() awaits, so a close() made meanwhile closes its child. */
313
308
  private spawning: Promise<McplTransport> | null = null;
314
309
 
@@ -318,9 +313,6 @@ class SpawnerStdioTransport implements Transport {
318
313
  constructor(
319
314
  private readonly config: McplServerConfig,
320
315
  private readonly onStderr: (line: string) => void,
321
- /** The child's pipe failed and it outlived SIGKILL too, so the line
322
- * stays open, owning it (the line's 'unreaped'): `reason` names the
323
- * pipe's error, and `failure` is the reap's. */
324
316
  private readonly onUnreaped: (reason: string, failure: Error) => void,
325
317
  ) {
326
318
  this.recorded = serverTextRedactor(config);
@@ -357,22 +349,31 @@ class SpawnerStdioTransport implements Transport {
357
349
  });
358
350
  line.on('stderr', (text: string) => this.onStderr(text));
359
351
  line.on('error', (error: Error) => this.onerror?.(error));
360
- line.on('close', () => this.onclose?.());
361
- line.on('unreaped', (failure: Error) => this.onUnreaped(`its stdio pipe failed with ${line.pipeFailure?.message ?? 'an error'}`, failure));
352
+ line.on('close', (info: TransportCloseInfo) => {
353
+ this.closeInfo = info;
354
+ this.onclose?.();
355
+ });
356
+ line.on('unreaped', (failure: Error) => this.onUnreaped(`this host stopped using the server's stdio (${line.stdioStopCause?.message ?? 'an error'})`, failure));
362
357
  }
363
358
 
364
- /** The first error on the child's stdin or stdout, once there is one: the
365
- * line is ending the child then, and writes nothing more to it. */
366
- get pipeFailure(): Error | undefined {
367
- return this.line?.pipeFailure;
359
+ get stdioStopCause(): Error | undefined {
360
+ return this.line?.stdioStopCause;
361
+ }
362
+
363
+ /** The last of the child's stderr, redacted, once the stream has ended or
364
+ * STDERR_END_WAIT_MS has passed, as the legacy handshake waits before it
365
+ * refuses; '' when there's no child or it wrote nothing. */
366
+ async stderrTail(): Promise<string> {
367
+ const line = this.line;
368
+ if (!line) return '';
369
+ await line.stderrEnded(STDERR_END_WAIT_MS);
370
+ return line.stderrTailRedacted(serverTextRedactor(this.config)).trimEnd();
368
371
  }
369
372
 
370
373
  async send(message: JSONRPCMessage): Promise<void> {
371
374
  if (!this.line || this.line.closed) throw new NotWrittenError(`MCP server "${this.config.id}" stdio transport is closed`);
372
- // Once the child's stdin or stdout has failed, the line is ending it and
373
- // writes nothing more to it, so a message is refused here as not sent.
374
- const failed = this.pipeFailure;
375
- if (failed) throw new NotWrittenError(`MCP server "${this.config.id}" stdio pipe failed with ${failed.message}`, true);
375
+ const stopped = this.stdioStopCause;
376
+ if (stopped) throw new NotWrittenError(`MCP server "${this.config.id}": this host stopped using the server's stdio (${stopped.message})`, true);
376
377
  this.line.writeLine(JSON.stringify(message));
377
378
  }
378
379
 
@@ -460,10 +461,7 @@ export class ModernMcpConnection extends EventEmitter {
460
461
  /** A lost session's reap in flight: the reconnect follows it, and
461
462
  * close() waits for it. */
462
463
  private losing: { session: Session; done: Promise<void> } | null = null;
463
- /** Child stderr from before anyone listens (the caller attaches only once
464
- * connect() returns), delivered to the first listener. Bounded: the
465
- * newest lines are kept. */
466
- private stderrBacklog: string[] = [];
464
+ private readonly heldStderr = new HeldStderr();
467
465
  private redactor?: ServerTextRedactor;
468
466
 
469
467
  /** The server's text with its configuration's credentials replaced, for
@@ -489,9 +487,8 @@ export class ModernMcpConnection extends EventEmitter {
489
487
  if (this.listenerCount('error') === 1) console.error(`[mcp] ${this.id}: ${redactServerText(error.message, this.config)}`);
490
488
  });
491
489
  this.on('newListener', (event: string | symbol) => {
492
- if (event !== 'stderr' || this.stderrBacklog.length === 0) return;
493
- const pending = this.stderrBacklog;
494
- this.stderrBacklog = [];
490
+ if (event !== 'stderr' || this.heldStderr.empty) return;
491
+ const pending = this.heldStderr.take();
495
492
  // After the listener is added (newListener fires just before).
496
493
  queueMicrotask(() => { for (const line of pending) this.emit('stderr', { line }); });
497
494
  });
@@ -502,8 +499,7 @@ export class ModernMcpConnection extends EventEmitter {
502
499
  this.emit('stderr', { line });
503
500
  return;
504
501
  }
505
- this.stderrBacklog.push(line);
506
- if (this.stderrBacklog.length > STDERR_BACKLOG_LINES) this.stderrBacklog.shift();
502
+ this.heldStderr.hold(line);
507
503
  }
508
504
 
509
505
  /** The per-call deadline: `requestTimeoutMs`, validated positive. */
@@ -520,6 +516,14 @@ export class ModernMcpConnection extends EventEmitter {
520
516
  return this.reconnectEnabled && !this.closedByHost && this.unreaped.length === 0;
521
517
  }
522
518
 
519
+ /**
520
+ * Stop reconnecting, closing nothing, as the legacy engine's does.
521
+ * @internal For the framework's stop(); not a host API.
522
+ */
523
+ stopReconnecting(): void {
524
+ this.reconnectEnabled = false;
525
+ }
526
+
523
527
  /** Whether close() has been called: closed for good, or closing. A
524
528
  * connection lost on its own is not closed in this sense. */
525
529
  get isClosed(): boolean {
@@ -610,12 +614,14 @@ export class ModernMcpConnection extends EventEmitter {
610
614
  // `cause`, can carry it whole (maskedError). The protocol refusal
611
615
  // above was built from redacted words.
612
616
  const masked = maskedError(error, this.recorded);
617
+ const stderrTail = stderrTailOf(error);
618
+ if (stderrTail !== undefined) Object.assign(masked, { stderrTail });
613
619
  // A launch that couldn't be reaped halts reconnecting, so no retry
614
620
  // will come: the first connect fails, as one without reconnect does.
615
621
  if (!this.reconnectEnabled || this.holdsUnreapedChild) throw masked;
616
- console.error(`MCP server "${this.id}" initial connect failed, will retry:`, masked.message);
622
+ console.error(`MCP server "${this.id}" initial connect failed, will retry: ${masked.message}${withStderr(masked, this.recorded)}`);
617
623
  this.reconnectAttempts = 1;
618
- this.emit('connect-failed', { error: (error as Error).message, attempt: 0 });
624
+ this.emit('connect-failed', { error: (error as Error).message, ...(stderrTail === undefined ? {} : { stderrTail }), attempt: 0 });
619
625
  this.scheduleReconnect();
620
626
  }
621
627
  }
@@ -656,11 +662,6 @@ export class ModernMcpConnection extends EventEmitter {
656
662
  ? new SpawnerStdioTransport(
657
663
  this.config,
658
664
  (line) => { if (this.isCurrent(generation)) this.deliverStderr(line); },
659
- // A child whose pipe failed and that outlived SIGKILL: a loss whose
660
- // launch is already known unreaped, so it's held as the loss is
661
- // handled (handleLost). During a connect there is no session to lose
662
- // yet: a connect whose pipe has failed fails, whether or not the SDK
663
- // resolves it (establish), and its cleanup holds the launch.
664
665
  (reason, failure) => { if (this.isCurrent(generation)) this.handleLost(generation, reason, failure); },
665
666
  )
666
667
  : null;
@@ -697,7 +698,9 @@ export class ModernMcpConnection extends EventEmitter {
697
698
  // Marked before the session is adopted too: a close during the connect
698
699
  // makes it a failed connect (see establish).
699
700
  session.lost = true;
700
- if (this.isCurrent(generation)) this.handleLost(generation, 'transport closed');
701
+ // Over stdio, how its child ended, as legacy's loss carries it.
702
+ const ended = session.stdio?.closeInfo;
703
+ if (this.isCurrent(generation)) this.handleLost(generation, ended?.reason ?? 'transport closed', undefined, ended);
701
704
  };
702
705
 
703
706
  // `done` settles only once this connect's own cleanup has finished, on
@@ -713,6 +716,13 @@ export class ModernMcpConnection extends EventEmitter {
713
716
  }
714
717
  }
715
718
 
719
+ /** `error` carrying the session's stderr tail as `stderrTail`, as a
720
+ * legacy connect's failure does, when the child wrote any. */
721
+ private static async withStderrTail(session: Session, error: unknown): Promise<unknown> {
722
+ const tail = await session.stdio?.stderrTail();
723
+ return tail && error instanceof Error ? Object.assign(error, { stderrTail: tail }) : error;
724
+ }
725
+
716
726
  /** Connect one generation's client, and adopt it if it's still current. */
717
727
  private async establish(session: Session): Promise<void> {
718
728
  try {
@@ -720,7 +730,7 @@ export class ModernMcpConnection extends EventEmitter {
720
730
  } catch (error) {
721
731
  const failure = await ModernMcpConnection.closeSession(session);
722
732
  if (failure) this.holdUnreaped(session, failure);
723
- throw this.versionVerdict(error) ?? error;
733
+ throw await ModernMcpConnection.withStderrTail(session, this.versionVerdict(error) ?? error);
724
734
  }
725
735
  if (!this.isCurrent(session.generation)) {
726
736
  // Superseded or closed while connecting: this generation never serves.
@@ -729,22 +739,22 @@ export class ModernMcpConnection extends EventEmitter {
729
739
  return;
730
740
  }
731
741
  // The transport closed while the client connected, such as a child that
732
- // exited at the connect-time list-change subscribe, or the child's stdio
733
- // pipe failed, such as that subscribe's write meeting EPIPE: the SDK
734
- // reports the subscribe's failure only as an error, and resolves the
735
- // connect anyway (R1-2). Adopted, the session would read as connected
736
- // with nothing it can write to behind it, and from a child that outlived
737
- // SIGKILL, whose 'unreaped' came while there was no session to lose, no
738
- // later close would come to reconnect it. So the connect failed: its
739
- // close reaps the child, and a child it can't reap is held. A failed
740
- // pipe is named whether or not the child has exited by now.
741
- const pipe = session.stdio?.pipeFailure;
742
- if (session.lost || pipe) {
742
+ // exited at the connect-time list-change subscribe, or this host stopped
743
+ // using the child's stdio, such as after that subscribe's write met
744
+ // EPIPE: the SDK reports the subscribe's failure only as an error, and
745
+ // resolves the connect anyway (R1-2). Adopted, the session would read as
746
+ // connected with nothing it can write to behind it, and from a child that
747
+ // outlived SIGKILL, whose 'unreaped' came while there was no session to
748
+ // lose, no later close would come to reconnect it. So the connect failed:
749
+ // its close reaps the child, and a child it can't reap is held. The
750
+ // stop's cause is named whether or not the child has exited by now.
751
+ const stopped = session.stdio?.stdioStopCause;
752
+ if (session.lost || stopped) {
743
753
  const failure = await ModernMcpConnection.closeSession(session);
744
754
  if (failure) this.holdUnreaped(session, failure);
745
- throw new Error(pipe
746
- ? `MCP server "${this.id}" stdio pipe failed with ${pipe.message} while connecting`
747
- : `MCP server "${this.id}" closed its connection while connecting`);
755
+ throw await ModernMcpConnection.withStderrTail(session, new Error(stopped
756
+ ? `MCP server "${this.id}": this host stopped using the server's stdio while connecting (${stopped.message})`
757
+ : `MCP server "${this.id}" closed its connection while connecting`));
748
758
  }
749
759
  this.session = session;
750
760
  this.protocolVersion = MODERN_MCP_PROTOCOL_VERSION;
@@ -847,10 +857,8 @@ export class ModernMcpConnection extends EventEmitter {
847
857
  }
848
858
  }
849
859
 
850
- /** The transport went away (child exit, or a closed HTTP client).
851
- * `unreaped`: the child's pipe failed and it outlived SIGKILL, so its
852
- * launch is already known unreaped. */
853
- private handleLost(generation: number, reason: string, unreaped?: Error): void {
860
+ /** The transport went away (child exit, or a closed HTTP client). */
861
+ private handleLost(generation: number, reason: string, unreaped?: Error, ended?: TransportCloseInfo): void {
854
862
  const session = this.session;
855
863
  if (session?.generation !== generation) return;
856
864
  this.session = null;
@@ -886,7 +894,7 @@ export class ModernMcpConnection extends EventEmitter {
886
894
  // skip neither 'close' nor the report.
887
895
  if (unreaped) this.holdUnreaped(session, unreaped, false);
888
896
  try {
889
- this.emit('close', { reason });
897
+ this.emit('close', { reason, code: ended?.code ?? null, signal: ended?.signal ?? null, pipeFailed: ended?.pipeFailed === true });
890
898
  } finally {
891
899
  if (unreaped) this.reportUnreaped(unreaped);
892
900
  }
@@ -920,19 +928,26 @@ export class ModernMcpConnection extends EventEmitter {
920
928
  } catch (error) {
921
929
  if (this.closedByHost) return;
922
930
  this.reconnectAttempts = attempt + 1;
931
+ const stderrTail = stderrTailOf(error);
932
+ const failed = (message: string, permanent?: true) => this.emit('reconnect-failed', {
933
+ error: message,
934
+ ...(stderrTail === undefined ? {} : { stderrTail }),
935
+ attempt,
936
+ ...(permanent ? { permanent } : {}),
937
+ });
923
938
  const halted = this.unreapedFailure();
924
939
  if (halted) {
925
- this.emit('reconnect-failed', { error: `${(error as Error).message}; ${halted}`, attempt, permanent: true });
940
+ failed(`${(error as Error).message}; ${halted}`, true);
926
941
  return;
927
942
  }
928
943
  // No common revision: every further attempt would hear the same
929
944
  // answer. Stop; only the configuration can fix it.
930
945
  if (error instanceof McplProtocolVersionError) {
931
946
  this.reconnectEnabled = false;
932
- this.emit('reconnect-failed', { error: error.message, attempt, permanent: true });
947
+ failed(error.message, true);
933
948
  return;
934
949
  }
935
- this.emit('reconnect-failed', { error: (error as Error).message, attempt });
950
+ failed((error as Error).message);
936
951
  this.scheduleReconnect();
937
952
  }
938
953
  }
@@ -1168,9 +1183,9 @@ export class ModernMcpConnection extends EventEmitter {
1168
1183
  : `${said}, so whether the request took effect is unknown. Check its effect before trying again.`;
1169
1184
  if (!record.handedOff) {
1170
1185
  log(`${what} not sent: ${logDetail(message)}`);
1171
- const pipe = error instanceof NotWrittenError && error.pipe;
1186
+ const stdioStopped = error instanceof NotWrittenError && error.stdioStopped;
1172
1187
  return new McplRequestError(
1173
- pipe
1188
+ stdioStopped
1174
1189
  ? `The request wasn't sent: this host can no longer write to ${server}, so nothing ran. ${this.retryText()}`
1175
1190
  // Before the wire, the cause can pass (the stdio line closing as
1176
1191
  // the connection is lost) or stay (a tool's outputSchema the SDK
@@ -695,7 +695,7 @@ export function serverConfigProblems(config: McplServerConfig): string[] {
695
695
  } catch (error) {
696
696
  return [(error as Error).message];
697
697
  }
698
- const problems: string[] = [];
698
+ const problems: string[] = credentialRequiredProblems(config);
699
699
  if (binding.transport === 'http') {
700
700
  // fetch refuses a url with userinfo, and its error quotes the whole url,
701
701
  // password included, into the connect failure's log line, trace and
@@ -760,6 +760,38 @@ export function serverConfigProblems(config: McplServerConfig): string[] {
760
760
  return problems;
761
761
  }
762
762
 
763
+ /** Strict-only admission, also applied to a captured policy at dial time.
764
+ * Nothing supplied in an invalid setting is quoted. Runtime providers are
765
+ * host-attached, so their presence is checked by the dial, not here. */
766
+ function credentialRequiredProblems(config: McplServerConfig): string[] {
767
+ const flag: unknown = config.credentialRequired;
768
+ if (flag === undefined || flag === null || flag === false) return [];
769
+ const problem = (why: string) => [`MCP server "${config.id}": credentialRequired ${why}`];
770
+ if (flag !== true) return problem('must be a boolean');
771
+ let binding: ServerBinding;
772
+ try { binding = resolveServerBinding(config); }
773
+ catch { return problem('requires a valid WebSocket binding'); }
774
+ if (binding.transport !== 'websocket' || binding.family !== 'legacy') {
775
+ return problem('applies only to a legacy ws(s):// server, not HTTP, stdio or modern servers');
776
+ }
777
+ if (config.token !== undefined && config.token !== null) return problem('forbids a static token, including an empty token');
778
+ if (config.tokenTransport !== 'header') return problem("requires tokenTransport 'header'");
779
+ const url = new URL(config.url!);
780
+ if (url.hash) return problem('forbids a URL fragment');
781
+ if (url.protocol === 'ws:') {
782
+ if (!isLoopbackHost(url.hostname)) return problem('requires TLS outside loopback');
783
+ const name = url.hostname.toLowerCase();
784
+ const host = name.replace(/\.$/, '');
785
+ if (host.endsWith('.localhost') || name === 'localhost.') {
786
+ return problem('requires TLS for a localhost name other than localhost itself, which a resolver can send off this host; use localhost or a loopback address');
787
+ }
788
+ if (host === '0.0.0.0' || host === '[::]') {
789
+ return problem('requires TLS for the unspecified address, which is not a loopback address; use 127.0.0.1 or [::1]');
790
+ }
791
+ }
792
+ return [];
793
+ }
794
+
763
795
  /**
764
796
  * What's wrong with a WebSocket server's `tokenTransport`, as a message, or
765
797
  * undefined: a value that is neither mode, or `'header'` with a url that
@@ -770,18 +802,19 @@ export function serverConfigProblems(config: McplServerConfig): string[] {
770
802
  * The refusal names the value's kind, never the value, which could be a
771
803
  * credential pasted into the wrong field, and quotes nothing of the url.
772
804
  */
773
- export function tokenTransportProblem(config: Pick<McplServerConfig, 'id' | 'tokenTransport'>, url: URL): string | undefined {
805
+ export function tokenTransportProblem(config: Pick<McplServerConfig, 'id' | 'tokenTransport' | 'credentialRequired'>, url: URL): string | undefined {
774
806
  // A configuration read from JSON can hold anything here.
775
807
  const mode: unknown = config.tokenTransport;
776
808
  if (mode === undefined || mode === null || mode === 'query') return undefined;
777
809
  if (mode !== 'header') {
778
810
  return `MCP server "${config.id}": tokenTransport must be 'query' or 'header', not ${typeof mode === 'string' ? 'another string' : kindOf(mode)}`;
779
811
  }
812
+ const strict = config.credentialRequired === true;
780
813
  if (url.searchParams.has('token')) {
781
- return `MCP server "${config.id}": with tokenTransport 'header' the url must not carry a "token" query parameter, which would still put a credential in it; give it as "token" or "accessProvider"`;
814
+ return `MCP server "${config.id}": with tokenTransport 'header' the url must not carry a "token" query parameter, which would still put a credential in it; give it as ${strict ? '"accessProvider"' : '"token" or "accessProvider"'}`;
782
815
  }
783
816
  if (url.username || url.password) {
784
- return `MCP server "${config.id}": with tokenTransport 'header' the url must not carry userinfo (user:password@), since the Bearer header replaces the userinfo's Basic credential whenever it carries a credential; leave out "tokenTransport" to send the userinfo as before, or give the credential as "token" or "accessProvider"`;
817
+ return `MCP server "${config.id}": with tokenTransport 'header' the url must not carry userinfo (user:password@), since the Bearer header replaces the userinfo's Basic credential whenever it carries a credential; ${strict ? 'give the credential as "accessProvider"' : 'leave out "tokenTransport" to send the userinfo as before, or give the credential as "token" or "accessProvider"'}`;
785
818
  }
786
819
  return undefined;
787
820
  }
@@ -295,18 +295,13 @@ export interface CoalescingReceiptRecord {
295
295
 
296
296
  export interface CoalescerHost<E> {
297
297
  /** True iff the placement still exists and, as far as the host can tell,
298
- * no model request has included it. The host can't always tell: a
299
- * message injected into a stream that didn't say whether it carries
300
- * injections, as no membrane 0.5.x stream does, counts as unread though
301
- * a request may have included it. */
298
+ * no model request has included it. */
302
299
  isUnread(placement: CoalescingPlacement): boolean;
303
300
  /** True iff nothing may have shown the placement to the agent: it is still
304
301
  * in the deferred queue and known not to be stored already, or it is
305
302
  * stored where no committing compile has begun to assemble it and no tool
306
303
  * boundary has injected past it. Unlike
307
- * isUnread, a compile counts from its start, and a boundary counts
308
- * whether or not its stream says it carries injected messages: a doubt
309
- * counts as shown. */
304
+ * isUnread, a compile counts from its start: a doubt counts as shown. */
310
305
  isUnshown(placement: CoalescingPlacement): boolean;
311
306
  /** False when the unread occurrence could not be removed (it may still be read). */
312
307
  remove(placement: CoalescingPlacement): boolean;
@@ -1000,6 +995,25 @@ export class PushCoalescer<E = unknown> {
1000
995
  return result;
1001
996
  }
1002
997
 
998
+ /**
999
+ * Render and materialize one subject's pending batch for `agentName` now,
1000
+ * outside a turn's assembly: a batch whose wake a hold keeps back, stored
1001
+ * as it renders (held, as a plain push under the hold is), so whoever reads
1002
+ * held traffic sees it and the turn after the hold doesn't take it again.
1003
+ * A batch that opened while another render ran is rendered once that
1004
+ * render settles. Resolves whether it materialized.
1005
+ */
1006
+ async materializeNow(agentName: string, subject: string): Promise<boolean> {
1007
+ for (;;) {
1008
+ if (this.suspended) return false;
1009
+ const started = await this.locked(() => this.freeze(subject, agentName));
1010
+ if (!started || started === 'declined') return false;
1011
+ await started.done;
1012
+ this.persist();
1013
+ if (!started.joined) return started.rendering.materialized === true;
1014
+ }
1015
+ }
1016
+
1003
1017
  /**
1004
1018
  * Under the lock: decide whether `subject`'s batch renders for `agentName`,
1005
1019
  * freeze it, make the render-start durable, re-check authority, and start
@@ -50,7 +50,6 @@ export interface ReferenceTestimony {
50
50
  name?: string;
51
51
  disposition?: ReferenceDisposition;
52
52
  rejectedFields: string[];
53
- truncatedFields: string[];
54
53
  /** Set when the testimony is an MCP resource_link's, which claims no
55
54
  * digest, expiry or disposition. A link is registered but never fetched
56
55
  * on the side, and its testimony never replaces a record's (register). */
@@ -182,8 +181,8 @@ function parseReferenceFields(block: Record<string, unknown>): BlockClassificati
182
181
  if (block.uri.length > REFERENCE_LIMITS.uri) {
183
182
  return { kind: 'invalid', shown: REFERENCE_LINK_TOO_LONG_TEXT };
184
183
  }
185
- const out = { rejected: [] as string[], truncated: [] as string[] };
186
- const t: ReferenceTestimony = { uri: block.uri, rejectedFields: out.rejected, truncatedFields: out.truncated };
184
+ const out = { rejected: [] as string[] };
185
+ const t: ReferenceTestimony = { uri: block.uri, rejectedFields: out.rejected };
187
186
  t.mimeType = takeString(block.mimeType, 'mimeType', out);
188
187
  t.name = takeString(block.name, 'name', out);
189
188
  t.expiresAt = takeString(block.expiresAt, 'expiresAt', out);
@@ -835,7 +834,7 @@ export class ReferenceRegistry {
835
834
  * entry for the id, if any, is in force again; with none, a stub reads the
836
835
  * id's references as an unknown server's. After a removal, that framework's
837
836
  * fetch refuses them too. A stopped framework keeps its configs, so its
838
- * fetcher, called before it starts again, can still dial them. */
837
+ * fetcher can still dial them. */
839
838
  forgetServerAddress(serverId: string, owner: object): void {
840
839
  const entries = (this.addresses.get(serverId) ?? []).filter((e) => e.owner !== owner);
841
840
  if (entries.length > 0) this.addresses.set(serverId, entries);
@@ -79,6 +79,9 @@ export interface McplConnectionCloseInfo {
79
79
  /** True when `reason` is the peer's own text, a WebSocket server's close
80
80
  * reason, rather than the transport's or the host's words. */
81
81
  reasonFromPeer?: boolean;
82
+ /** True when the stdio transport ended the child itself, after a pipe
83
+ * error or a line on its stdout longer than the bound. */
84
+ pipeFailed?: boolean;
82
85
  /** True when the host closed the link (`close()` / `reconnectAfterFailure()`),
83
86
  * false when the transport dropped unexpectedly. */
84
87
  byHost: boolean;
@@ -454,6 +457,8 @@ export class McplServerConnection extends EventEmitter {
454
457
  * the epoch their opening went to, never across a reconnect. */
455
458
  transportEpoch = 0;
456
459
 
460
+ private transportEnded = new AbortController();
461
+
457
462
  /**
458
463
  * RFC-014: the server's `toolHints`, `toolsets` and `surface` members as it
459
464
  * advertised them at the last handshake (toolsetDeclarationsOf), or null.
@@ -531,6 +536,8 @@ export class McplServerConnection extends EventEmitter {
531
536
  this.grant = CapabilityGrant.empty();
532
537
  this.policyEstablished = false;
533
538
  this.transportEpoch++;
539
+ this.transportEnded.abort();
540
+ this.transportEnded = new AbortController();
534
541
  // What the server sent that is still held came on the transport that is
535
542
  // now gone. What it said about its own process is dropped: its requests
536
543
  // wait on answers that can no longer reach whoever asked (an inference
@@ -726,6 +733,20 @@ export class McplServerConnection extends EventEmitter {
726
733
  return this.reconnectEnabled;
727
734
  }
728
735
 
736
+ /**
737
+ * Stop reconnecting, closing nothing: a retry already scheduled starts
738
+ * nothing when its timer fires, none is scheduled after, and an attempt
739
+ * under way is aborted, as close() aborts it, so one still waiting on its
740
+ * access provider never dials and a child it spawned is reaped. The host's
741
+ * stop() does this first, so nothing is launched or dialed while it shuts
742
+ * down; close() clears the timer.
743
+ * @internal For the framework's stop(); not a host API.
744
+ */
745
+ stopReconnecting(): void {
746
+ this.reconnectEnabled = false;
747
+ this.reconnecting?.abort.abort();
748
+ }
749
+
729
750
  /** Whether a child this connection launched couldn't be confirmed exited.
730
751
  * While it's true the connection owns that child, so whatever registered
731
752
  * it keeps its id held; close() retries the reap. */
@@ -1417,7 +1438,7 @@ export class McplServerConnection extends EventEmitter {
1417
1438
  transport.writeLine(JSON.stringify(initRequest));
1418
1439
  });
1419
1440
 
1420
- // The transport has found the child's stdin or stdout failed before the
1441
+ // The transport has stopped using the child's stdio before the
1421
1442
  // handshake could hand it on, such as when this host's answer to a ping
1422
1443
  // the server sent met EPIPE. Nothing more is written to the child then,
1423
1444
  // so the connection couldn't serve. It's read here, once the answer has
@@ -1430,8 +1451,8 @@ export class McplServerConnection extends EventEmitter {
1430
1451
  // the launch if it can't reap the child. A failure of the write below
1431
1452
  // is reported only once the caller has wired the transport, so it
1432
1453
  // reaches the connection as a loss, like any later one.
1433
- const pipeFailure = transport.pipeFailure;
1434
- if (pipeFailure) throw failed(`MCPL server "${config.id}" stdio pipe failed with ${pipeFailure.message} during the handshake`);
1454
+ const stopCause = transport.stdioStopCause;
1455
+ if (stopCause) throw failed(`MCPL server "${config.id}": this host stopped using the server's stdio during the handshake (${stopCause.message})`);
1435
1456
 
1436
1457
  // Send `initialized` notification (no id)
1437
1458
  transport.writeLine(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }));
@@ -2163,12 +2184,8 @@ export class McplServerConnection extends EventEmitter {
2163
2184
  'not-sent',
2164
2185
  ));
2165
2186
  }
2166
- // Once the child's stdin or stdout has failed, the transport is ending
2167
- // it and writes nothing more to it, so a request is refused here as not
2168
- // sent rather than left to wait out its timeout. The method and the
2169
- // pipe's error are the operator's, so they go to the log.
2170
- const pipeFailure = this.transport?.pipeFailure;
2171
- if (pipeFailure) {
2187
+ const stopCause = this.transport?.stdioStopCause;
2188
+ if (stopCause) {
2172
2189
  return McplServerConnection.refused(
2173
2190
  new McplRequestError(
2174
2191
  `The request wasn't sent: this host can no longer write to the "${this.id}" server, so nothing ran. ` +
@@ -2177,7 +2194,7 @@ export class McplServerConnection extends EventEmitter {
2177
2194
  : "This host won't reconnect to it on its own. If you need it, ask whoever runs this host."),
2178
2195
  'not-sent',
2179
2196
  ),
2180
- `MCPL server "${this.id}": ${method} not sent, since its stdio pipe failed: ${logDetail(pipeFailure.message)}`,
2197
+ `MCPL server "${this.id}": ${method} not sent, since this host stopped using the server's stdio (${logDetail(stopCause.message)})`,
2181
2198
  );
2182
2199
  }
2183
2200
 
@@ -2492,6 +2509,7 @@ export class McplServerConnection extends EventEmitter {
2492
2509
  private responderFor(id: string | number, epoch: number) {
2493
2510
  return {
2494
2511
  id,
2512
+ signal: epoch === this.transportEpoch ? this.transportEnded.signal : AbortSignal.abort(),
2495
2513
  respond: (result: unknown) => this.sendResponse(id, result, epoch),
2496
2514
  respondError: (code: number, message: string, data?: unknown) =>
2497
2515
  this.sendErrorResponse(id, code, message, data, epoch),
@@ -2787,6 +2805,7 @@ export class McplServerConnection extends EventEmitter {
2787
2805
  signal: info.signal ?? null,
2788
2806
  reason: info.reason ?? (byHost ? 'closed by host' : undefined),
2789
2807
  ...(info.reasonFromPeer ? { reasonFromPeer: true } : {}),
2808
+ ...(info.pipeFailed ? { pipeFailed: true } : {}),
2790
2809
  byHost,
2791
2810
  };
2792
2811
  this.emit('close', closeInfo.code, closeInfo.signal, closeInfo);
@@ -2798,20 +2817,10 @@ export class McplServerConnection extends EventEmitter {
2798
2817
  }
2799
2818
  };
2800
2819
  transport.on('close', lost);
2801
- // A stdio child whose stdin or stdout failed, and that outlived SIGKILL
2802
- // too: the transport stays open, owning the child. The connection holds
2803
- // it as a launch it can't reap, as a reconnect does, which halts
2804
- // reconnecting, and is lost as on a close, with the permanent failure
2805
- // that raises the alert and takes its tools off the list. Its id stays
2806
- // held until close() confirms the exit. A close the host has begun
2807
- // reports the failure itself. During a handshake nothing listens yet: a
2808
- // handshake whose pipe has failed fails, when its answer comes (it's
2809
- // refused then), when the child's exit closes the transport, or at its
2810
- // timeout, and its own cleanup holds the launch.
2811
2820
  transport.on('unreaped', (failure: Error) => {
2812
2821
  if (this.closed || this.transport !== transport) return;
2813
2822
  this.holdUnreaped(transport, failure);
2814
- lost({ reason: `its stdio pipe failed with ${transport.pipeFailure?.message ?? 'an error'}, and its child outlived SIGKILL` });
2823
+ lost({ reason: `this host stopped using the server's stdio (${transport.stdioStopCause?.message ?? 'an error'}), and the server's process outlived SIGKILL` });
2815
2824
  this.emit('reconnect-failed', { error: this.unreapedFailure(), attempt: Math.max(1, this.reconnectAttempts), permanent: true });
2816
2825
  });
2817
2826
  }
@@ -94,22 +94,25 @@ const schemaOf = (tool: HintedTool): JsonSchema => (isObj(tool.inputSchema) ? to
94
94
 
95
95
  /** `play(point: string)`, `move(forward?: int -10..10 =0, turn?: int)` */
96
96
  export function signature(tool: HintedTool): string {
97
- const s = schemaOf(tool);
98
- const props = (isObj(s.properties) ? s.properties : {}) as Record<string, JsonSchema>;
99
- const req = new Set(Array.isArray(s.required) ? (s.required as string[]) : []);
100
- const params = Object.entries(props).map(([k, v]) => {
101
- const def = isObj(v) && v.default !== undefined ? ` =${literal(v.default)}` : '';
102
- return `${nameToTypeBack(k)}${req.has(k) ? '' : '?'}: ${typeSig(v, 1)}${def}`;
103
- });
104
- return `${nameToTypeBack(String(tool.name))}(${params.join(', ')})`;
97
+ return `${nameToTypeBack(String(tool.name))}(${paramsOf(schemaOf(tool))})`;
105
98
  }
106
99
 
107
100
  /** An options schema as `{size?: 9|13|19 =9, …}` (a toolset's options). */
108
101
  export function objectSignature(schema: unknown): string {
109
- const inner = signature({ name: '', inputSchema: schema }).slice(1, -1);
102
+ const inner = paramsOf(isObj(schema) ? schema : {});
110
103
  return inner ? `{${inner}}` : '{}';
111
104
  }
112
105
 
106
+ /** A schema's properties as `name?: type =default`, joined with commas. */
107
+ function paramsOf(s: JsonSchema): string {
108
+ const props = (isObj(s.properties) ? s.properties : {}) as Record<string, JsonSchema>;
109
+ const req = new Set(Array.isArray(s.required) ? (s.required as string[]) : []);
110
+ return Object.entries(props).map(([k, v]) => {
111
+ const def = isObj(v) && v.default !== undefined ? ` =${literal(v.default)}` : '';
112
+ return `${nameToTypeBack(k)}${req.has(k) ? '' : '?'}: ${typeSig(v, 1)}${def}`;
113
+ }).join(', ');
114
+ }
115
+
113
116
  /** First sentence of a description, bounded. */
114
117
  export function firstSentence(text: unknown, max = 140): string {
115
118
  const t = (typeof text === 'string' ? text : '').trim().replace(/\s+/g, ' ');
@@ -153,11 +156,15 @@ export function renderToolLines(tools: HintedTool[], shown: Shown, opts: { descr
153
156
  const d = toolDigest(t);
154
157
  const describe = opts.describeAll || !shown.has(d);
155
158
  shown.add(d);
156
- const sentence = describe ? firstSentence(t.description) : '';
157
- return `${indent}${signature(t)}${sentence ? ` — ${suppliedValue(sentence)}` : ''}`;
159
+ return toolLine(t, describe, indent);
158
160
  });
159
161
  }
160
162
 
163
+ export function toolLine(tool: HintedTool, describe = true, indent = ' '): string {
164
+ const sentence = describe ? firstSentence(tool.description) : '';
165
+ return `${indent}${signature(tool)}${sentence ? ` — ${suppliedValue(sentence)}` : ''}`;
166
+ }
167
+
161
168
  /** A server's value as JSON, or a note where it's nested too deeply, or too long, to show. */
162
169
  export function jsonText(value: unknown, space?: number): string {
163
170
  if (!nestsWithin(value)) return '(nested too deeply to show)';