@orkestrel/mcp 0.0.19 → 0.0.20

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.
@@ -10,6 +10,7 @@ let node_https = require("node:https");
10
10
  let _orkestrel_websocket = require("@orkestrel/websocket");
11
11
  let _orkestrel_process_server = require("@orkestrel/process/server");
12
12
  let _orkestrel_process = require("@orkestrel/process");
13
+ let node_stream = require("node:stream");
13
14
  //#region src/server/constants.ts
14
15
  /**
15
16
  * The Streamable-HTTP transport header that carries the MCP session id. When a {@link
@@ -87,7 +88,7 @@ var DEFAULT_MCP_SESSION_TTL = 3e5;
87
88
  //#endregion
88
89
  //#region src/server/helpers.ts
89
90
  /**
90
- * Create a readable stream from its pull and cancellation behaviours.
91
+ * Creates a readable stream from its pull and cancellation behaviours.
91
92
  *
92
93
  * @param pull - The behaviour that supplies the stream's next chunk
93
94
  * @param cancel - The behaviour that releases the stream after consumer cancellation
@@ -100,12 +101,12 @@ function createReadableStream(pull, cancel) {
100
101
  });
101
102
  }
102
103
  /**
103
- * Pump a controlled held-open exchange onto an open SSE stream — one `data:` event per
104
+ * Pumps a controlled held-open exchange onto an open SSE stream — one `data:` event per
104
105
  * notification in order, then the terminating response — and END the exchange however the
105
106
  * pump leaves.
106
107
  *
107
108
  * @remarks
108
- * The Streamable-HTTP twin of {@link import('@src/core').sendStream}, and it owns exactly what
109
+ * The Streamable-HTTP twin of {@link import('@orkestrel/mcp').sendStream}, and it owns exactly what
109
110
  * that owns. The `finally` releases the exchange on EVERY exit — the normal terminal, a
110
111
  * producer that threw, a `write` that threw, and an abort alike — because nothing else will:
111
112
  * a request whose client vanished cancels nothing by itself, so an exchange this pump walks
@@ -113,7 +114,7 @@ function createReadableStream(pull, cancel) {
113
114
  * The exchange is released BEFORE the body ends, so the slot is already back when the response
114
115
  * completes.
115
116
  *
116
- * Total (§14) — never throws and never rejects. A held-open SSE response has already sent its
117
+ * Total — never throws and never rejects. A held-open SSE response has already sent its
117
118
  * headers and part of its body, so there is no failure the transport could still convert into
118
119
  * a different answer; the honest end of a broken stream is a closed one, and the fault itself
119
120
  * is already legible on `server.emitter`'s `error` event, which is where a contained fault
@@ -195,7 +196,7 @@ function allowsOrigin(request, options) {
195
196
  return options?.origins?.includes(parsed.origin) ?? false;
196
197
  }
197
198
  /**
198
- * Read the request's `mcp-session-id` header — the session id a stateful transport
199
+ * Reads the request's `mcp-session-id` header — the session id a stateful transport
199
200
  * validates, or `undefined` when absent.
200
201
  *
201
202
  * @remarks
@@ -213,7 +214,7 @@ function readSessionHeader(request) {
213
214
  return id === null ? void 0 : id;
214
215
  }
215
216
  /**
216
- * Read the request's `Last-Event-ID` header — the SSE resume cursor a client sends when it
217
+ * Reads the request's `Last-Event-ID` header — the SSE resume cursor a client sends when it
217
218
  * reconnects to the resumable `GET {path}` stream, or `undefined` when absent.
218
219
  *
219
220
  * @remarks
@@ -231,7 +232,7 @@ function readLastEventId(request) {
231
232
  return id === null ? void 0 : id;
232
233
  }
233
234
  /**
234
- * Build the stateful transport's "unknown session" rejection — an HTTP `404` carrying a
235
+ * Builds the stateful transport's "unknown session" rejection — an HTTP `404` carrying a
235
236
  * JSON-RPC error body.
236
237
  *
237
238
  * @remarks
@@ -249,16 +250,16 @@ function rejectUnknownSession() {
249
250
  return Response.json((0, _src_core.buildJSONRPCError)(void 0, _src_core.JSONRPC_INVALID_REQUEST, "Session not found"), { status: 404 });
250
251
  }
251
252
  /**
252
- * Decode a `fetch` Response's Server-Sent-Events body into the JSON-RPC messages it
253
+ * Decodes a `fetch` Response's Server-Sent-Events body into the JSON-RPC messages it
253
254
  * carried — the CLIENT-side inverse of the server's Streamable-HTTP SSE response.
254
255
  *
255
256
  * @remarks
256
257
  * Reads the whole `response.body` stream chunk-by-chunk through a `TextDecoder({
257
258
  * stream: true })` (handling a multi-byte char split across reads) and `@orkestrel/sse`'s
258
259
  * {@link SSEParserInterface} (handling a partial line / in-progress event split across
259
- * reads), then narrows each dispatched event's `data` to a {@link JSONRPCMessage} via
260
+ * reads), then narrows each dispatched event's `data` to a {@link JSONRPCMessage} with
260
261
  * `parseJSONRPCMessage` (so a non-message / non-JSON `data:` event is DROPPED, never
261
- * thrown — total, §14). It reuses the SAME `SSEParser` the server's `openStream` seam
262
+ * thrown — total). It reuses the SAME `SSEParser` the server's `openStream` seam
262
263
  * serializes against, so the wire round-trips. A `null` body (no stream) yields no
263
264
  * messages; the {@link import('./transports/HTTPClientTransport.js').HTTPClientTransport}
264
265
  * reads a request/response SSE reply (the server sends one `data:` event then ends), so
@@ -289,13 +290,13 @@ async function readEventStream(response) {
289
290
  return messages;
290
291
  }
291
292
  /**
292
- * Decode one SSE event's `data` string into a {@link JSONRPCMessage}, or `undefined`
293
+ * Decodes one SSE event's `data` string into a {@link JSONRPCMessage}, or `undefined`
293
294
  * when it is not one — the per-event step {@link readEventStream} folds over.
294
295
  *
295
296
  * @remarks
296
297
  * `JSON.parse`s the `data` (the server serializes the JSON-RPC envelope as the event's
297
298
  * `data`) inside a try/catch and narrows the parsed value with `parseJSONRPCMessage`.
298
- * Total (§14): malformed JSON or a non-message value yields `undefined`, never throws.
299
+ * Total: malformed JSON or a non-message value yields `undefined`, never throws.
299
300
  *
300
301
  * @param data - One SSE event's `data` payload
301
302
  * @returns The decoded {@link JSONRPCMessage}, or `undefined`
@@ -308,13 +309,13 @@ function decodeEvent(data) {
308
309
  }
309
310
  }
310
311
  /**
311
- * Read the path (without the query string) of a raw `node:http` protocol-upgrade request —
312
+ * Reads the path (without the query string) of a raw `node:http` protocol-upgrade request —
312
313
  * the `createWebSocketServer` upgrade-path match.
313
314
  *
314
315
  * @remarks
315
316
  * A `node:http` {@link import('node:http').IncomingMessage}'s `url` is the request TARGET
316
- * (`'/mcp?x=1'`), narrowed with `isString` (§14, never `as`) and defaulting to `'/'` for an
317
- * absent target; it is parsed against a dummy base (only the pathname matters for the upgrade
317
+ * (`'/mcp?x=1'`), narrowed with `isString` (never `as`) and defaulting to `'/'` for an
318
+ * absent target; it is parsed against a placeholder base (only the pathname matters for the upgrade
318
319
  * decision) and the `pathname` returned. The upgrade handler compares this against its
319
320
  * configured `path` to decide whether to claim the socket. Total — never throws on an
320
321
  * adversarial / absent target.
@@ -327,7 +328,7 @@ function upgradeRequestPath(request) {
327
328
  return new URL(target, "http://localhost").pathname;
328
329
  }
329
330
  /**
330
- * Fold one more chunk of raw stdio bytes into a newline-framed buffer — the shared
331
+ * Folds one more chunk of raw stdio bytes into a newline-framed buffer — the shared
331
332
  * line-framing step both stdio transports (client and server) read their inbound
332
333
  * newline-delimited JSON-RPC messages through.
333
334
  *
@@ -352,19 +353,20 @@ function extractLines(buffer, chunk) {
352
353
  };
353
354
  }
354
355
  /**
355
- * Decode and deliver each complete newline-framed line onto a {@link
356
+ * Decodes and delivers each complete newline-framed line onto a {@link
356
357
  * MCPClientTransportEventMap} emitter — the shared per-chunk dispatch step both stdio
357
- * transports (client and server) run their {@link extractLines} output through.
358
+ * transports run their framed lines through: the server transport frames with {@link
359
+ * extractLines}, the client transport takes its lines from the process supervisor.
358
360
  *
359
361
  * @remarks
360
362
  * A blank line is skipped (a stray trailing newline). Every other line is decoded
361
363
  * with {@link decodeEvent} (`JSON.parse` + `parseJSONRPCMessage`, guarded); a
362
364
  * well-formed {@link JSONRPCMessage} emits `message`, a malformed / non-message line
363
- * emits `error` (§14 — total, never throws). Pure w.r.t. its own state — the emit is
365
+ * emits `error` (total, never throws). Pure w.r.t. its own state — the emit is
364
366
  * the caller-owned side effect.
365
367
  *
366
368
  * @param emitter - The transport's {@link EmitterInterface} to emit `message` / `error` onto
367
- * @param lines - The complete lines (from {@link extractLines}) to decode and deliver
369
+ * @param lines - The complete lines to decode and deliver
368
370
  */
