@animalabs/agent-framework 0.22.4 → 0.23.1

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 (234) hide show
  1. package/README.md +105 -3
  2. package/dist/src/agent.d.ts +36 -10
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +76 -32
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/code-execution/index.d.ts +1 -1
  7. package/dist/src/code-execution/index.d.ts.map +1 -1
  8. package/dist/src/code-execution/index.js.map +1 -1
  9. package/dist/src/code-execution/py-runner.d.ts +34 -2
  10. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  11. package/dist/src/code-execution/py-runner.js +72 -16
  12. package/dist/src/code-execution/py-runner.js.map +1 -1
  13. package/dist/src/code-execution/runtime-py.d.ts +3 -2
  14. package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
  15. package/dist/src/code-execution/runtime-py.js +47 -7
  16. package/dist/src/code-execution/runtime-py.js.map +1 -1
  17. package/dist/src/code-execution/tool-definition.d.ts +12 -7
  18. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  19. package/dist/src/code-execution/tool-definition.js +59 -11
  20. package/dist/src/code-execution/tool-definition.js.map +1 -1
  21. package/dist/src/config-checks.d.ts +2 -2
  22. package/dist/src/config-checks.d.ts.map +1 -1
  23. package/dist/src/config-checks.js +6 -2
  24. package/dist/src/config-checks.js.map +1 -1
  25. package/dist/src/framework.d.ts +744 -120
  26. package/dist/src/framework.d.ts.map +1 -1
  27. package/dist/src/framework.js +3546 -906
  28. package/dist/src/framework.js.map +1 -1
  29. package/dist/src/gate/event-gate.d.ts.map +1 -1
  30. package/dist/src/gate/event-gate.js +13 -6
  31. package/dist/src/gate/event-gate.js.map +1 -1
  32. package/dist/src/held-writes.d.ts +16 -0
  33. package/dist/src/held-writes.d.ts.map +1 -0
  34. package/dist/src/held-writes.js +56 -0
  35. package/dist/src/held-writes.js.map +1 -0
  36. package/dist/src/index.d.ts +7 -3
  37. package/dist/src/index.d.ts.map +1 -1
  38. package/dist/src/index.js +8 -4
  39. package/dist/src/index.js.map +1 -1
  40. package/dist/src/mcpl/capability-grant.d.ts +4 -0
  41. package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
  42. package/dist/src/mcpl/capability-grant.js +1 -1
  43. package/dist/src/mcpl/capability-grant.js.map +1 -1
  44. package/dist/src/mcpl/channel-registry.d.ts +95 -18
  45. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  46. package/dist/src/mcpl/channel-registry.js +578 -150
  47. package/dist/src/mcpl/channel-registry.js.map +1 -1
  48. package/dist/src/mcpl/convert-block.d.ts +2 -2
  49. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  50. package/dist/src/mcpl/convert-block.js +6 -7
  51. package/dist/src/mcpl/convert-block.js.map +1 -1
  52. package/dist/src/mcpl/index.d.ts +5 -1
  53. package/dist/src/mcpl/index.d.ts.map +1 -1
  54. package/dist/src/mcpl/index.js +3 -1
  55. package/dist/src/mcpl/index.js.map +1 -1
  56. package/dist/src/mcpl/inference-router.d.ts +4 -2
  57. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  58. package/dist/src/mcpl/inference-router.js +6 -7
  59. package/dist/src/mcpl/inference-router.js.map +1 -1
  60. package/dist/src/mcpl/log-value.d.ts +7 -0
  61. package/dist/src/mcpl/log-value.d.ts.map +1 -1
  62. package/dist/src/mcpl/log-value.js +9 -0
  63. package/dist/src/mcpl/log-value.js.map +1 -1
  64. package/dist/src/mcpl/modern-connection.d.ts +227 -0
  65. package/dist/src/mcpl/modern-connection.d.ts.map +1 -0
  66. package/dist/src/mcpl/modern-connection.js +1088 -0
  67. package/dist/src/mcpl/modern-connection.js.map +1 -0
  68. package/dist/src/mcpl/prefix-rule.d.ts +18 -0
  69. package/dist/src/mcpl/prefix-rule.d.ts.map +1 -0
  70. package/dist/src/mcpl/prefix-rule.js +25 -0
  71. package/dist/src/mcpl/prefix-rule.js.map +1 -0
  72. package/dist/src/mcpl/protocol-family.d.ts +179 -0
  73. package/dist/src/mcpl/protocol-family.d.ts.map +1 -0
  74. package/dist/src/mcpl/protocol-family.js +697 -0
  75. package/dist/src/mcpl/protocol-family.js.map +1 -0
  76. package/dist/src/mcpl/push-coalescer.d.ts +38 -19
  77. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  78. package/dist/src/mcpl/push-coalescer.js +52 -23
  79. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  80. package/dist/src/mcpl/reference-fetcher.d.ts +66 -15
  81. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  82. package/dist/src/mcpl/reference-fetcher.js +243 -76
  83. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  84. package/dist/src/mcpl/references.d.ts +63 -18
  85. package/dist/src/mcpl/references.d.ts.map +1 -1
  86. package/dist/src/mcpl/references.js +117 -32
  87. package/dist/src/mcpl/references.js.map +1 -1
  88. package/dist/src/mcpl/server-connection.d.ts +326 -30
  89. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  90. package/dist/src/mcpl/server-connection.js +995 -212
  91. package/dist/src/mcpl/server-connection.js.map +1 -1
  92. package/dist/src/mcpl/server-registry.d.ts +25 -5
  93. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  94. package/dist/src/mcpl/server-registry.js +51 -12
  95. package/dist/src/mcpl/server-registry.js.map +1 -1
  96. package/dist/src/mcpl/tool-result-normalize.d.ts +54 -0
  97. package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -0
  98. package/dist/src/mcpl/tool-result-normalize.js +232 -0
  99. package/dist/src/mcpl/tool-result-normalize.js.map +1 -0
  100. package/dist/src/mcpl/transport.d.ts +56 -13
  101. package/dist/src/mcpl/transport.d.ts.map +1 -1
  102. package/dist/src/mcpl/transport.js +206 -70
  103. package/dist/src/mcpl/transport.js.map +1 -1
  104. package/dist/src/mcpl/types.d.ts +66 -20
  105. package/dist/src/mcpl/types.d.ts.map +1 -1
  106. package/dist/src/mcpl/types.js.map +1 -1
  107. package/dist/src/mcpl/visible-content.d.ts +2 -1
  108. package/dist/src/mcpl/visible-content.d.ts.map +1 -1
  109. package/dist/src/mcpl/visible-content.js +2 -1
  110. package/dist/src/mcpl/visible-content.js.map +1 -1
  111. package/dist/src/module-registry.d.ts +16 -6
  112. package/dist/src/module-registry.d.ts.map +1 -1
  113. package/dist/src/module-registry.js +65 -6
  114. package/dist/src/module-registry.js.map +1 -1
  115. package/dist/src/modules/api/index.d.ts +1 -1
  116. package/dist/src/modules/api/index.d.ts.map +1 -1
  117. package/dist/src/modules/api/index.js +3 -2
  118. package/dist/src/modules/api/index.js.map +1 -1
  119. package/dist/src/modules/discord/discord-js-client.d.ts +42 -2
  120. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
  121. package/dist/src/modules/discord/discord-js-client.js +268 -65
  122. package/dist/src/modules/discord/discord-js-client.js.map +1 -1
  123. package/dist/src/modules/discord/index.d.ts +27 -5
  124. package/dist/src/modules/discord/index.d.ts.map +1 -1
  125. package/dist/src/modules/discord/index.js +366 -76
  126. package/dist/src/modules/discord/index.js.map +1 -1
  127. package/dist/src/modules/discord/refusal.d.ts +14 -0
  128. package/dist/src/modules/discord/refusal.d.ts.map +1 -0
  129. package/dist/src/modules/discord/refusal.js +14 -0
  130. package/dist/src/modules/discord/refusal.js.map +1 -0
  131. package/dist/src/modules/discord/types.d.ts +17 -7
  132. package/dist/src/modules/discord/types.d.ts.map +1 -1
  133. package/dist/src/modules/health/index.d.ts.map +1 -1
  134. package/dist/src/modules/health/index.js +6 -2
  135. package/dist/src/modules/health/index.js.map +1 -1
  136. package/dist/src/modules/index.d.ts +1 -1
  137. package/dist/src/modules/index.d.ts.map +1 -1
  138. package/dist/src/modules/index.js +1 -1
  139. package/dist/src/modules/index.js.map +1 -1
  140. package/dist/src/modules/workspace/disk-agreement.d.ts +25 -2
  141. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  142. package/dist/src/modules/workspace/disk-agreement.js +45 -5
  143. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  144. package/dist/src/modules/workspace/effects.d.ts +2 -1
  145. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  146. package/dist/src/modules/workspace/effects.js +19 -9
  147. package/dist/src/modules/workspace/effects.js.map +1 -1
  148. package/dist/src/modules/workspace/index.d.ts +214 -42
  149. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  150. package/dist/src/modules/workspace/index.js +667 -113
  151. package/dist/src/modules/workspace/index.js.map +1 -1
  152. package/dist/src/modules/workspace/observe.d.ts +240 -8
  153. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  154. package/dist/src/modules/workspace/observe.js +554 -24
  155. package/dist/src/modules/workspace/observe.js.map +1 -1
  156. package/dist/src/modules/workspace/types.d.ts +10 -1
  157. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  158. package/dist/src/modules/workspace/types.js.map +1 -1
  159. package/dist/src/modules/workspace/watcher.d.ts +42 -0
  160. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  161. package/dist/src/modules/workspace/watcher.js +130 -25
  162. package/dist/src/modules/workspace/watcher.js.map +1 -1
  163. package/dist/src/silent-heartbeat.d.ts +112 -39
  164. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  165. package/dist/src/silent-heartbeat.js +100 -29
  166. package/dist/src/silent-heartbeat.js.map +1 -1
  167. package/dist/src/tool-args.d.ts +40 -0
  168. package/dist/src/tool-args.d.ts.map +1 -0
  169. package/dist/src/tool-args.js +207 -0
  170. package/dist/src/tool-args.js.map +1 -0
  171. package/dist/src/tool-presentation.d.ts.map +1 -1
  172. package/dist/src/tool-presentation.js +9 -2
  173. package/dist/src/tool-presentation.js.map +1 -1
  174. package/dist/src/types/agent.d.ts +27 -17
  175. package/dist/src/types/agent.d.ts.map +1 -1
  176. package/dist/src/types/events.d.ts +8 -0
  177. package/dist/src/types/events.d.ts.map +1 -1
  178. package/dist/src/types/framework.d.ts +17 -2
  179. package/dist/src/types/framework.d.ts.map +1 -1
  180. package/dist/src/types/module.d.ts +54 -10
  181. package/dist/src/types/module.d.ts.map +1 -1
  182. package/dist/src/types/trace.d.ts +2 -2
  183. package/dist/src/types/trace.d.ts.map +1 -1
  184. package/package.json +3 -4
  185. package/src/agent.ts +99 -36
  186. package/src/code-execution/index.ts +1 -0
  187. package/src/code-execution/py-runner.ts +104 -17
  188. package/src/code-execution/runtime-py.ts +47 -7
  189. package/src/code-execution/tool-definition.ts +65 -11
  190. package/src/config-checks.ts +4 -2
  191. package/src/framework.ts +3633 -956
  192. package/src/gate/event-gate.ts +14 -6
  193. package/src/held-writes.ts +59 -0
  194. package/src/index.ts +19 -4
  195. package/src/mcpl/capability-grant.ts +1 -1
  196. package/src/mcpl/channel-registry.ts +613 -160
  197. package/src/mcpl/convert-block.ts +15 -7
  198. package/src/mcpl/index.ts +14 -1
  199. package/src/mcpl/inference-router.ts +8 -7
  200. package/src/mcpl/log-value.ts +10 -0
  201. package/src/mcpl/modern-connection.ts +1182 -0
  202. package/src/mcpl/prefix-rule.ts +32 -0
  203. package/src/mcpl/protocol-family.ts +691 -0
  204. package/src/mcpl/push-coalescer.ts +67 -29
  205. package/src/mcpl/reference-fetcher.ts +288 -82
  206. package/src/mcpl/references.ts +123 -33
  207. package/src/mcpl/server-connection.ts +1050 -221
  208. package/src/mcpl/server-registry.ts +50 -12
  209. package/src/mcpl/tool-result-normalize.ts +294 -0
  210. package/src/mcpl/transport.ts +204 -62
  211. package/src/mcpl/types.ts +67 -20
  212. package/src/mcpl/visible-content.ts +2 -1
  213. package/src/module-registry.ts +68 -13
  214. package/src/modules/api/index.ts +3 -2
  215. package/src/modules/discord/discord-js-client.ts +288 -72
  216. package/src/modules/discord/index.ts +380 -77
  217. package/src/modules/discord/refusal.ts +13 -0
  218. package/src/modules/discord/types.ts +17 -9
  219. package/src/modules/health/index.ts +6 -2
  220. package/src/modules/index.ts +1 -1
  221. package/src/modules/workspace/disk-agreement.ts +46 -5
  222. package/src/modules/workspace/effects.ts +18 -7
  223. package/src/modules/workspace/index.ts +722 -124
  224. package/src/modules/workspace/observe.ts +591 -22
  225. package/src/modules/workspace/types.ts +10 -1
  226. package/src/modules/workspace/watcher.ts +126 -25
  227. package/src/silent-heartbeat.ts +184 -59
  228. package/src/tool-args.ts +213 -0
  229. package/src/tool-presentation.ts +9 -2
  230. package/src/types/agent.ts +27 -17
  231. package/src/types/events.ts +8 -0
  232. package/src/types/framework.ts +17 -2
  233. package/src/types/module.ts +55 -10
  234. package/src/types/trace.ts +2 -2
