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