369
371
  function dispatchLines(emitter, lines) {
370
372
  for (const line of lines) {
@@ -378,17 +380,17 @@ function dispatchLines(emitter, lines) {
378
380
  }
379
381
  }
380
382
  /**
381
- * Bridge a message-channel {@link MCPClientTransportInterface} (the shape the stdio and
383
+ * Bridges a message-channel {@link MCPClientTransportInterface} (the shape the stdio and
382
384
  * WebSocket SERVER transports already implement) into the environment-agnostic
383
- * {@link import('@src/core').MCPTransportInterface} port — the adapter
385
+ * {@link import('@orkestrel/mcp').MCPTransportInterface} port — the adapter
384
386
  * {@link import('./factories.js').createStdioServer} and {@link
385
387
  * import('./factories.js').createWebSocketServer} pipe through `bindServer`, so the
386
- * request/reply/error pump those two factories used to hand-roll identically now
388
+ * request/reply/error pump those factories used to hand-roll identically now
387
389
  * lives ONCE in the core binder.
388
390
  *
389
391
  * @remarks
390
392
  * `send` decodes the already-serialized reply string back to a {@link JSONRPCMessage}
391
- * and writes it via `transport.send` (the SAME `JSON.stringify` the underlying
393
+ * and writes it through `transport.send` (the same `JSON.stringify` the underlying
392
394
  * transport already performs, so the wire bytes are unchanged). `listen` filters
393
395
  * `transport`'s `message` event to INVOCATIONS ONLY — requests and notifications, never a
394
396
  * stray response, exactly as the prior hand-rolled pumps did — and re-serializes each one
@@ -406,24 +408,24 @@ function dispatchLines(emitter, lines) {
406
408
  * decode rather than after it. Removing the cost means giving `MCPTransportInterface` a
407
409
  * message-shaped face beside its string one, which every transport would then carry.
408
410
  *
409
- * @remarks Per {@link import('@src/core').MCPTransportInterface}, `listen`/`closed`
411
+ * @remarks Per {@link import('@orkestrel/mcp').MCPTransportInterface}, `listen`/`closed`
410
412
  * each hold THE SINGLE current handler (a second call REPLACES the first, never adds).
411
- * Since the underlying `transport.emitter` is ADD-based (`on` subscribes, never
413
+ * Because the underlying `transport.emitter` is ADD-based (`on` subscribes, never
412
414
  * replaces), this bridge installs ONE stable emitter listener per event on first use
413
- * and re-routes it to whichever handler is CURRENTLY registered (`undefined` while
415
+ * and re-routes it to whichever handler is active (`undefined` while
414
416
  * none is), so rebinding never double-dispatches.
415
417
  *
416
- * @remarks A response whose `result` serializes away (e.g. `undefined`) is dropped by
418
+ * @remarks A response whose `result` serializes away (for example, `undefined`) is dropped by
417
419
  * the message validators on the wire's decode side — an asymmetry the stdio/WS carrier
418
- * shares with the streamable-HTTP face, since both round-trip through `JSON.stringify`
420
+ * shares with the streamable-HTTP face, because both round-trip through `JSON.stringify`
419
421
  * / `JSON.parse` before re-validation.
420
422
  *
421
423
  * @param transport - The message-channel transport to bridge (stdio or WebSocket)
422
- * @returns An {@link import('@src/core').MCPTransportInterface} `bindServer` can drive
424
+ * @returns An {@link import('@orkestrel/mcp').MCPTransportInterface} `bindServer` can drive
423
425
  *
424
426
  * @example
425
427
  * ```ts
426
- * import { bindServer } from '@src/core'
428
+ * import { bindServer } from '@orkestrel/mcp'
427
429
  *
428
430
  * const transport = new StdioServerTransport(process.stdin, process.stdout)
429
431
  * bindServer(mcp, bridgeMessageTransport(transport))
@@ -459,7 +461,7 @@ function bridgeMessageTransport(transport) {
459
461
  //#endregion
460
462
  //#region src/server/inferers.ts
461
463
  /**
462
- * Infer the first required MCP HTTP header that is missing or mismatched.
464
+ * Infers the first required MCP HTTP header that is missing or mismatched.
463
465
  *
464
466
  * @remarks
465
467
  * A modern request derives its protocol, method, and tools/call-only name expectations from
@@ -540,7 +542,7 @@ function inferHeaderIssue(request, reference) {
540
542
  };
541
543
  }
542
544
  /**
543
- * Infer the legacy revision an `initialize` request negotiates.
545
+ * Infers the legacy revision an `initialize` request negotiates.
544
546
  *
545
547
  * @remarks
546
548
  * A supported legacy request is pinned exactly. A modern, malformed, absent, or unsupported
@@ -556,7 +558,7 @@ function inferLegacyVersion(request) {
556
558
  return _src_core.MCP_PROTOCOL_VERSION;
557
559
  }
558
560
  /**
559
- * Infer the HTTP status for one MCP dispatch outcome without changing its JSON-RPC body.
561
+ * Infers the HTTP status for one MCP dispatch outcome without changing its JSON-RPC body.
560
562
  *
561
563
  * @remarks
562
564
  * Notifications are accepted with `202`. Legacy response envelopes retain uniform `200`
@@ -578,7 +580,7 @@ function inferStatus(response, era) {
578
580
  //#endregion
579
581
  //#region src/server/transports/HTTPDisconnect.ts
580
582
  /**
581
- * Compose one incoming HTTP request lifetime with one MCP-owned SSE response lifetime.
583
+ * Composes one incoming HTTP request lifetime with one MCP-owned SSE response lifetime.
582
584
  *
583
585
  * @remarks
584
586
  * The composed {@link signal} observes request abort and EVERY way this response can end
@@ -592,7 +594,8 @@ function inferStatus(response, era) {
592
594
  *
593
595
  * {@link bridge} preserves the source response status and headers, forwards its body bytes, and
594
596
  * owns keepalive comments plus listener/timer cleanup until upstream completion, request abort,
595
- * or consumer cancellation. This is a single-response lifecycle object, not a reusable bridge.
597
+ * or consumer cancellation. This is a single-response lifecycle object, not a reusable bridge:
598
+ * a second {@link bridge} call THROWS rather than arming a second keepalive over one lifecycle.
596
599
  * It supplies no handler or session policy.
597
600
  *
598
601
  * The keepalive interval is a BUDGET, sanitized like every other numeric knob in this package:
@@ -617,9 +620,10 @@ var HTTPDisconnect = class {
617
620
  #interval;
618
621
  #signal;
619
622
  #timer;
623
+ #bridged = false;
620
624
  #pulling = false;
621
625
  /**
622
- * Create the lifecycle composition for one request and its future SSE response.
626
+ * Creates the lifecycle composition for one request and its future SSE response.
623
627
  *
624
628
  * @param signal - The incoming request signal
625
629
  * @param options - Optional keepalive `interval` in milliseconds; an invalid value falls back
@@ -640,7 +644,7 @@ var HTTPDisconnect = class {
640
644
  return this.#signal;
641
645
  }
642
646
  /**
643
- * Bridge one open SSE response through cancellation-aware byte forwarding and keepalives.
647
+ * Bridges one open SSE response through cancellation-aware byte forwarding and keepalives.
644
648
  *
645
649
  * Consumer cancellation, a read failure while forwarding, and a keepalive tick that finds the
646
650
  * SSE stream already closed each abort {@link signal}; consumer cancellation also cancels the
@@ -649,9 +653,12 @@ var HTTPDisconnect = class {
649
653
  *
650
654
  * @param stream - The open SSE stream whose response will be consumed by the HTTP writer
651
655
  * @returns A one-use response preserving status, status text, headers, and SSE body bytes
652
- * @throws When the supplied SSE response has no body
656
+ * @throws When this disconnect has already bridged a stream, or the supplied SSE response
657
+ * has no body
653
658
  */
654
659
  bridge(stream) {
660
+ if (this.#bridged) throw new Error("MCP SSE response is already bridged");
661
+ this.#bridged = true;
655
662
  const response = stream.response;
656
663
  const body = response.body;
657
664
  if (body === null) throw new Error("MCP SSE response has no body");
@@ -705,7 +712,7 @@ var HTTPDisconnect = class {
705
712
  //#endregion
706
713
  //#region src/server/handlers.ts
707
714
  /**
708
- * Create the Streamable-HTTP POST handler used by `createMCPRoutes`.
715
+ * Creates the Streamable-HTTP POST handler used by `createMCPRoutes`.
709
716
  *
710
717
  * @remarks
711
718
  * Modern requests require matching protocol/method headers and a matching name header only
@@ -731,7 +738,7 @@ var HTTPDisconnect = class {
731
738
  * import { createToolManager } from '@orkestrel/tool'
732
739
  *
733
740
  * const mcp = createMCPServer({ identity: { name: 'docs', version: '1.0.0' }, tools: createToolManager() })
734
- * const handler = createMCPPostHandler(createMCPLegacy(mcp), { streaming: true })
741
+ * const handler = createMCPPostHandler(createMCPLegacy(mcp), { streaming: true }) // answers `initialize` too; pass `mcp` alone for modern-only
735
742
  * await handler(new Request('http://localhost/mcp', {
736
743
  * method: 'POST',
737
744
  * body: '{"jsonrpc":"2.0","method":"ping","id":1}',
@@ -806,12 +813,12 @@ function createMCPPostHandler(mcp, options) {
806
813
  * - **Request/response over `fetch`.** `send(message)` POSTs the JSON-serialized
807
814
  * message to `options.url` with `content-type: application/json` and an
808
815
  * `Accept` of BOTH `application/json` and `text/event-stream` (so the server may
809
- * answer with either framing) — plus any `options.headers` (e.g. an `Authorization`
816
+ * answer with either framing) — plus any `options.headers` (for example, an `Authorization`
810
817
  * bearer). It then decodes the reply and emits each decoded {@link JSONRPCMessage} on
811
- * the `message` event the {@link import('@src/core').MCPClientInterface} subscribes
818
+ * the `message` event the {@link import('@orkestrel/mcp').MCPClientInterface} subscribes
812
819
  * to.
813
820
  * - **Both reply framings.** A `200` with an `application/json` body is parsed with
814
- * `parseJSONRPCMessage`; a `200` with a `text/event-stream` body is decoded via the
821
+ * `parseJSONRPCMessage`; a `200` with a `text/event-stream` body is decoded with the
815
822
  * `@orkestrel/sse` {@link import('@orkestrel/sse').SSEParserInterface} ({@link
816
823
  * readEventStream}) — the inverse of the server's `openStream` seam, so the wire
817
824
  * round-trips. A `202`
@@ -829,10 +836,15 @@ function createMCPPostHandler(mcp, options) {
829
836
  * Before initialize returns, neither captured legacy header is sent.
830
837
  * `close()` clears the captured protocol so a reconnect's `initialize`
831
838
  * POST is headerless; the captured `session` persists across `close()`.
832
- * - **Total at the boundary (§14).** Every reply is narrowed (`parseJSONRPCMessage`,
839
+ * - **`close()` releases what is in flight.** Every `fetch` this transport still has open is
840
+ * ABORTED, which cancels the response body a `send` is reading — an SSE reply the server
841
+ * never ends would otherwise outlive the transport, with nothing left able to reach it. The
842
+ * aborted read surfaces on `error` and the `send` reporting it resolves. `close()` is
843
+ * idempotent (one `close` event per connected lifetime), and `start()` opens the next one.
844
+ * - **Total at the boundary.** Every reply is narrowed (`parseJSONRPCMessage`,
833
845
  * the SSE decoder) — a non-message reply is dropped, never asserted; a `fetch` /
834
846
  * decode failure surfaces on the `error` event rather than escaping `send`.
835
- * - **Observable (§13).** Owns the `emitter` ({@link MCPClientTransportEventMap}); fires
847
+ * - **Observable.** Owns the `emitter` ({@link MCPClientTransportEventMap}); fires
836
848
  * `message` per decoded reply, `error` on a fault, and `close` on `close()`.
837
849
  *
838
850
  * @example
@@ -848,8 +860,10 @@ var HTTPClientTransport = class {
848
860
  #headers;
849
861
  #fetch;
850
862
  #timeout;
863
+ #pending = /* @__PURE__ */ new Set();
851
864
  #session = void 0;
852
865
  #protocol = void 0;
866
+ #closed = false;
853
867
  constructor(options) {
854
868
  this.#emitter = new _orkestrel_emitter.Emitter();
855
869
  this.#url = options.url;
@@ -866,8 +880,19 @@ var HTTPClientTransport = class {
866
880
  get duplex() {
867
881
  return false;
868
882
  }
869
- async start() {}
883
+ async start() {
884
+ this.#closed = false;
885
+ }
870
886
  async send(message) {
887
+ const request = new AbortController();
888
+ this.#pending.add(request);
889
+ try {
890
+ await this.#exchange(message, request.signal);
891
+ } finally {
892
+ this.#pending.delete(request);
893
+ }
894
+ }
895
+ async #exchange(message, signal) {
871
896
  let response;
872
897
  try {
873
898
  response = await this.#fetch(this.#url, {
@@ -880,7 +905,7 @@ var HTTPClientTransport = class {
880
905
  ...this.#headers
881
906
  },
882
907
  body: JSON.stringify(message),
883
- ...this.#timeout === void 0 ? {} : { signal: AbortSignal.timeout(this.#timeout) }
908
+ signal: this.#timeout === void 0 ? signal : AbortSignal.any([signal, AbortSignal.timeout(this.#timeout)])
884
909
  });
885
910
  } catch (error) {
886
911
  this.#emitter.emit("error", error);
@@ -891,6 +916,10 @@ var HTTPClientTransport = class {
891
916
  await this.#deliver(response);
892
917
  }
893
918
  async close() {
919
+ if (this.#closed) return;
920
+ this.#closed = true;
921
+ for (const request of this.#pending) request.abort();
922
+ this.#pending.clear();
894
923
  this.#protocol = void 0;
895
924
  this.#emitter.emit("close");
896
925
  }
@@ -938,8 +967,8 @@ var HTTPClientTransport = class {
938
967
  * The single session entity (the old `SessionState` + `EventStore` merged): it holds the
939
968
  * session `id`, its OWN bounded, replayable log of pushed server→client messages (the
940
969
  * resumable GET-SSE channel — a private `#events` `Map` + a monotone `#counter`, with
941
- * `capacity` / `ttl` eviction, NOT a separate store), and the set of currently OPEN
942
- * server→client SSE streams (a resumable `GET {path}` registers via `attach`, unregisters via
970
+ * `capacity` / `ttl` eviction, not a separate store), and the set of open
971
+ * server→client SSE streams (a resumable `GET {path}` registers through `attach`, unregisters through
943
972
  * `detach` on disconnect). Still a small entity (not a record), built minimal + extensible.
944
973
  *
945
974
  * - **`push` is the server-initiated primitive.** It APPENDS the message to the log (assigning
@@ -959,7 +988,7 @@ var HTTPClientTransport = class {
959
988
  * lost (its cursor is OLDER than everything retained); returning `[]` lets the handler then
960
989
  * stream only the fresh pushes that follow `attach` — the spec-sane resume.
961
990
  *
962
- * - **Bounded, append-ordered, plain `Map` (§21).** The log lives in ONE insertion-ordered
991
+ * - **Bounded, append-ordered, plain `Map`.** The log lives in ONE insertion-ordered
963
992
  * `Map<id, entry>` — insertion order IS append order IS id order, so `replay` and capacity
964
993
  * eviction both walk the map directly. NO database mirror — the log is process-local
965
994
  * transport mechanics, not durable state. `push` first drops every entry older than `ttl`
@@ -973,8 +1002,8 @@ var HTTPClientTransport = class {
973
1002
  * serializes a message onto the already-open streams.
974
1003
  *
975
1004
  * - **Injected clock.** `push` / `replay` accept an optional `now` (epoch ms), defaulting to
976
- * `Date.now()` — so a test drives TTL eviction with an elapsed clock rather than a real timer
977
- * (AGENTS §16).
1005
+ * `Date.now()` — so a test drives TTL eviction with an elapsed clock rather than a real
1006
+ * timer.
978
1007
  *
979
1008
  * @example
980
1009
  * ```ts
@@ -1055,7 +1084,7 @@ var MCPSession = class {
1055
1084
  * {@link import('./WebSocketClientTransport.js').WebSocketClientTransport} reuses.
1056
1085
  *
1057
1086
  * @remarks
1058
- * - **Reuses `MCPClientTransportInterface` (§21).** It IS the same generic carrier the HTTP
1087
+ * - **Reuses `MCPClientTransportInterface`.** It IS the same generic carrier the HTTP
1059
1088
  * client transport implements — `emitter` (`message` / `close` / `error`), `start`,
1060
1089
  * `send`, `close` — so the WebSocket server and client both speak ONE transport contract,
1061
1090
  * no near-duplicate sibling interface. `session` is `undefined` (the stateless v1; a
@@ -1064,22 +1093,27 @@ var MCPSession = class {
1064
1093
  * - **Inbound (`message`).** `start()` subscribes to the socket's `message` event; each text
1065
1094
  * frame is `JSON.parse`d inside a try/catch and narrowed with `parseJSONRPCMessage` — a
1066
1095
  * well-formed {@link JSONRPCMessage} is re-emitted on this transport's `message` event (the
1067
- * parsed envelope the {@link import('@src/core').MCPServerInterface} pump dispatches), while
1068
- * a non-JSON or non-message frame is surfaced on `error` and DROPPED, never thrown (§14). It
1096
+ * parsed envelope the {@link import('@orkestrel/mcp').MCPServerInterface} pump dispatches), while
1097
+ * a non-JSON or non-message frame is surfaced on `error` and DROPPED, never thrown. It
1069
1098
  * also bridges the socket's `close` → this transport's `close`, and the socket's `error`.
1070
1099
  * - **Outbound (`send`).** `send(message)` writes one text frame
1071
1100
  * (`nodeWs.send(JSON.stringify(message))`); the underlying wrapper no-ops a write on a
1072
1101
  * non-open socket, so a closed connection drops silently rather than throwing.
1073
- * - **`close()`** closes the underlying socket (the RFC 6455 close handshake) and fires the
1074
- * transport's `close` event (idempotent a second `close`, or a socket-driven close, emits
1075
- * once).
1076
- * - **Observable (§13).** Owns the `emitter` ({@link MCPClientTransportEventMap}); the emitter
1102
+ * - **`close()`** removes the subscriptions `start()` installed on the socket, closes the
1103
+ * underlying socket (the RFC 6455 close handshake), and fires the transport's `close` event
1104
+ * (idempotent — a second `close`, or a socket-driven close, emits once). A frame that arrives
1105
+ * between that release and the peer's close echo reaches nothing: the socket-driven close path
1106
+ * releases the same way, so a closed transport is never subscribed to a live socket.
1107
+ * - **Observable.** Owns the `emitter` ({@link MCPClientTransportEventMap}); the emitter
1077
1108
  * isolates a listener throw (a buggy observer never corrupts the bridge). `error` is a
1078
1109
  * DOMAIN event (a transport-level fault), distinct from the emitter's listener-error channel.
1079
1110
  */
1080
1111
  var WebSocketServerTransport = class {
1081
1112
  #emitter;
1082
1113
  #socket;
1114
+ #frame = (text) => this.#receive(text);
1115
+ #ending = () => this.#onClose();
1116
+ #failure = (error) => this.#emitter.emit("error", error);
1083
1117
  #started = false;
1084
1118
  #closed = false;
1085
1119
  constructor(socket) {
@@ -1096,9 +1130,9 @@ var WebSocketServerTransport = class {
1096
1130
  async start() {
1097
1131
  if (this.#started || this.#closed) return;
1098
1132
  this.#started = true;
1099
- this.#socket.emitter.on("message", (text) => this.#receive(text));
1100
- this.#socket.emitter.on("close", () => this.#onClose());
1101
- this.#socket.emitter.on("error", (error) => this.#emitter.emit("error", error));
1133
+ this.#socket.emitter.on("message", this.#frame);
1134
+ this.#socket.emitter.on("close", this.#ending);
1135
+ this.#socket.emitter.on("error", this.#failure);
1102
1136
  }
1103
1137
  async send(message) {
1104
1138
  this.#socket.send(JSON.stringify(message));
@@ -1106,6 +1140,7 @@ var WebSocketServerTransport = class {
1106
1140
  async close() {
1107
1141
  if (this.#closed) return;
1108
1142
  this.#closed = true;
1143
+ this.#release();
1109
1144
  this.#socket.close();
1110
1145
  this.#emitter.emit("close");
1111
1146
  }
@@ -1127,8 +1162,14 @@ var WebSocketServerTransport = class {
1127
1162
  #onClose() {
1128
1163
  if (this.#closed) return;
1129
1164
  this.#closed = true;
1165
+ this.#release();
1130
1166
  this.#emitter.emit("close");
1131
1167
  }
1168
+ #release() {
1169
+ this.#socket.emitter.off("message", this.#frame);
1170
+ this.#socket.emitter.off("close", this.#ending);
1171
+ this.#socket.emitter.off("error", this.#failure);
1172
+ }
1132
1173
  };
1133
1174
  //#endregion
1134
1175
  //#region src/server/transports/WebSocketClientTransport.ts
@@ -1146,7 +1187,8 @@ var WebSocketServerTransport = class {
1146
1187
  * event, and VALIDATES `Sec-WebSocket-Accept === computeWebSocketAccept(key)` (the D2 helper)
1147
1188
  * — a mismatch (or a non-`101` response, or a request error) REJECTS `start()` and the socket
1148
1189
  * is destroyed. On success it wraps the raw upgraded socket in `createNodeWebSocket({ socket,
1149
- * head })` (CLIENT mode — no key → frames are MASKED per §5.3) and bridges its `message`.
1190
+ * head })` (CLIENT mode — no key → frames are MASKED per RFC 6455 §5.3) and bridges its
1191
+ * `message`.
1150
1192
  * - **The arriving socket is RE-ASKED for, never assumed.** `start()` suspends across that
1151
1193
  * connect and upgrade, so it re-checks the transport's state before installing anything: a
1152
1194
  * concurrent `start()` that already installed a socket, or a {@link close} that ended the
@@ -1155,15 +1197,18 @@ var WebSocketServerTransport = class {
1155
1197
  * `start()` calls still resolve; exactly one socket is ever bound.
1156
1198
  * - **Inbound (`message`).** Each decoded text frame is `JSON.parse`d (guarded) and narrowed
1157
1199
  * with `parseJSONRPCMessage` — a {@link JSONRPCMessage} re-emits on this transport's `message`
1158
- * event (the reply the {@link import('@src/core').MCPClientInterface} correlates by `id`); a
1159
- * non-JSON / non-message frame surfaces on `error` and is dropped (§14). The socket's `close`
1200
+ * event (the reply the {@link import('@orkestrel/mcp').MCPClientInterface} correlates by `id`); a
1201
+ * non-JSON / non-message frame surfaces on `error` and is dropped. The socket's `close`
1160
1202
  * / `error` bridge to this transport's events.
1161
1203
  * - **Outbound (`send`).** `send(message)` writes one masked text frame.
1162
- * - **`close()`** closes the underlying socket and fires `close` (idempotent).
1204
+ * - **`close()`** unsubscribes from the socket, closes it, and fires `close` (idempotent). An
1205
+ * upgrade still on the wire is DESTROYED, so a `close()` during the handshake ends the
1206
+ * transport at once instead of waiting for a peer that may never answer — the suspended
1207
+ * `start()` resolves, because the close is the outcome its caller asked for.
1163
1208
  * - **URL scheme.** `options.url` accepts a `ws://` / `wss://` URL or an `http://` / `https://`
1164
1209
  * one; a `ws(s)` scheme is converted to `http(s)` for the underlying upgrade request (`wss`
1165
- * → TLS via `node:https`). Either reaches the same endpoint.
1166
- * - **Observable (§13).** Owns the `emitter` ({@link MCPClientTransportEventMap}); every emit
1210
+ * → TLS through `node:https`). Either reaches the same endpoint.
1211
+ * - **Observable.** Owns the `emitter` ({@link MCPClientTransportEventMap}); every emit
1167
1212
  * the emitter isolates a listener throw (a buggy observer never corrupts the transport);
1168
1213
  * `error` is a DOMAIN event (a transport-level fault).
1169
1214
  *
@@ -1178,7 +1223,11 @@ var WebSocketClientTransport = class {
1178
1223
  #emitter;
1179
1224
  #url;
1180
1225
  #headers;
1226
+ #frame = (text) => this.#receive(text);
1227
+ #ending = () => this.#onClose();
1228
+ #failure = (error) => this.#emitter.emit("error", error);
1181
1229
  #socket = void 0;
1230
+ #request = void 0;
1182
1231
  #closed = false;
1183
1232
  constructor(options) {
1184
1233
  this.#emitter = new _orkestrel_emitter.Emitter();
@@ -1195,8 +1244,28 @@ var WebSocketClientTransport = class {
1195
1244
  async start() {
1196
1245
  if (this.#socket !== void 0) return;
1197
1246
  this.#closed = false;
1198
- const url = this.#httpURL();
1199
- const key = (0, node_crypto.randomBytes)(16).toString("base64");
1247
+ try {
1248
+ await this.#connect(this.#httpURL(), (0, node_crypto.randomBytes)(16).toString("base64"));
1249
+ } finally {
1250
+ this.#request = void 0;
1251
+ }
1252
+ }
1253
+ async send(message) {
1254
+ const socket = this.#socket;
1255
+ if (socket === void 0) throw new Error("WebSocket transport is not connected");
1256
+ socket.send(JSON.stringify(message));
1257
+ }
1258
+ async close() {
1259
+ if (this.#closed) return;
1260
+ this.#closed = true;
1261
+ this.#request?.destroy();
1262
+ const socket = this.#socket;
1263
+ this.#release();
1264
+ this.#socket = void 0;
1265
+ if (socket !== void 0) socket.close();
1266
+ this.#emitter.emit("close");
1267
+ }
1268
+ async #connect(url, key) {
1200
1269
  const secure = url.protocol === "https:";
1201
1270
  const send = secure ? node_https.request : node_http.request;
1202
1271
  await new Promise((resolve, reject) => {
@@ -1213,6 +1282,7 @@ var WebSocketClientTransport = class {
1213
1282
  ...this.#headers
1214
1283
  }
1215
1284
  });
1285
+ this.#request = request;
1216
1286
  request.on("upgrade", (response, socket, head) => {
1217
1287
  const accept = response.headers["sec-websocket-accept"];
1218
1288
  if (!(0, _orkestrel_contract.isString)(accept) || accept !== (0, _orkestrel_websocket.computeWebSocketAccept)(key)) {
@@ -1237,27 +1307,27 @@ var WebSocketClientTransport = class {
1237
1307
  response.resume();
1238
1308
  reject(/* @__PURE__ */ new Error(`WebSocket upgrade declined with status ${response.statusCode ?? 0}`));
1239
1309
  });
1240
- request.on("error", (error) => reject(error instanceof Error ? error : new Error(String(error))));
1310
+ request.on("error", (error) => {
1311
+ if (this.#closed) {
1312
+ resolve();
1313
+ return;
1314
+ }
1315
+ reject(error instanceof Error ? error : new Error(String(error)));
1316
+ });
1241
1317
  request.end();
1242
1318
  });
1243
1319
  }
1244
- async send(message) {
1245
- const socket = this.#socket;
1246
- if (socket === void 0) throw new Error("WebSocket transport is not connected");
1247
- socket.send(JSON.stringify(message));
1320
+ #bind(ws) {
1321
+ ws.emitter.on("message", this.#frame);
1322
+ ws.emitter.on("close", this.#ending);
1323
+ ws.emitter.on("error", this.#failure);
1248
1324
  }
1249
- async close() {
1250
- if (this.#closed) return;
1251
- this.#closed = true;
1325
+ #release() {
1252
1326
  const socket = this.#socket;
1253
- this.#socket = void 0;
1254
- if (socket !== void 0) socket.close();
1255
- this.#emitter.emit("close");
1256
- }
1257
- #bind(ws) {
1258
- ws.emitter.on("message", (text) => this.#receive(text));
1259
- ws.emitter.on("close", () => this.#onClose(ws));
1260
- ws.emitter.on("error", (error) => this.#emitter.emit("error", error));
1327
+ if (socket === void 0) return;
1328
+ socket.emitter.off("message", this.#frame);
1329
+ socket.emitter.off("close", this.#ending);
1330
+ socket.emitter.off("error", this.#failure);
1261
1331
  }
1262
1332
  #receive(text) {
1263
1333
  let parsed;
@@ -1274,9 +1344,10 @@ var WebSocketClientTransport = class {
1274
1344
  }
1275
1345
  this.#emitter.emit("message", message);
1276
1346
  }
1277
- #onClose(socket) {
1278
- if (this.#closed || this.#socket !== socket) return;
1347
+ #onClose() {
1348
+ if (this.#closed) return;
1279
1349
  this.#closed = true;
1350
+ this.#release();
1280
1351
  this.#socket = void 0;
1281
1352
  this.#emitter.emit("close");
1282
1353
  }
@@ -1305,19 +1376,21 @@ var WebSocketClientTransport = class {
1305
1376
  * - **Inbound (`message`).** Standard output is drained eagerly through the supervisor's
1306
1377
  * `readline`-framed `lines` iterable, so a multi-byte UTF-8 sequence split across two reads is
1307
1378
  * decoded whole and a final line written without a trailing newline still arrives. Each framed
1308
- * line is decoded and delivered via the shared {@link dispatchLines} helper — a well-formed
1309
- * {@link JSONRPCMessage} emits `message`, a malformed line emits `error` (§14, never throws).
1379
+ * line is decoded and delivered through the shared {@link dispatchLines} helper — a well-formed
1380
+ * {@link JSONRPCMessage} emits `message`, a malformed line emits `error` (never throws).
1310
1381
  * - **Outbound (`send`).** `send(message)` writes one newline-terminated `JSON.stringify`d line
1311
1382
  * through the supervisor's `send` and AWAITS its answer, so this promise settles only after the
1312
1383
  * host reports the line handled rather than the moment the write is queued. The supervisor never
1313
1384
  * rejects — it answers `false` for a channel that was closed, destroyed, or ended, and for a write
1314
1385
  * that failed — so a `false` answer REJECTS here with the same not-connected error a transport
1315
1386
  * that was never started raises. A dead peer surfaces at the caller instead of vanishing.
1316
- * - **`close()`** terminates the child through the supervisor's bounded `SIGTERM` grace
1317
- * `SIGKILL` group-kill, awaits its observed exit, tears down the supervisor, and fires `close`
1318
- * once (idempotent). On a POSIX host the child leads its own process group, so the group-kill
1319
- * reaches its grandchildren rather than orphaning them.
1320
- * - **Observable (§13).** Owns the `emitter` ({@link MCPClientTransportEventMap}); the
1387
+ * - **`close()`** releases this transport's line pump without waiting for the child's stdout
1388
+ * iterator, then runs the supervisor's bounded `SIGTERM` grace `SIGKILL` group-kill and
1389
+ * teardown before firing `close` once (idempotent). A descendant can retain an inherited stdout
1390
+ * pipe after the child exits; the pump's release barrier keeps that substrate limit from keeping
1391
+ * this transport's `close()` pending. On a POSIX host the child leads its own process group, so
1392
+ * the group-kill reaches its grandchildren rather than orphaning them.
1393
+ * - **Observable.** Owns the `emitter` ({@link MCPClientTransportEventMap}); the
1321
1394
  * emitter isolates a listener throw; `error` is a DOMAIN event (a transport-level
1322
1395
  * fault, including the child spawn cause the supervisor surfaces), distinct from the emitter's
1323
1396
  * own listener-error channel.
@@ -1335,6 +1408,8 @@ var StdioClientTransport = class {
1335
1408
  #args;
1336
1409
  #env;
1337
1410
  #process = void 0;
1411
+ #release = Promise.withResolvers();
1412
+ #pumping = Promise.resolve();
1338
1413
  #closed = false;
1339
1414
  constructor(options) {
1340
1415
  this.#emitter = new _orkestrel_emitter.Emitter();
@@ -1352,6 +1427,7 @@ var StdioClientTransport = class {
1352
1427
  async start() {
1353
1428
  if (this.#process !== void 0) return;
1354
1429
  this.#closed = false;
1430
+ this.#release = Promise.withResolvers();
1355
1431
  const child = new _orkestrel_process_server.Process({
1356
1432
  command: {
1357
1433
  file: this.#command,
@@ -1365,7 +1441,7 @@ var StdioClientTransport = class {
1365
1441
  this.#process = child;
1366
1442
  child.emitter.on("error", (cause) => this.#emitter.emit("error", cause));
1367
1443
  child.exit.then(() => this.#onExit(child));
1368
- this.#pump(child);
1444
+ this.#pumping = this.#pump(child, this.#release.promise);
1369
1445
  }
1370
1446
  async send(message) {
1371
1447
  const child = this.#process;
@@ -1375,14 +1451,20 @@ var StdioClientTransport = class {
1375
1451
  if (this.#closed) return;
1376
1452
  this.#closed = true;
1377
1453
  const child = this.#process;
1454
+ const pumping = this.#pumping;
1455
+ this.#release.resolve();
1378
1456
  this.#process = void 0;
1379
1457
  if (child !== void 0) await child.destroy();
1458
+ await pumping;
1380
1459
  this.#emitter.emit("close");
1381
1460
  }
1382
- async #pump(child) {
1383
- for await (const line of child.lines) {
1461
+ async #pump(child, release) {
1462
+ const iterator = child.lines[Symbol.asyncIterator]();
1463
+ while (true) {
1464
+ const next = await Promise.race([iterator.next(), release]);
1465
+ if (next === void 0 || next.done) return;
1384
1466
  if (this.#process !== child) return;
1385
- dispatchLines(this.#emitter, [line]);
1467
+ dispatchLines(this.#emitter, [next.value]);
1386
1468
  }
1387
1469
  }
1388
1470
  #onExit(child) {
@@ -1403,22 +1485,31 @@ var StdioClientTransport = class {
1403
1485
  * import('./WebSocketServerTransport.js').WebSocketServerTransport}.
1404
1486
  *
1405
1487
  * @remarks
1406
- * - **Reuses `MCPClientTransportInterface` (§21).** The same generic carrier the HTTP
1488
+ * - **Reuses `MCPClientTransportInterface`.** The same generic carrier the HTTP
1407
1489
  * and WebSocket server transports implement — `emitter` (`message` / `close` /
1408
1490
  * `error`), `start`, `send`, `close`. `session` is `undefined` (the stateless v1).
1409
1491
  * - **Inbound (`message`).** `start()` subscribes to `input`'s `data` event; each
1410
1492
  * chunk is folded through the shared {@link extractLines} line-framing helper
1411
1493
  * (buffering a partial trailing line across reads), and every complete line is
1412
- * decoded and delivered via the shared {@link dispatchLines} helper — a
1494
+ * decoded and delivered through the shared {@link dispatchLines} helper — a
1413
1495
  * well-formed {@link JSONRPCMessage} re-emits on `message`, a malformed line
1414
- * emits `error` (§14, never throws). `input`'s `close` bridges to this
1496
+ * emits `error` (never throws). `input`'s `close` bridges to this
1415
1497
  * transport's `close`.
1416
1498
  * - **Outbound (`send`).** `send(message)` writes one newline-terminated
1417
1499
  * `JSON.stringify`d line to `output`.
1418
- * - **`close()`** fires this transport's `close` (idempotent) the injected streams
1419
- * are owned by the caller (typically `process.stdin`/`process.stdout`, which must
1420
- * never be closed out from under the process) and are not torn down here.
1421
- * - **Observable (§13).** Owns the `emitter` ({@link MCPClientTransportEventMap}); the
1500
+ * - **`close()`** removes this transport's input subscriptions and fires its `close`
1501
+ * event (idempotent). It pauses the input only when the caller was not already reading
1502
+ * it at `start` (`readableFlowing !== true`) AND no `data` listener remains once this
1503
+ * transport's own is removed so a process holding `process.stdin` can exit, and a
1504
+ * caller's own flow is never stopped underneath it. The transport preserves flowing versus
1505
+ * non-flowing state and restores every caller-owned listener. A Node stream that had never been
1506
+ * read starts with `readableFlowing === null` and is left non-flowing (`false`), because Node
1507
+ * exposes no public operation that restores `null` after data consumption starts. Attaching a
1508
+ * later `data` listener does not resume that stream; the caller must call `resume()` before the
1509
+ * listener receives data. The injected streams are owned by the caller (typically
1510
+ * `process.stdin`/`process.stdout`), so the transport never destroys, ends, or blanket-clears
1511
+ * them.
1512
+ * - **Observable.** Owns the `emitter` ({@link MCPClientTransportEventMap}); the
1422
1513
  * emitter isolates a listener throw; `error` is a DOMAIN event (a transport-level
1423
1514
  * fault), distinct from the emitter's own listener-error channel.
1424
1515
  */
@@ -1426,9 +1517,13 @@ var StdioServerTransport = class {
1426
1517
  #emitter;
1427
1518
  #input;
1428
1519
  #output;
1520
+ #data = (chunk) => this.#receive(chunk.toString());
1521
+ #ending = () => this.#onClose();
1522
+ #failure = (error) => this.#emitter.emit("error", error);
1429
1523
  #buffer = "";
1430
1524
  #started = false;
1431
1525
  #closed = false;
1526
+ #flowing = false;
1432
1527
  constructor(input, output) {
1433
1528
  this.#emitter = new _orkestrel_emitter.Emitter();
1434
1529
  this.#input = input;
@@ -1444,9 +1539,10 @@ var StdioServerTransport = class {
1444
1539
  async start() {
1445
1540
  if (this.#started || this.#closed) return;
1446
1541
  this.#started = true;
1447
- this.#input.on("data", (chunk) => this.#receive(chunk.toString()));
1448
- this.#input.on("close", () => this.#onClose());
1449
- this.#input.on("error", (error) => this.#emitter.emit("error", error));
1542
+ this.#flowing = this.#input instanceof node_stream.Readable && this.#input.readableFlowing === true;
1543
+ this.#input.on("data", this.#data);
1544
+ this.#input.on("close", this.#ending);
1545
+ this.#input.on("error", this.#failure);
1450
1546
  }
1451
1547
  async send(message) {
1452
1548
  this.#output.write(`${JSON.stringify(message)}\n`);
@@ -1454,6 +1550,7 @@ var StdioServerTransport = class {
1454
1550
  async close() {
1455
1551
  if (this.#closed) return;
1456
1552
  this.#closed = true;
1553
+ this.#release();
1457
1554
  this.#emitter.emit("close");
1458
1555
  }
1459
1556
  #receive(chunk) {
@@ -1464,13 +1561,20 @@ var StdioServerTransport = class {
1464
1561
  #onClose() {
1465
1562
  if (this.#closed) return;
1466
1563
  this.#closed = true;
1564
+ this.#release();
1467
1565
  this.#emitter.emit("close");
1468
1566
  }
1567
+ #release() {
1568
+ this.#input.removeListener("data", this.#data);
1569
+ this.#input.removeListener("close", this.#ending);
1570
+ this.#input.removeListener("error", this.#failure);
1571
+ if (!this.#flowing && this.#input.listenerCount("data") === 0) this.#input.pause();
1572
+ }
1469
1573
  };
1470
1574
  //#endregion
1471
1575
  //#region src/server/factories.ts
1472
1576
  /**
1473
- * Adapt the installed server token primitives to the host-neutral MCP continuation port.
1577
+ * Adapts the installed server token primitives to the host-neutral MCP continuation port.
1474
1578
  *
1475
1579
  * @param secret - Current signing secret or `[current, ...older]` rotation list
1476
1580
  * @returns A continuation port that seals and opens opaque canonical state strings
@@ -1486,8 +1590,8 @@ function createMCPContinuation(secret) {
1486
1590
  };
1487
1591
  }
1488
1592
  /**
1489
- * Create the MCP Streamable-HTTP transport routes — mounts a transport-agnostic
1490
- * {@link MCPDispatcherInterface} (the `@src/core` dispatch boundary) on the fetch-standard router
1593
+ * Creates the MCP Streamable-HTTP transport routes — mounts a transport-agnostic
1594
+ * {@link MCPDispatcherInterface} (the `@orkestrel/mcp` dispatch boundary) on the fetch-standard router
1491
1595
  * spine, pumping each `POST` body through `mcp.dispatch`. Returns the {@link RouteInput}s to
1492
1596
  * hand to `router.add(...)`.
1493
1597
  *
@@ -1510,7 +1614,7 @@ function createMCPContinuation(secret) {
1510
1614
  *
1511
1615
  * When `streaming` is enabled (the default) and the client `Accept`s `text/event-stream`,
1512
1616
  * the `200` reply is framed as a Streamable-HTTP SSE response (one `data:` event carrying
1513
- * the JSON-RPC envelope, then the stream ends) via `@orkestrel/server`'s generic
1617
+ * the JSON-RPC envelope, then the stream ends) through `@orkestrel/server`'s generic
1514
1618
  * {@link import('@orkestrel/server').openStream} seam; otherwise it is a plain JSON body.
1515
1619
  *
1516
1620
  * **Sessions are a SEPARATE, plug-and-play middleware.** `createMCPRoutes` mints / reads no
@@ -1532,11 +1636,12 @@ function createMCPContinuation(secret) {
1532
1636
  *
1533
1637
  * @example
1534
1638
  * ```ts
1535
- * import { createMCPLegacy, createMCPServer, createToolManager } from '@src/core'
1536
- * import { createMCPRoutes } from '@src/server'
1639
+ * import { createMCPLegacy, createMCPServer } from '@orkestrel/mcp'
1640
+ * import { createMCPRoutes } from '@orkestrel/mcp/server'
1641
+ * import { createToolManager } from '@orkestrel/tool'
1537
1642
  *
1538
1643
  * const mcp = createMCPServer({ identity: { name: 'docs', version: '1.0.0' }, tools: createToolManager() })
1539
- * const routes = createMCPRoutes(createMCPLegacy(mcp)) // both eras; pass `mcp` for modern only
1644
+ * const routes = createMCPRoutes(createMCPLegacy(mcp)) // answers `initialize` too; pass `mcp` alone for modern-only
1540
1645
  * ```
1541
1646
  */
1542
1647
  function createMCPRoutes(mcp, options) {
@@ -1548,7 +1653,7 @@ function createMCPRoutes(mcp, options) {
1548
1653
  }];
1549
1654
  }
1550
1655
  /**
1551
- * Create the HTTP CLIENT transport for an {@link import('@src/core').MCPClientInterface}
1656
+ * Creates the HTTP CLIENT transport for an {@link import('@orkestrel/mcp').MCPClientInterface}
1552
1657
  * — a {@link MCPClientTransportInterface} that drives a REMOTE Streamable-HTTP MCP server
1553
1658
  * over `fetch`. The egress mirror of {@link createMCPRoutes}.
1554
1659
  *
@@ -1556,9 +1661,9 @@ function createMCPRoutes(mcp, options) {
1556
1661
  * Hand it to `createMCPClient({ transport })`: each JSON-RPC message the client sends is
1557
1662
  * `POST`ed to `options.url` with `content-type: application/json` and an `Accept` of
1558
1663
  * both `application/json` and `text/event-stream` (the server answers with EITHER — a
1559
- * plain JSON envelope or a Streamable-HTTP SSE `data:` event, decoded via `@orkestrel/sse`),
1664
+ * plain JSON envelope or a Streamable-HTTP SSE `data:` event, decoded with `@orkestrel/sse`),
1560
1665
  * and the reply is surfaced on the transport's `message` event for the client's id
1561
- * correlation. Add `options.headers` (e.g. an `Authorization` bearer) to reach a guarded
1666
+ * correlation. Add `options.headers` (for example, an `Authorization` bearer) to reach a guarded
1562
1667
  * server. `start` / `close` hold no connection; against a STATEFUL server it captures the
1563
1668
  * `mcp-session-id` from `initialize` and echoes it on later requests. It also captures
1564
1669
  * the initialize result's `protocolVersion` and sends `mcp-protocol-version` alone on each
@@ -1567,13 +1672,13 @@ function createMCPRoutes(mcp, options) {
1567
1672
  *
1568
1673
  * @param options - `url` (the remote endpoint; REQUIRED), optional `headers` merged onto
1569
1674
  * every request, optional `fetch` (default `globalThis.fetch`), and optional `timeout`
1570
- * (ms, applied via `AbortSignal.timeout`); see {@link HTTPClientTransportOptions}
1675
+ * (ms, applied with `AbortSignal.timeout`); see {@link HTTPClientTransportOptions}
1571
1676
  * @returns A working {@link MCPClientTransportInterface} over `fetch`
1572
1677
  *
1573
1678
  * @example
1574
1679
  * ```ts
1575
- * import { createMCPClient } from '@src/core'
1576
- * import { createHTTPClientTransport } from '@src/server'
1680
+ * import { createMCPClient } from '@orkestrel/mcp'
1681
+ * import { createHTTPClientTransport } from '@orkestrel/mcp/server'
1577
1682
  *
1578
1683
  * const client = createMCPClient({
1579
1684
  * transport: createHTTPClientTransport({ url: 'http://localhost:3000/mcp' }),
@@ -1586,7 +1691,7 @@ function createHTTPClientTransport(options) {
1586
1691
  return new HTTPClientTransport(options);
1587
1692
  }
1588
1693
  /**
1589
- * Create the MCP WebSocket transport INGRESS — an {@link UpgradeHandler} that exposes a
1694
+ * Creates the MCP WebSocket transport INGRESS — an {@link UpgradeHandler} that exposes a
1590
1695
  * transport-agnostic {@link MCPDispatcherInterface} over a WebSocket, the WebSocket mirror of
1591
1696
  * {@link createMCPRoutes}. Register it on the spine's upgrade seam.
1592
1697
  *
@@ -1601,11 +1706,11 @@ function createHTTPClientTransport(options) {
1601
1706
  * A decline NEVER writes to the socket (it is not yet ours) — the spine owns the unclaimed
1602
1707
  * outcome.
1603
1708
  * - **Claims (returns `true`)** otherwise: it builds `createNodeWebSocket({ socket, key, head,
1604
- * protocol })` (SERVER mode → writes the `101` handshake, echoing the `subprotocol`, default
1605
- * {@link MCP_WEBSOCKET_SUBPROTOCOL} `'mcp'`, and sends UNMASKED frames), wraps it in a
1709
+ * protocol })` (SERVER mode → writes the `101` handshake, selects the configured subprotocol
1710
+ * only when the client's offer contains it, and sends UNMASKED frames), wraps it in a
1606
1711
  * {@link WebSocketServerTransport}, and pipes it through the core {@link
1607
- * import('@src/core').MCPTransportInterface} port via {@link
1608
- * import('./helpers.js').bridgeMessageTransport} + {@link import('@src/core').bindServer}:
1712
+ * import('@orkestrel/mcp').MCPTransportInterface} port through {@link
1713
+ * import('./helpers.js').bridgeMessageTransport} + {@link import('@orkestrel/mcp').bindServer}:
1609
1714
  * each inbound REQUEST runs through `mcp.dispatch`, and a defined response is written back
1610
1715
  * as a frame — a NOTIFICATION sends nothing, and a non-request message (a stray response) is
1611
1716
  * ignored. A `dispatch` / `send` fault surfaces on `mcp.emitter`'s `error` event rather than
@@ -1630,19 +1735,24 @@ function createHTTPClientTransport(options) {
1630
1735
  *
1631
1736
  * @example
1632
1737
  * ```ts
1633
- * import { createMCPServer, createToolManager } from '@src/core'
1634
- * import { createWebSocketServer } from '@src/server'
1738
+ * import { createMCPLegacy, createMCPServer } from '@orkestrel/mcp'
1739
+ * import { createWebSocketServer } from '@orkestrel/mcp/server'
1740
+ * import { createToolManager } from '@orkestrel/tool'
1635
1741
  *
1636
1742
  * const mcp = createMCPServer({ identity: { name: 'docs', version: '1.0.0' }, tools: createToolManager() })
1637
- * server.upgrade(createWebSocketServer(mcp, { emitter: server.emitter })) // ws://…/mcp
1743
+ * // Claims the MCP upgrade at ws://…/mcp:
1744
+ * server.upgrade(createWebSocketServer(createMCPLegacy(mcp), { emitter: server.emitter })) // answers `initialize` too; pass `mcp` alone for modern-only
1638
1745
  * ```
1639
1746
  */
1640
1747
  function createWebSocketServer(mcp, options) {
1641
1748
  const path = options.path ?? "/mcp";
1642
1749
  const subprotocol = options.subprotocol ?? "mcp";
1643
- const live = /* @__PURE__ */ new Set();
1750
+ const live = /* @__PURE__ */ new Map();
1644
1751
  options.emitter.on("stop", () => {
1645
- for (const transport of live) transport.close();
1752
+ for (const [transport, unbind] of live) {
1753
+ unbind();
1754
+ transport.close();
1755
+ }
1646
1756
  });
1647
1757
  return (request, socket, head) => {
1648
1758
  const upgrade = request.headers["upgrade"];
@@ -1652,21 +1762,26 @@ function createWebSocketServer(mcp, options) {
1652
1762
  if (!(0, _orkestrel_contract.isString)(key)) return false;
1653
1763
  const version = request.headers["sec-websocket-version"];
1654
1764
  if (!(0, _orkestrel_contract.isString)(version) || version !== _orkestrel_websocket.WEBSOCKET_VERSION) return false;
1765
+ const offer = request.headers["sec-websocket-protocol"];
1766
+ const protocol = (0, _orkestrel_contract.isString)(offer) && offer.split(",").some((candidate) => candidate.trim() === subprotocol) ? subprotocol : void 0;
1655
1767
  const transport = new WebSocketServerTransport((0, _orkestrel_websocket.createNodeWebSocket)({
1656
1768
  socket,
1657
1769
  key,
1658
1770
  head,
1659
- protocol: subprotocol
1771
+ ...protocol === void 0 ? {} : { protocol }
1660
1772
  }));
1661
- live.add(transport);
1662
- transport.emitter.on("close", () => live.delete(transport));
1663
- (0, _src_core.bindServer)(mcp, bridgeMessageTransport(transport));
1773
+ const unbind = (0, _src_core.bindServer)(mcp, bridgeMessageTransport(transport));
1774
+ live.set(transport, unbind);
1775
+ transport.emitter.on("close", () => {
1776
+ live.delete(transport);
1777
+ unbind();
1778
+ });
1664
1779
  transport.start();
1665
1780
  return true;
1666
1781
  };
1667
1782
  }
1668
1783
  /**
1669
- * Create the WebSocket CLIENT transport for an {@link import('@src/core').MCPClientInterface}
1784
+ * Creates the WebSocket CLIENT transport for an {@link import('@orkestrel/mcp').MCPClientInterface}
1670
1785
  * — a {@link MCPClientTransportInterface} that drives a REMOTE MCP server over a WebSocket. The
1671
1786
  * egress mirror of {@link createWebSocketServer} and the WebSocket sibling of {@link
1672
1787
  * createHTTPClientTransport}.
@@ -1675,11 +1790,11 @@ function createWebSocketServer(mcp, options) {
1675
1790
  * Hand it to `createMCPClient({ transport })`: `start()` (run by `client.connect()`) performs
1676
1791
  * the RFC 6455 client handshake against `options.url` (accepting a `ws://` / `wss://` or an
1677
1792
  * `http://` / `https://` URL — a `ws(s)` scheme is converted to `http(s)` for the underlying
1678
- * upgrade request), validates the `Sec-WebSocket-Accept` (via `@orkestrel/websocket`'s
1793
+ * upgrade request), validates the `Sec-WebSocket-Accept` (with `@orkestrel/websocket`'s
1679
1794
  * `computeWebSocketAccept`), and opens a persistent bidirectional frame channel; each JSON-RPC
1680
1795
  * message the client `send`s is written as one masked text frame, and each decoded reply is
1681
1796
  * surfaced on the transport's `message` event for the client's id correlation. Add
1682
- * `options.headers` (e.g. an `Authorization` bearer) to reach a guarded server.
1797
+ * `options.headers` (for example, an `Authorization` bearer) to reach a guarded server.
1683
1798
  *
1684
1799
  * @param options - `url` (the remote WebSocket endpoint; REQUIRED) and optional `headers`
1685
1800
  * merged onto the upgrade request; see {@link WebSocketClientTransportOptions}
@@ -1687,8 +1802,8 @@ function createWebSocketServer(mcp, options) {
1687
1802
  *
1688
1803
  * @example
1689
1804
  * ```ts
1690
- * import { createMCPClient } from '@src/core'
1691
- * import { createWebSocketClientTransport } from '@src/server'
1805
+ * import { createMCPClient } from '@orkestrel/mcp'
1806
+ * import { createWebSocketClientTransport } from '@orkestrel/mcp/server'
1692
1807
  *
1693
1808
  * const client = createMCPClient({
1694
1809
  * transport: createWebSocketClientTransport({ url: 'ws://localhost:3000/mcp' }),
@@ -1701,7 +1816,7 @@ function createWebSocketClientTransport(options) {
1701
1816
  return new WebSocketClientTransport(options);
1702
1817
  }
1703
1818
  /**
1704
- * Create the stdio CLIENT transport for an {@link import('@src/core').MCPClientInterface}
1819
+ * Creates the stdio CLIENT transport for an {@link import('@orkestrel/mcp').MCPClientInterface}
1705
1820
  * — a {@link MCPClientTransportInterface} that spawns and drives a CHILD PROCESS MCP server
1706
1821
  * over newline-delimited JSON-RPC on `stdin`/`stdout`, the stdio sibling of {@link
1707
1822
  * createHTTPClientTransport} and {@link createWebSocketClientTransport}.
@@ -1721,8 +1836,8 @@ function createWebSocketClientTransport(options) {
1721
1836
  *
1722
1837
  * @example
1723
1838
  * ```ts
1724
- * import { createMCPClient } from '@src/core'
1725
- * import { createStdioClientTransport } from '@src/server'
1839
+ * import { createMCPClient } from '@orkestrel/mcp'
1840
+ * import { createStdioClientTransport } from '@orkestrel/mcp/server'
1726
1841
  *
1727
1842
  * const client = createMCPClient({
1728
1843
  * transport: createStdioClientTransport({ command: 'node', args: ['./server.js'] }),
@@ -1735,16 +1850,16 @@ function createStdioClientTransport(options) {
1735
1850
  return new StdioClientTransport(options);
1736
1851
  }
1737
1852
  /**
1738
- * Create the MCP stdio transport INGRESS — pumps a transport-agnostic {@link
1853
+ * Creates the MCP stdio transport INGRESS — pumps a transport-agnostic {@link
1739
1854
  * MCPDispatcherInterface} over newline-delimited JSON-RPC on `stdin`/`stdout` (or an
1740
1855
  * injected stream pair), the stdio mirror of {@link createWebSocketServer}.
1741
1856
  *
1742
1857
  * @remarks
1743
1858
  * Wraps `options.input` (default `process.stdin`) / `options.output` (default
1744
1859
  * `process.stdout`) in a {@link import('./transports/StdioServerTransport.js').StdioServerTransport}
1745
- * and pipes it through the core {@link import('@src/core').MCPTransportInterface} port
1746
- * via {@link import('./helpers.js').bridgeMessageTransport} + {@link
1747
- * import('@src/core').bindServer}: each inbound REQUEST runs through `mcp.dispatch`, and
1860
+ * and pipes it through the core {@link import('@orkestrel/mcp').MCPTransportInterface} port
1861
+ * through {@link import('./helpers.js').bridgeMessageTransport} + {@link
1862
+ * import('@orkestrel/mcp').bindServer}: each inbound REQUEST runs through `mcp.dispatch`, and
1748
1863
  * a defined response is written back as a newline-terminated line — a NOTIFICATION
1749
1864
  * writes nothing, and a non-request message is ignored. A `dispatch` / `send` fault
1750
1865
  * surfaces on `mcp.emitter`'s `error` event rather than escaping the (async) message
@@ -1757,21 +1872,24 @@ function createStdioClientTransport(options) {
1757
1872
  *
1758
1873
  * @example
1759
1874
  * ```ts
1760
- * import { createMCPServer, createToolManager } from '@src/core'
1761
- * import { createStdioServer } from '@src/server'
1875
+ * import { createMCPLegacy, createMCPServer } from '@orkestrel/mcp'
1876
+ * import { createStdioServer } from '@orkestrel/mcp/server'
1877
+ * import { createToolManager } from '@orkestrel/tool'
1762
1878
  *
1763
1879
  * const mcp = createMCPServer({ identity: { name: 'docs', version: '1.0.0' }, tools: createToolManager() })
1764
- * createStdioServer(mcp).start() // an MCP client now connects over this process's stdio
1880
+ * // An MCP client now connects over this process's stdio:
1881
+ * createStdioServer(createMCPLegacy(mcp)).start() // answers `initialize` too; pass `mcp` alone for modern-only
1765
1882
  * ```
1766
1883
  */
1767
1884
  function createStdioServer(mcp, options) {
1768
1885
  const transport = new StdioServerTransport(options?.input ?? process.stdin, options?.output ?? process.stdout);
1769
- (0, _src_core.bindServer)(mcp, bridgeMessageTransport(transport));
1886
+ const unbind = (0, _src_core.bindServer)(mcp, bridgeMessageTransport(transport));
1770
1887
  return {
1771
1888
  start() {
1772
1889
  transport.start();
1773
1890
  },
1774
1891
  stop() {
1892
+ unbind();
1775
1893
  transport.close();
1776
1894
  }
1777
1895
  };
@@ -1779,9 +1897,9 @@ function createStdioServer(mcp, options) {
1779
1897
  //#endregion
1780
1898
  //#region src/server/middlewares.ts
1781
1899
  /**
1782
- * Create the native MCP session {@link MiddlewareHandler} — the plug-and-play stateful layer
1900
+ * Creates the native MCP session {@link MiddlewareHandler} — the plug-and-play stateful layer
1783
1901
  * that fronts a session-agnostic {@link import('./factories.js').createMCPRoutes}. Compose it
1784
- * via `router.use(createMCPSession())` (or the equivalent middleware seam), mirroring any
1902
+ * with `router.use(createMCPSession())` (or the equivalent middleware seam), mirroring any
1785
1903
  * other closure-scoped stateful middleware. Has NO dependency on `@orkestrel/middleware` — the
1786
1904
  * session store, mint-on-`initialize`, and resumable stream are all native to this package.
1787
1905
  *
@@ -1790,11 +1908,11 @@ function createStdioServer(mcp, options) {
1790
1908
  * `path` (default {@link DEFAULT_MCP_PATH}); a request to any other path passes straight
1791
1909
  * through (`next()`).
1792
1910
  *
1793
- * A modern-shaped POST also passes straight through via `next()`, ignoring any session id.
1911
+ * A modern-shaped POST also passes straight through with `next()`, ignoring any session id.
1794
1912
  * The remaining behavior is the legacy session layer:
1795
1913
  *
1796
1914
  * - **`POST {path}`.** Buffers `const text = await request.text()` (so the downstream route
1797
- * can re-read it via a freshly-built forwarded `Request`). Resolves a session via {@link
1915
+ * can re-read it from a freshly-built forwarded `Request`). Resolves a session through {@link
1798
1916
  * readSessionHeader}: a VALID id touches the entry and sets `context.state.session`; an
1799
1917
  * ABSENT / unknown id whose (guarded) body parses to an `initialize` request ({@link
1800
1918
  * isInitializeRequest}) MINTS a fresh {@link MCPSession} (`crypto.randomUUID()`, `capacity`)
@@ -1809,7 +1927,7 @@ function createStdioServer(mcp, options) {
1809
1927
  * a `DELETE` arriving while the request was suspended is not undone.
1810
1928
  * - **`GET {path}`.** Resolves the session the same way (no mint — only `initialize` mints);
1811
1929
  * an invalid / unknown id is the same `404`. A valid session opens the resumable
1812
- * server→client stream via `@orkestrel/server`'s {@link import('@orkestrel/server').openStream}:
1930
+ * server→client stream through `@orkestrel/server`'s {@link import('@orkestrel/server').openStream}:
1813
1931
  * replays every event after the client's `Last-Event-ID` ({@link readLastEventId}) BEFORE
1814
1932
  * attaching the stream for live pushes, then attaches; cancellation of the streamed response
1815
1933
  * body composes with `request.signal` and detaches it. Long-lived — never `end()`ed here.
@@ -1833,12 +1951,14 @@ function createStdioServer(mcp, options) {
1833
1951
  *
1834
1952
  * @example
1835
1953
  * ```ts
1836
- * import { createMCPServer, createToolManager } from '@src/core'
1837
- * import { createMCPRoutes, createMCPSession } from '@src/server'
1954
+ * import { createMCPLegacy, createMCPServer } from '@orkestrel/mcp'
1955
+ * import { createMCPRoutes, createMCPSession } from '@orkestrel/mcp/server'
1956
+ * import { createToolManager } from '@orkestrel/tool'
1838
1957
  *
1839
1958
  * const mcp = createMCPServer({ identity: { name: 'docs', version: '1.0.0' }, tools: createToolManager() })
1840
1959
  * router.use(createMCPSession({ ttl: 60_000 })) // stateful: mint + validate + resumable GET / DELETE
1841
- * router.add(createMCPRoutes(mcp)) // the route stays session-agnostic
1960
+ * // The route stays session-agnostic:
1961
+ * router.add(createMCPRoutes(createMCPLegacy(mcp))) // answers `initialize` too; pass `mcp` alone for modern-only
1842
1962
  * ```
1843
1963
  */
1844
1964
  function createMCPSession(options) {