@@ -23,6 +23,7 @@ import { StringDecoder } from 'node:string_decoder';
23
23
  import WebSocket from 'ws';
24
24
 
25
25
  import type { McplServerConfig } from './types.js';
26
+ import { describeCredentialError, noteDialSent, redactUrl, resolveServerBinding, urlAsDialed } from './protocol-family.js';
26
27
  import { assertBooleanSetting } from '../config-checks.js';
27
28
  import { logDetail } from './log-value.js';
28
29
 
@@ -45,6 +46,9 @@ export interface TransportCloseInfo {
45
46
  * - `'stderr'` `(line: string)` diagnostic line (stdio child stderr; never WS)
46
47
  * - `'error'` `(err: Error)` transport-level error
47
48
  * - `'close'` `(info: TransportCloseInfo)` transport closed (once)
49
+ * - `'unreaped'` `(failure: Error)` a stdio child whose pipe failed
50
+ * outlived SIGKILL too: the transport stays open, owning the child, and
51
+ * close() retries the reap (never WS)
48
52
  *
49
53
  * Inbound `'line'`/`'stderr'` events are buffered until the first matching
50
54
  * listener attaches (and re-buffered whenever there are none), so no message is
@@ -90,6 +94,17 @@ export abstract class McplTransport extends EventEmitter {
90
94
  return '';
91
95
  }
92
96
 
97
+ /**
98
+ * The error a stdio child's stdin or stdout failed with, once one has. The
99
+ * transport is ending the child then, and stays open until it exits, but
100
+ * writes nothing more to it: writeLine drops each line, and both engines
101
+ * refuse a request as not sent. Undefined while writes go out, and always
102
+ * for a transport that has no pipes.
103
+ */
104
+ get pipeFailure(): Error | undefined {
105
+ return undefined;
106
+ }
107
+
93
108
  /**
94
109
  * Resolves once the server's diagnostic stream has ended or closed, so that
95
110
  * {@link stderrTail} holds what arrived on it, or once `ms` have passed while
@@ -167,7 +182,8 @@ export const CHILD_ENV_ALLOWLIST = [
167
182
  'http_proxy', 'https_proxy', 'no_proxy',
168
183
  '__CF_USER_TEXT_ENCODING',
169
184
  // Windows equivalents, harmless elsewhere.
170
- 'SYSTEMROOT', 'WINDIR', 'COMSPEC', 'PATHEXT', 'APPDATA', 'LOCALAPPDATA', 'USERPROFILE',
185
+ // USERNAME is Windows' USER, which Python's getpass.getuser() reads there.
186
+ 'SYSTEMROOT', 'WINDIR', 'COMSPEC', 'PATHEXT', 'APPDATA', 'LOCALAPPDATA', 'USERPROFILE', 'USERNAME',
171
187
  ];
172
188
 
173
189
  /** Environment for a stdio child: allowlisted host vars (+ LC_*), then the
@@ -244,6 +260,10 @@ export class StdioTransport extends McplTransport {
244
260
  return (this.stderrCut ? '…' : '') + this.stderrEnd;
245
261
  }
246
262
 
263
+ override get pipeFailure(): Error | undefined {
264
+ return this.pipeError;
265
+ }
266
+
247
267
  override stderrEnded(ms: number): Promise<void> {
248
268
  const stderr = this.child.stderr;
249
269
  if (!stderr || this.stderrDone) return Promise.resolve();
@@ -266,6 +286,8 @@ export class StdioTransport extends McplTransport {
266
286
  private constructor(
267
287
  private readonly child: ChildProcess,
268
288
  private readonly rl: ReadlineInterface,
289
+ /** The server this child serves, named in its stderr lines and in what
290
+ * its reaping reports. */
269
291
  serverId: string,
270
292
  ) {
271
293
  super(serverId);
@@ -301,16 +323,26 @@ export class StdioTransport extends McplTransport {
301
323
  this.child.on('error', (err) => this.reportError(err));
302
324
  // The child's stdin and stdout carry the protocol, so after an error on
303
325
  // either, a write's EPIPE or a read error, this transport can't reach the
304
- // child even if it's still running. The handler sends a running child
305
- // SIGTERM, and the exit's close names the error, so the connection handles
306
- // it as any other exit. The close is the report: no 'error' is emitted,
307
- // nothing is thrown. Nothing here escalates to SIGKILL, so a child that
308
- // ignores SIGTERM keeps running, unreported. A child that stops reading
309
- // without closing its stdin raises no error: the writes are buffered, and
310
- // only request timeouts bound it (McplServerConfig.requestTimeoutMs).
326
+ // child even if it's still running. The handler ends the child as close()
327
+ // does, SIGTERM and then SIGKILL, in the one attempt close() shares, and
328
+ // the exit's close names the error, so the connection handles it as any
329
+ // other exit. The close is the report: no 'error' is emitted, nothing is
330
+ // thrown, and nothing more is written meanwhile (pipeFailure). The first
331
+ // error starts that one attempt, and a later one, from either pipe, adds
332
+ // nothing to it. A child that outlives SIGKILL too stays this
333
+ // transport's: 'unreaped' says so, unless the host is closing it, whose
334
+ // close() reports the failure itself. A child that stops reading without
335
+ // closing its stdin raises no error: the writes are buffered, and only
336
+ // request timeouts bound it (McplServerConfig.requestTimeoutMs). Under
337
+ // Bun a child that closes its stdin isn't reported either: Bun emits no
338
+ // 'error' for the writes to it (checked on 1.3.10, 1.4.3 and 1.4.4's
339
+ // canary), so request timeouts are its only bound too.
311
340
  const endOnPipeError = (err: Error) => {
312
- this.pipeError ??= err;
313
- if (this.child.exitCode === null && this.child.signalCode === null) this.child.kill();
341
+ if (this.pipeError) return;
342
+ this.pipeError = err;
343
+ void this.terminate().catch((failure: unknown) => {
344
+ if (!this.closed) this.emit('unreaped', failure instanceof Error ? failure : new Error(String(failure)));
345
+ });
314
346
  };
315
347
  this.child.stdin?.on('error', endOnPipeError);
316
348
  // On the stream itself, since close() closes readline, which takes its own
@@ -367,31 +399,89 @@ export class StdioTransport extends McplTransport {
367
399
  }
368
400
 
369
401
  writeLine(json: string): void {
402
+ // Nothing more goes to a child whose stdin or stdout has failed: this
403
+ // transport is ending it.
404
+ if (this.pipeError) return;
370
405
  // stdin may be gone if the child died between the closed-check and here.
371
406
  this.child.stdin?.write(json + '\n');
372
407
  }
373
408
 
374
- async close(): Promise<void> {
409
+ private closing: Promise<void> | null = null;
410
+
411
+ /**
412
+ * Close and reap: resolves once the child has actually exited, not merely
413
+ * been signalled (`child.killed` is true as soon as a signal is sent). A
414
+ * child that outlives SIGTERM by {@link STDIO_EXIT_GRACE_MS} gets SIGKILL.
415
+ * If it still hasn't exited after that bound again, close rejects: an
416
+ * explicit cleanup failure, rather than waiting forever or claiming an
417
+ * exit that wasn't seen. Calls share one attempt, and so does a pipe
418
+ * failure that is already ending the child; a later call succeeds if the
419
+ * child has exited since.
420
+ */
421
+ close(): Promise<void> {
422
+ if (!this.closing) {
423
+ this.closing = this.reap();
424
+ return this.closing;
425
+ }
426
+ // A later call re-checks a failed verdict: a child that has exited since
427
+ // is reaped now. No new signals are sent.
428
+ return this.closing.catch((error: unknown) => {
429
+ if (this.exited()) return;
430
+ throw error;
431
+ });
432
+ }
433
+
434
+ private async reap(): Promise<void> {
375
435
  this.markClosed({ reason: 'closed by host' });
376
436
  this.rl.close();
377
- if (this.child && !this.child.killed) {
378
- this.child.kill();
379
- }
380
- await new Promise<void>((resolve) => {
381
- // Once Node has seen a child exit, it has its exitCode set, or its
382
- // signalCode when a signal ended it, and its 'exit' event has fired. A
383
- // signal that didn't come through kill() (an OOM kill, a crash) leaves
384
- // `killed` false. A child that has exited but isn't reaped yet still
385
- // takes the kill() above, which sets `killed`.
386
- if (!this.child || this.child.exitCode !== null || this.child.signalCode !== null || this.child.killed) {
387
- resolve();
388
- } else {
389
- this.child.once('exit', () => resolve());
390
- }
437
+ await this.terminate();
438
+ }
439
+
440
+ /** Whether the child has exited, or never started (a spawn error). */
441
+ private exited(): boolean {
442
+ return this.child.pid === undefined || this.child.exitCode !== null || this.child.signalCode !== null;
443
+ }
444
+
445
+ private terminating: Promise<void> | null = null;
446
+
447
+ /**
448
+ * End the child: SIGTERM, then SIGKILL once it has outlived SIGTERM by
449
+ * {@link STDIO_EXIT_GRACE_MS}. Resolves once it has exited; rejects if it
450
+ * still hasn't that long after SIGKILL. close() and a pipe failure share
451
+ * one attempt, so no second signal or timer goes out. A failed attempt is
452
+ * re-checked, not repeated: a child that has exited since resolves it.
453
+ */
454
+ private terminate(): Promise<void> {
455
+ this.terminating ??= this.escalate();
456
+ return this.terminating.catch((error: unknown) => {
457
+ if (this.exited()) return;
458
+ throw error;
391
459
  });
392
460
  }
461
+
462
+ private async escalate(): Promise<void> {
463
+ const child = this.child;
464
+ if (this.exited()) return;
465
+ const exited = new Promise<void>((resolve) => child.once('exit', () => resolve()));
466
+ const exitWithin = (ms: number) => new Promise<boolean>((resolve) => {
467
+ const timer = setTimeout(() => resolve(false), ms);
468
+ void exited.then(() => { clearTimeout(timer); resolve(true); });
469
+ });
470
+ child.kill('SIGTERM');
471
+ if (await exitWithin(STDIO_EXIT_GRACE_MS)) return;
472
+ const named = `stdio child ${child.pid} of MCP server "${this.serverId}"`;
473
+ console.error(`[mcpl] ${named} ignored SIGTERM for ${STDIO_EXIT_GRACE_MS}ms — sending SIGKILL`);
474
+ child.kill('SIGKILL');
475
+ if (await exitWithin(STDIO_EXIT_GRACE_MS)) return;
476
+ throw new Error(
477
+ `${named} did not exit within ${STDIO_EXIT_GRACE_MS}ms of SIGKILL; it could not be reaped`,
478
+ );
479
+ }
393
480
  }
394
481
 
482
+ /** How long a stdio child gets to exit after SIGTERM, and again after SIGKILL. */
483
+ const STDIO_EXIT_GRACE_MS = 2_000;
484
+
395
485
  // ---------------------------------------------------------------------------
396
486
  // WebSocket
397
487
  // ---------------------------------------------------------------------------
@@ -399,6 +489,18 @@ export class StdioTransport extends McplTransport {
399
489
  /** Timeout for the WebSocket to reach OPEN before we give up (ms). */
400
490
  const WS_OPEN_TIMEOUT_MS = 15_000;
401
491
 
492
+ /** `work`, or `signal`'s reason once it aborts, whichever comes first. The
493
+ * listener comes off the signal when `work` settles, so a long-lived signal,
494
+ * such as a reconnect loop's, doesn't gather one per dial. */
495
+ function raceAbort<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
496
+ if (signal.aborted) return Promise.reject(signal.reason);
497
+ return new Promise<T>((resolve, reject) => {
498
+ const onAbort = () => reject(signal.reason);
499
+ signal.addEventListener('abort', onAbort, { once: true });
500
+ work.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));
501
+ });
502
+ }
503
+
402
504
  export class WebSocketTransport extends McplTransport {
403
505
  readonly kind = 'websocket' as const;
404
506
 
@@ -425,28 +527,43 @@ export class WebSocketTransport extends McplTransport {
425
527
  /**
426
528
  * Dial `config.url` and resolve once the socket is OPEN. Appends `?token=`
427
529
  * when `config.token` is set. Rejects on connect error / timeout (and tears
428
- * the socket down) so a failed dial never leaks an open handle.
530
+ * the socket down) so a failed dial never leaks an open handle. `signal`
531
+ * ends a dial whose connection is closed meanwhile, at once, rather than
532
+ * at the open timeout.
429
533
  */
430
- static async open(config: McplServerConfig): Promise<WebSocketTransport> {
534
+ static async open(config: McplServerConfig, signal?: AbortSignal): Promise<WebSocketTransport> {
431
535
  // Resolve the per-dial credential first: the provider runs on every
432
536
  // connect AND reconnect, so rotation happens at the transport layer and
433
537
  // nothing above it ever holds a credential.
434
538
  let token: string | null = config.token ?? null;
539
+ const abandoned = (): Error =>
540
+ new Error(`MCPL server "${config.id}" WebSocket connect abandoned: the connection was closed (${redactUrl(config.url)})`);
435
541
  if (config.accessProvider) {
542
+ if (signal?.aborted) throw abandoned();
436
543
  try {
437
- token = (await config.accessProvider()) ?? token;
544
+ // A dial whose connection is closed meanwhile stops waiting for the
545
+ // provider; whatever the provider does later is ignored.
546
+ const provided = Promise.resolve(config.accessProvider());
547
+ provided.catch(() => { /* a late failure after the dial was abandoned */ });
548
+ token = (signal ? await raceAbort(provided, signal) : await provided) ?? token;
438
549
  } catch (err) {
550
+ if (signal?.aborted) throw abandoned();
439
551
  // Fall back to the static token (if any) rather than failing the dial
440
552
  // outright — the server will refuse a stale credential loudly anyway.
553
+ // The provider's own text may quote the credential, so it isn't logged.
441
554
  console.error(
442
- `[mcpl] server "${config.id}": access provider failed (${err instanceof Error ? err.message : err}) — dialing with configured fallback`,
555
+ `[mcpl] server "${config.id}": access provider failed (${describeCredentialError(err)}) — dialing with configured fallback`,
443
556
  );
444
557
  }
445
558
  }
446
559
  const url = buildWebSocketUrl(config, token);
560
+ // A provider's token is in this url, and the query may be re-encoded:
561
+ // the server's text is recorded with what the dial sent, the token in
562
+ // each form a token is replaced in.
563
+ noteDialSent(config, { url, token: token ?? undefined });
447
564
  // Error strings travel: traces, logs, tool results the model reads. They
448
565
  // get the endpoint, never the credential.
449
- const redacted = buildWebSocketUrl({ ...config, token: undefined }, null);
566
+ const redacted = redactUrl(config.url);
450
567
  const ws = new WebSocket(url);
451
568
 
452
569
  // A failed dial can emit 'error' MORE THAN ONCE on the raw socket (seen
@@ -460,31 +577,36 @@ export class WebSocketTransport extends McplTransport {
460
577
 
461
578
  return new Promise<WebSocketTransport>((resolve, reject) => {
462
579
  let settled = false;
463
- const timer = setTimeout(() => {
580
+ const abandon = (error: Error): void => {
464
581
  if (settled) return;
465
582
  settled = true;
583
+ clearTimeout(timer);
584
+ signal?.removeEventListener('abort', onAbort);
466
585
  ws.removeAllListeners();
467
586
  ws.on('error', swallowLateErrors);
468
587
  try { ws.terminate(); } catch { /* already gone */ }
469
- reject(new Error(`MCPL server "${config.id}" WebSocket connect timed out after ${WS_OPEN_TIMEOUT_MS}ms (${redacted})`));
588
+ reject(error);
589
+ };
590
+ const timer = setTimeout(() => {
591
+ abandon(new Error(`MCPL server "${config.id}" WebSocket connect timed out after ${WS_OPEN_TIMEOUT_MS}ms (${redacted})`));
470
592
  }, WS_OPEN_TIMEOUT_MS);
593
+ const onAbort = (): void => {
594
+ abandon(new Error(`MCPL server "${config.id}" WebSocket connect abandoned: the connection was closed (${redacted})`));
595
+ };
596
+ if (signal?.aborted) onAbort();
597
+ else signal?.addEventListener('abort', onAbort, { once: true });
471
598
 
472
599
  ws.once('open', () => {
473
600
  if (settled) return;
474
601
  settled = true;
475
602
  clearTimeout(timer);
603
+ signal?.removeEventListener('abort', onAbort);
476
604
  ws.removeAllListeners('error'); // the instance re-attaches its own handlers
477
605
  resolve(new WebSocketTransport(ws, config.id));
478
606
  });
479
607
 
480
608
  ws.once('error', (err: Error) => {
481
- if (settled) return;
482
- settled = true;
483
- clearTimeout(timer);
484
- ws.removeAllListeners();
485
- ws.on('error', swallowLateErrors);
486
- try { ws.terminate(); } catch { /* already gone */ }
487
- reject(new Error(`MCPL server "${config.id}" WebSocket connect failed: ${err.message} (${redacted})`));
609
+ abandon(new Error(`MCPL server "${config.id}" WebSocket connect failed: ${err.message} (${redacted})`));
488
610
  });
489
611
  });
490
612
  }
@@ -514,8 +636,9 @@ export class WebSocketTransport extends McplTransport {
514
636
  }
515
637
 
516
638
  /**
517
- * Build the dial URL: validate it's a ws(s):// URL and append the auth token as
518
- * a `token` query param when provided (preserving any existing query).
639
+ * Build the dial URL: validate it's a ws(s):// URL the ws library will dial,
640
+ * which refuses one with a fragment, and append the auth token as a `token`
641
+ * query param when provided (preserving any existing query).
519
642
  */
520
643
  export function buildWebSocketUrl(config: McplServerConfig, tokenOverride?: string | null): string {
521
644
  if (!config.url) {
@@ -525,30 +648,45 @@ export function buildWebSocketUrl(config: McplServerConfig, tokenOverride?: stri
525
648
  try {
526
649
  parsed = new URL(config.url);
527
650
  } catch {
528
- throw new Error(`MCPL server "${config.id}": invalid WebSocket url "${config.url}"`);
651
+ // Not echoed: a url that doesn't parse can't be redacted.
652
+ throw new Error(`MCPL server "${config.id}": invalid WebSocket url (it isn't shown, since it may carry a credential)`);
529
653
  }
530
654
  if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') {
531
655
  throw new Error(
532
656
  `MCPL server "${config.id}": websocket url must be ws:// or wss://, got "${parsed.protocol}"`,
533
657
  );
534
658
  }
535
- const token = tokenOverride !== undefined ? tokenOverride : config.token;
536
- if (token) {
537
- parsed.searchParams.set('token', token);
659
+ if (parsed.hash) {
660
+ throw new Error(`MCPL server "${config.id}": websocket url must not have a fragment, got "${parsed.hash}"`);
538
661
  }
539
- return parsed.toString();
662
+ const token = tokenOverride !== undefined ? tokenOverride : config.token;
663
+ return urlAsDialed(parsed, token).toString();
540
664
  }
541
665
 
542
666
  // ---------------------------------------------------------------------------
543
667
  // Factory
544
668
  // ---------------------------------------------------------------------------
545
669
 
546
- /** True when the config selects the WebSocket transport (explicit `transport`,
547
- * or a `url` with no `command`). */
670
+ /** True when the config selects the WebSocket transport, as
671
+ * {@link resolveServerBinding} decides it; false for any other transport and
672
+ * for a configuration it refuses. */
548
673
  export function isWebSocketTransport(config: McplServerConfig): boolean {
549
- if (config.transport === 'websocket') return true;
550
- if (config.transport === 'stdio') return false;
551
- return Boolean(config.url) && !config.command;
674
+ try {
675
+ return resolveServerBinding(config).transport === 'websocket';
676
+ } catch {
677
+ return false;
678
+ }
679
+ }
680
+
681
+ /** Whether a server's binding dials it, over WebSocket or HTTP. False for a
682
+ * stdio launch, and for a configuration whose binding can't be resolved,
683
+ * which the family check refuses. */
684
+ function dialsNetwork(config: McplServerConfig): boolean {
685
+ try {
686
+ return resolveServerBinding(config).transport !== 'stdio';
687
+ } catch {
688
+ return false;
689
+ }
552
690
  }
553
691
 
554
692
  /** The settings of McplServerConfig whose type is boolean. */
@@ -572,25 +710,29 @@ const SERVER_CONFIG_BOOLEANS: Record<BooleanSetting, string> = {
572
710
  * only `true` as on, so the quotes would hide what the value does. null counts
573
711
  * as not set, so a generated config that writes an unset setting as null
574
712
  * keeps working. `inheritEnv` is checked only for a stdio server, since a
575
- * WebSocket server never reads it. The error names the server, the setting
576
- * and the value's kind, not the value, which could be a secret pasted into
577
- * the wrong field.
713
+ * network server, WebSocket or HTTP, never reads it. The error names the
714
+ * server, the setting and the value's kind, not the value, which could be a
715
+ * secret pasted into the wrong field.
578
716
  */
579
717
  export function assertServerConfigBooleans(config: McplServerConfig): void {
580
718
  for (const [setting, whatTrueDoes] of Object.entries(SERVER_CONFIG_BOOLEANS) as Array<[BooleanSetting, string]>) {
581
- if (setting === 'inheritEnv' && isWebSocketTransport(config)) continue;
719
+ if (setting === 'inheritEnv' && dialsNetwork(config)) continue;
582
720
  assertBooleanSetting(`MCPL server "${config.id}" ${setting}`, config[setting], whatTrueDoes);
583
721
  }
584
722
  }
585
723
 
586
724
  /**
587
- * Open the transport selected by `config` (WebSocket when `transport` is
588
- * 'websocket' or a bare `url` is given; stdio otherwise). The returned transport
589
- * is connected at the byte level but has NOT performed the MCPL handshake.
725
+ * Open the legacy transport `config` selects, as {@link resolveServerBinding}
726
+ * decides it: stdio for a `command`, MCPL's WebSocket binding for a ws(s)://
727
+ * url. A configuration it refuses, or one for the modern family (an http(s)
728
+ * url, or `protocol: 'modern'`), throws before anything is spawned or dialed.
729
+ * The returned transport is connected at the byte level but has NOT
730
+ * performed the MCPL handshake.
590
731
  */
591
- export async function openTransport(config: McplServerConfig): Promise<McplTransport> {
592
- if (isWebSocketTransport(config)) {
593
- return WebSocketTransport.open(config);
732
+ export async function openTransport(config: McplServerConfig, signal?: AbortSignal): Promise<McplTransport> {
733
+ const binding = resolveServerBinding(config);
734
+ if (binding.family !== 'legacy') {
735
+ throw new Error(`MCP server "${config.id}" is configured for modern MCP; the legacy transports can't open it`);
594
736
  }
595
- return StdioTransport.spawn(config);
737
+ return binding.transport === 'websocket' ? WebSocketTransport.open(config, signal) : StdioTransport.spawn(config);
596
738
  }
package/src/mcpl/types.ts CHANGED
@@ -211,15 +211,22 @@ export interface McplChannelCapabilities {
211
211
  /**
212
212
  * Configuration for connecting to a single MCPL server.
213
213
  * Provided in FrameworkConfig.mcplServers[].
214
+ *
215
+ * Treat a submitted configuration and its nested values as immutable for as
216
+ * long as the server uses them. Apply changed settings with
217
+ * `restartMcplServer(id, newConfig)`; use `accessProvider` for a network
218
+ * server's rotating credentials (a stdio server's changed `env` goes through
219
+ * a restart with the new configuration).
214
220
  */
215
221
  export interface McplServerConfig {
216
222
  /** Unique server identifier */
217
223
  id: string;
218
224
 
219
225
  /**
220
- * Command to spawn the server process (stdio transport). Mutually exclusive
221
- * with `url`. Required for the stdio transport; omit it (and set `url`) for
222
- * the WebSocket transport.
226
+ * Command to spawn the server process (stdio transport). Required for the
227
+ * stdio transport; omit it (and set `url`) for a network server, over
228
+ * WebSocket or HTTP. A configuration with both needs `transport` to say
229
+ * which one is used, and is refused without it.
223
230
  */
224
231
  command?: string;
225
232
 
@@ -246,22 +253,41 @@ export interface McplServerConfig {
246
253
  inheritEnv?: boolean;
247
254
 
248
255
  /**
249
- * WebSocket URL for the network transport (`ws://` or `wss://`). Mutually
250
- * exclusive with `command`. When set (or `transport: 'websocket'`), the host
251
- * dials this endpoint instead of spawning a process — newline-delimited
252
- * JSON-RPC MCPL runs over the socket identically to stdio.
256
+ * URL for a network server, used instead of `command`. A configuration
257
+ * with both needs `transport` to say which one is used, and is refused
258
+ * without it. Its scheme decides the protocol family:
259
+ * - `ws://` or `wss://` is a legacy MCPL server over MCPL's WebSocket
260
+ * binding. The host dials it and runs the same newline-delimited JSON-RPC
261
+ * as over stdio.
262
+ * - `http://` or `https://` is a modern MCP (2026-07-28) server over
263
+ * Streamable HTTP.
253
264
  */
254
265
  url?: string;
255
266
 
256
267
  /**
257
- * Transport selector. Defaults to `'websocket'` when a bare `url` is given
258
- * (no `command`), `'stdio'` otherwise. Set explicitly to disambiguate.
268
+ * Transport selector. When it is absent, a `command` means stdio and a
269
+ * `url` means the transport its scheme names; with both, it is required.
270
+ * When it is set, it decides which of the two is used and must agree with
271
+ * it: `'stdio'` needs a `command`, `'websocket'` a ws(s):// url and
272
+ * `'http'` an http(s):// url.
273
+ */
274
+ transport?: 'stdio' | 'websocket' | 'http';
275
+
276
+ /**
277
+ * Protocol family for a stdio (`command`) server. Defaults to `'legacy'`,
278
+ * which is MCP 2024-11-05 + MCPL and today's behavior. `'modern'` speaks
279
+ * MCP 2026-07-28 through the SDK-backed modern engine. A network server's
280
+ * family comes from its url's scheme, so setting this on one is a
281
+ * configuration error. There is no probing between families.
259
282
  */
260
- transport?: 'stdio' | 'websocket';
283
+ protocol?: 'legacy' | 'modern';
261
284
 
262
285
  /**
263
- * Bearer token for WebSocket auth. Appended to `url` as a `token` query
264
- * parameter (`?token=…`) on connect. Ignored by the stdio transport.
286
+ * Bearer token for a network server. Where it travels depends on the
287
+ * transport: on a ws(s):// url it is appended as a `token` query parameter
288
+ * (`?token=…`) on connect; to an http(s):// server it is sent as an
289
+ * `Authorization: Bearer` header and never put in the url. A legacy stdio
290
+ * server ignores it, and a modern stdio server refuses it.
265
291
  */
266
292
  token?: string;
267
293
 
@@ -384,12 +410,20 @@ export interface McplServerConfig {
384
410
  * descriptive error, which the framework surfaces as a normal isError
385
411
  * tool_result. Request timeouts are the only bound for a stdio server that
386
412
  * stops reading its stdin without closing it: the host's writes are
387
- * buffered, no error is raised, and the connection stays up. This is the
388
- * timeout of every request that names none; the handshake's initialize has
389
- * its own. Set 0 to disable, except for channels/publish and the
390
- * channels/open a delivery may send first, which keep the 60 s default: a
391
- * publish's outcome is reported to the resident and recorded for its
392
- * draft, so it must end. Default: 60000 (60 seconds).
413
+ * buffered, no error is raised, and the connection stays up. Under Bun they
414
+ * are also the only bound for one that closes its stdin, since Bun emits no
415
+ * 'error' event for the writes to it. requestTimeoutMs is the timeout of
416
+ * every request that names none; the handshake's initialize has its own.
417
+ * Set 0 to disable, except for channels/publish and the channels/open a
418
+ * delivery may send first, which keep the 60 s default: a publish's outcome
419
+ * is reported to the resident and recorded for its draft, so it must end.
420
+ * Default: 60000 (60 seconds). A value above 2^31−1 is refused: Node runs a
421
+ * longer timer after about 1 ms, so every request would time out at once.
422
+ *
423
+ * For a modern server it is one deadline per tool call, across every leg
424
+ * of the call, and must be an integer from 1 to 2^31−1. It can't be
425
+ * disabled: the SDK reads 0 as "time out now". At the deadline
426
+ * cancellation is requested and the outcome is unknown.
393
427
  */
394
428
  requestTimeoutMs?: number;
395
429
 
@@ -419,7 +453,10 @@ export interface McplServerConfig {
419
453
  * `toolLifecycle.*.tools`) match these full names; `enabledTools` /
420
454
  * `disabledTools` use the bare server-side names.
421
455
  *
422
- * Must not collide with any module name or another server's prefix.
456
+ * Must not equal any module name or another server's prefix, nor meet
457
+ * another server's prefix: two prefixes meet when one is the other
458
+ * followed by `-`, or begins with the other followed by `--`, as `foo-`
459
+ * and `foo--bar` each meet `foo` (prefixesMeet, #265).
423
460
  */
424
461
  toolPrefix?: string;
425
462
 
@@ -1075,7 +1112,15 @@ export interface ChannelsListResult {
1075
1112
  * Spec Section 14.3.
1076
1113
  */
1077
1114
  export interface ChannelsOpenParams {
1078
- /** Exact registered id. Preferred over type/address matching. */
1115
+ /**
1116
+ * The exact registered id to open. Without it, a server matches by `type`
1117
+ * and `address`, as before. A server that doesn't know the id should
1118
+ * refuse rather than open another channel: the host fails an open whose
1119
+ * answer names a different channel id, by its `channel.id` or a history
1120
+ * item's `channelId`, compared exactly, as the server's traffic is. When
1121
+ * that other channel is registered and decided closed, the host closes it
1122
+ * again.
1123
+ */
1079
1124
  channelId?: string;
1080
1125
  type: string;
1081
1126
  address?: Record<string, unknown>;
@@ -1330,6 +1375,8 @@ export interface McpToolDefinition {
1330
1375
  name: string;
1331
1376
  description?: string;
1332
1377
  inputSchema: Record<string, unknown>;
1378
+ /** Server metadata for the tool (e.g. RFC-008 `mcpl/class` hints). */
1379
+ _meta?: Record<string, unknown>;
1333
1380
  }
1334
1381
 
1335
1382
  /**
@@ -36,8 +36,9 @@ export function isVisiblyEmptyContent(content: readonly unknown[] | null | undef
36
36
  * block whose text isn't a string, which only a nonconformant sender writes,
37
37
  * so the agent learns that a block was there. It is the host's word, not the
38
38
  * sender's, so it never makes content visible (isVisiblyEmptyConversion).
39
+ * The other blocks the converters can't show have theirs in references.ts.
39
40
  */
40
- export const NON_STRING_TEXT_PLACEHOLDER = "[Text not shown: the server's text block had no string text.]";
41
+ export const NON_STRING_TEXT_PLACEHOLDER = '[Text not shown: a text part arrived without readable text.]';
41
42
 
42
43
  /** A wire text block whose text isn't a string, which the converter shows as
43
44
  * NON_STRING_TEXT_PLACEHOLDER. */