@orkestrel/mcp 0.0.27 → 0.0.29

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.
@@ -1,7 +1,6 @@
1
- import { createSSEParser } from "@orkestrel/sse";
2
- import { JSONRPC_INVALID_PARAMS, JSONRPC_INVALID_REQUEST, JSONRPC_METHOD_NOT_FOUND, JSONRPC_PARSE_ERROR, MCP_HANDSHAKE_VERSION, MCP_HEADER_MISMATCH, MCP_LOOKUP_PAGES, MCP_META_VERSION, MCP_MISSING_CAPABILITY, MCP_PARAM_PREFIX, MCP_UNSUPPORTED_VERSION, SUPPORTED_LEGACY_PROTOCOL_VERSIONS, bindServer, buildHeaderParameters, buildHeaderProjection, buildJSONRPCError, decodeSentinel, encodeSentinel, extractToolSchema, inferRequestVersion, isInitializeRequest, isJSONRPCInvocation, isJSONRPCResponse, isMCPLegacyVersion, isMCPModernVersion, isMCPVersion, isModernRequest, parseJSONRPCMessage, parseRequestContext, renderHeaderValue } from "../core/index.js";
3
- import { isArray, isRecord, isString, sanitizeBudget } from "@orkestrel/contract";
4
- import { openStream, signToken, verifyToken } from "@orkestrel/server";
1
+ import { HTTPClientTransport, JSONRPC_INVALID_PARAMS, JSONRPC_INVALID_REQUEST, JSONRPC_METHOD_NOT_FOUND, JSONRPC_PARSE_ERROR, MCP_HANDSHAKE_VERSION, MCP_HEADER_MISMATCH, MCP_LOOKUP_PAGES, MCP_META_VERSION, MCP_METHOD_HEADER, MCP_MISSING_CAPABILITY, MCP_NAME_HEADER, MCP_PARAM_PREFIX, MCP_PROTOCOL_VERSION_HEADER, MCP_SESSION_HEADER, MCP_UNSUPPORTED_VERSION, MCP_WEBSOCKET_SUBPROTOCOL, SUPPORTED_LEGACY_PROTOCOL_VERSIONS, bindServer, buildHeaderParameters, buildJSONRPCError, decodeEvent, decodeSentinel, deliverMessage, extractToolSchema, inferRequestEra, isInitializeRequest, isJSONRPCInvocation, isMCPLegacyVersion, isMCPModernVersion, isModernRequest, parseJSONRPCMessage, parseRequestContext, renderHeaderValue } from "../core/index.js";
2
+ import { isRecord, isString, parseJSON, sanitizeBudget } from "@orkestrel/contract";
3
+ import { createStream, signToken, verifyToken } from "@orkestrel/server";
5
4
  import { Emitter } from "@orkestrel/emitter";
6
5
  import { WEBSOCKET_READY_OPEN, WEBSOCKET_VERSION, computeWebSocketAccept, createNodeWebSocket } from "@orkestrel/websocket";
7
6
  import { randomBytes } from "node:crypto";
@@ -11,35 +10,14 @@ import { Process } from "@orkestrel/process/server";
11
10
  import { PROCESS_GRACE } from "@orkestrel/process";
12
11
  import { Readable } from "node:stream";
13
12
  //#region src/server/constants.ts
14
- /**
15
- * The Streamable-HTTP transport header that carries the MCP session id. When a {@link
16
- * import('./middlewares.js').createMCPSession} middleware is mounted, it SETS this header on
17
- * the `initialize` response (the minted id) and READS it on every subsequent request
18
- * (validating the session); the stateless `createMCPRoutes` default neither sets nor reads it.
19
- */
20
- var MCP_SESSION_HEADER = "mcp-session-id";
21
- /**
22
- * The Streamable-HTTP transport header carrying the negotiated MCP protocol version
23
- * on every post-initialize client request.
24
- *
25
- * @remarks
26
- * Required by MCP 2025-06-18 after initialization. Both HTTP client transports
27
- * capture the initialize result's `protocolVersion` and send it on subsequent
28
- * requests; `createMCPRoutes` rejects a present unsupported value before dispatch.
29
- */
30
- var MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version";
31
- /** The modern Streamable-HTTP request header carrying the JSON-RPC method name. */
32
- var MCP_METHOD_HEADER = "mcp-method";
33
- /** The modern Streamable-HTTP request header carrying a named method's target. */
34
- var MCP_NAME_HEADER = "mcp-name";
35
- /** The reverse-proxy response header controlling buffering of an SSE response. */
13
+ /** Names the reverse-proxy response header controlling buffering of an SSE response. */
36
14
  var SSE_BUFFERING_HEADER = "x-accel-buffering";
37
- /** The `X-Accel-Buffering` value that disables reverse-proxy buffering. */
15
+ /** Names the `X-Accel-Buffering` value that disables reverse-proxy buffering. */
38
16
  var SSE_BUFFERING_DISABLED = "no";
39
- /** The default request path `createMCPRoutes` mounts the transport's `POST` route at. */
17
+ /** Names the default request path `createMCPRoutes` mounts the transport's `POST` route at. */
40
18
  var DEFAULT_MCP_PATH = "/mcp";
41
19
  /**
42
- * The default interval in milliseconds between SSE keepalive comments on held-open MCP
20
+ * Sets the default interval in milliseconds between SSE keepalive comments on held-open MCP
43
21
  * responses.
44
22
  *
45
23
  * @remarks
@@ -47,34 +25,22 @@ var DEFAULT_MCP_PATH = "/mcp";
47
25
  * client detection and staying comfortably inside common intermediary idle windows.
48
26
  */
49
27
  var DEFAULT_MCP_KEEPALIVE_INTERVAL = 15e3;
50
- /** The comment text written by the held-open MCP response keepalive. */
28
+ /** Names the comment text written by the held-open MCP response keepalive. */
51
29
  var SSE_KEEPALIVE_COMMENT = "keepalive";
52
30
  /**
53
- * The WebSocket subprotocol the MCP-over-WebSocket transports negotiate sent by the
54
- * client in `Sec-WebSocket-Protocol`, echoed by the server in its `101` handshake.
55
- *
56
- * @remarks
57
- * `createWebSocketServer` echoes it in the upgrade response and `createWebSocketClientTransport`
58
- * requests it, so an MCP WebSocket endpoint is distinguishable from any other WebSocket on the
59
- * same path. The default WebSocket upgrade path is {@link DEFAULT_MCP_PATH} (the same `'/mcp'`
60
- * the HTTP transport mounts at) — the upgrade is selected by the `Upgrade: websocket` header,
61
- * not a separate path.
62
- */
63
- var MCP_WEBSOCKET_SUBPROTOCOL = "mcp";
64
- /**
65
- * The default capacity of a session's FOLDED resumable event log (the per-{@link
31
+ * Sets the default capacity of a session's folded resumable event log (the per-{@link
66
32
  * import('./MCPSession.js').MCPSession} replay log) — the maximum number of pushed
67
- * server→client messages retained for replay before the OLDEST is evicted.
33
+ * server→client messages retained for replay before the oldest is evicted.
68
34
  *
69
35
  * @remarks
70
36
  * Bounds the replay log's memory: only the most-recent {@link DEFAULT_MCP_SESSION_CAPACITY}
71
37
  * pushes are retained, so a client reconnecting with a `Last-Event-ID` older than that window
72
- * replays nothing (its cursor fell off the back). Override per `createMCPSession`'s `capacity`
73
- * for a deeper / shallower window.
38
+ * replays nothing (its cursor fell off the back). Override through the `session` group of
39
+ * `createMCPSession`'s options (`session.capacity`) for a deeper / shallower window.
74
40
  */
75
41
  var DEFAULT_MCP_SESSION_CAPACITY = 1024;
76
42
  /**
77
- * The default per-event idle lifetime (ms) of a session's folded resumable event log — an
43
+ * Sets the default per-event idle lifetime (ms) of a session's folded resumable event log — an
78
44
  * entry older than this is lazily evicted on the next access (no background timer), bounding
79
45
  * how far back a reconnecting client may replay.
80
46
  *
@@ -85,12 +51,12 @@ var DEFAULT_MCP_SESSION_CAPACITY = 1024;
85
51
  */
86
52
  var DEFAULT_MCP_SESSION_TTL = 3e5;
87
53
  /**
88
- * The default bound in milliseconds on one unconfirmed write to a stdio client transport's
54
+ * Sets the default bound in milliseconds on one unconfirmed write to a stdio client transport's
89
55
  * child `stdin` — the `delivery` a `createStdioClientTransport` caller who supplies none gets.
90
56
  *
91
57
  * @remarks
92
58
  * Ten seconds. The load-bearing property is the ordering, not the magnitude: this bound stays
93
- * BELOW {@link import('@orkestrel/mcp').DEFAULT_MCP_REQUEST_TIMEOUT}, so a write the child never
59
+ * below {@link import('@orkestrel/mcp').DEFAULT_MCP_REQUEST_TIMEOUT}, so a write the child never
94
60
  * reads fails as an undeliverable message while the request that carried it is still open,
95
61
  * rather than being masked by that request's own deadline expiring first. Override per
96
62
  * transport with `delivery`; an explicit `0` there removes the bound.
@@ -99,48 +65,17 @@ var DEFAULT_MCP_DELIVERY = 1e4;
99
65
  //#endregion
100
66
  //#region src/server/helpers.ts
101
67
  /**
102
- * Builds the error for a non-success HTTP response that carried no JSON-RPC message.
103
- *
104
- * @param response - The response whose status is reported
105
- * @param type - The response's content type, or an empty string when absent
106
- * @returns An error naming the HTTP status and unsupported response shape
107
- *
108
- * @example
109
- * ```ts
110
- * const error = buildResponseError(new Response('', { status: 500 }), '')
111
- * ```
112
- */
113
- function buildResponseError(response, type) {
114
- if (type.includes("application/json")) return /* @__PURE__ */ new Error(`HTTP ${response.status} response contained an application/json body that was not a JSON-RPC message`);
115
- if (type.includes("text/event-stream")) return /* @__PURE__ */ new Error(`HTTP ${response.status} response contained a text/event-stream body without a JSON-RPC message`);
116
- const shape = type === "" ? "a body without a content type" : `an unsupported '${type}' body`;
117
- return /* @__PURE__ */ new Error(`HTTP ${response.status} response contained ${shape}`);
118
- }
119
- /**
120
- * Creates a readable stream from its pull and cancellation behaviours.
121
- *
122
- * @param pull - The behaviour that supplies the stream's next chunk
123
- * @param cancel - The behaviour that releases the stream after consumer cancellation
124
- * @returns A readable stream backed by the supplied behaviours
125
- */
126
- function createReadableStream(pull, cancel) {
127
- return new ReadableStream({
128
- pull,
129
- cancel
130
- });
131
- }
132
- /**
133
68
  * Pumps a controlled held-open exchange onto an open SSE stream — one `data:` event per
134
- * notification in order, then the terminating response — and END the exchange however the
69
+ * notification in order, then the terminating response — and end the exchange however the
135
70
  * pump leaves.
136
71
  *
137
72
  * @remarks
138
73
  * The Streamable-HTTP twin of {@link import('@orkestrel/mcp').sendStream}, and it owns exactly what
139
- * that owns. The `finally` releases the exchange on EVERY exit — the normal terminal, a
74
+ * that owns. The `finally` releases the exchange on every exit — the normal terminal, a
140
75
  * producer that threw, a `write` that threw, and an abort alike — because nothing else will:
141
76
  * a request whose client vanished cancels nothing by itself, so an exchange this pump walks
142
77
  * away from keeps its producer, its request lifetime, and its live subscription slot forever.
143
- * The exchange is released BEFORE the body ends, so the slot is already back when the response
78
+ * The exchange is released before the body ends, so the slot is already back when the response
144
79
  * completes.
145
80
  *
146
81
  * Total — never throws and never rejects. A held-open SSE response has already sent its
@@ -157,7 +92,7 @@ function createReadableStream(pull, cancel) {
157
92
  * ```ts
158
93
  * const answer = await mcp.dispatch(invocation, { signal: disconnect.signal })
159
94
  * if (answer !== undefined && Symbol.asyncIterator in answer) {
160
- * const sse = openStream()
95
+ * const sse = createStream()
161
96
  * queueMicrotask(() => void sendEventStream(answer, sse))
162
97
  * }
163
98
  * ```
@@ -179,7 +114,7 @@ async function sendEventStream(stream, sse) {
179
114
  }
180
115
  }
181
116
  /**
182
- * Whether the request's `Accept` header opts into a Server-Sent-Events response.
117
+ * Checks whether the request's `Accept` header opts into a Server-Sent-Events response.
183
118
  *
184
119
  * @remarks
185
120
  * Reads the fetch-standard `Request.headers.get('accept')` and returns `true` when it
@@ -189,7 +124,7 @@ async function sendEventStream(stream, sse) {
189
124
  * — an absent / unmatched header returns `false`.
190
125
  *
191
126
  * @param request - The fetch-standard `Request`
192
- * @returns `true` when the client `Accept`s `text/event-stream`, else `false`
127
+ * @returns True if the client `Accept`s `text/event-stream`; false otherwise
193
128
  */
194
129
  function acceptsEventStream(request) {
195
130
  const accept = request.headers.get("accept");
@@ -197,7 +132,7 @@ function acceptsEventStream(request) {
197
132
  return accept.toLowerCase().includes("text/event-stream");
198
133
  }
199
134
  /**
200
- * Whether an HTTP request satisfies the endpoint's origin gate.
135
+ * Checks whether an HTTP request satisfies the endpoint's origin gate.
201
136
  *
202
137
  * @remarks
203
138
  * Validation is enabled by default. A request without `Origin` is allowed. A canonical origin
@@ -208,7 +143,7 @@ function acceptsEventStream(request) {
208
143
  *
209
144
  * @param request - The fetch-standard request to validate
210
145
  * @param options - Shared origin validation and delegation options
211
- * @returns `true` when the request may reach MCP dispatch
146
+ * @returns True if the request may reach MCP dispatch; false otherwise
212
147
  */
213
148
  function allowsOrigin(request, options) {
214
149
  if (options?.enabled === false) return true;
@@ -267,7 +202,7 @@ function readLastEventId(request) {
267
202
  * @remarks
268
203
  * Returns `Response.json(buildJSONRPCError(undefined, JSONRPC_INVALID_REQUEST, 'Session not
269
204
  * found'), { status: 404 })`, mirroring `createMCPRoutes`'s `400` transport-failure shape (a
270
- * JSON-RPC error BODY with NO id) but at the session-not-found status. Shared by
205
+ * JSON-RPC error body with no id) but at the session-not-found status. Shared by
271
206
  * every {@link import('./middlewares.js').createMCPSession} validation site — the
272
207
  * non-`initialize` `POST` path, the resumable `GET {path}` open, and the `DELETE {path}`
273
208
  * session-end (each a missing / unknown / TTL-evicted id) — so the single `404` envelope
@@ -279,70 +214,11 @@ function rejectUnknownSession() {
279
214
  return Response.json(buildJSONRPCError(void 0, JSONRPC_INVALID_REQUEST, "Session not found"), { status: 404 });
280
215
  }
281
216
  /**
282
- * Decodes a `fetch` Response's Server-Sent-Events body into the JSON-RPC messages it
283
- * carried — the CLIENT-side inverse of the server's Streamable-HTTP SSE response.
284
- *
285
- * @remarks
286
- * Reads the whole `response.body` stream chunk-by-chunk through a `TextDecoder({
287
- * stream: true })` (handling a multi-byte char split across reads) and `@orkestrel/sse`'s
288
- * {@link SSEParserInterface} (handling a partial line / in-progress event split across
289
- * reads), then narrows each dispatched event's `data` to a {@link JSONRPCMessage} with
290
- * `parseJSONRPCMessage` (so a non-message / non-JSON `data:` event is DROPPED, never
291
- * thrown — total). It reuses the SAME `SSEParser` the server's `openStream` seam
292
- * serializes against, so the wire round-trips. A `null` body (no stream) yields no
293
- * messages; the {@link import('./transports/HTTPClientTransport.js').HTTPClientTransport}
294
- * reads a request/response SSE reply (the server sends one `data:` event then ends), so
295
- * this drains to completion.
296
- *
297
- * @param response - The SSE `fetch` Response to decode (its `body` is read to completion)
298
- * @returns Every {@link JSONRPCMessage} the stream carried, in order
299
- */
300
- async function readEventStream(response) {
301
- const body = response.body;
302
- if (body === null) return [];
303
- const reader = body.getReader();
304
- const decoder = new TextDecoder();
305
- const parser = createSSEParser();
306
- const messages = [];
307
- try {
308
- for (;;) {
309
- const { done, value } = await reader.read();
310
- if (done) break;
311
- for (const event of parser.parse(decoder.decode(value, { stream: true }))) {
312
- const message = decodeEvent(event.data);
313
- if (message !== void 0) messages.push(message);
314
- }
315
- }
316
- } finally {
317
- reader.releaseLock();
318
- }
319
- return messages;
320
- }
321
- /**
322
- * Decodes one SSE event's `data` string into a {@link JSONRPCMessage}, or `undefined`
323
- * when it is not one — the per-event step {@link readEventStream} folds over.
324
- *
325
- * @remarks
326
- * `JSON.parse`s the `data` (the server serializes the JSON-RPC envelope as the event's
327
- * `data`) inside a try/catch and narrows the parsed value with `parseJSONRPCMessage`.
328
- * Total: malformed JSON or a non-message value yields `undefined`, never throws.
329
- *
330
- * @param data - One SSE event's `data` payload
331
- * @returns The decoded {@link JSONRPCMessage}, or `undefined`
332
- */
333
- function decodeEvent(data) {
334
- try {
335
- return parseJSONRPCMessage(JSON.parse(data));
336
- } catch {
337
- return;
338
- }
339
- }
340
- /**
341
217
  * Reads the path (without the query string) of a raw `node:http` protocol-upgrade request —
342
218
  * the `createWebSocketServer` upgrade-path match.
343
219
  *
344
220
  * @remarks
345
- * A `node:http` {@link import('node:http').IncomingMessage}'s `url` is the request TARGET
221
+ * A `node:http` {@link import('node:http').IncomingMessage}'s `url` is the request target
346
222
  * (`'/mcp?x=1'`), narrowed with `isString` (never `as`) and defaulting to `'/'` for an
347
223
  * absent target; it is parsed against a placeholder base (only the pathname matters for the upgrade
348
224
  * decision) and the `pathname` returned. The upgrade handler compares this against its
@@ -363,7 +239,7 @@ function upgradeRequestPath(request) {
363
239
  *
364
240
  * @remarks
365
241
  * Concatenates `buffer` (the carried-forward partial line from the previous call)
366
- * with `chunk`, splits on `'\n'`, and returns every COMPLETE line (a `'\r'` trailing
242
+ * with `chunk`, splits on `'\n'`, and returns every complete line (a `'\r'` trailing
367
243
  * a line, from a CRLF-framed peer, is trimmed) plus the final, possibly-empty
368
244
  * fragment as the new `remainder` — the caller threads it back in as the next call's
369
245
  * `buffer`. A chunk containing no `'\n'` yields no lines and the whole (buffer +
@@ -388,12 +264,12 @@ function extractLines(buffer, chunk) {
388
264
  * The completion callback is the writable channel's backpressure boundary. A callback error and
389
265
  * a synchronous `write` throw reject the returned promise with the original value.
390
266
  *
391
- * That callback is the ONLY thing that settles the promise: this helper holds no timer and no
267
+ * That callback is the only thing that settles the promise: this helper holds no timer and no
392
268
  * abort, so an output that neither confirms nor fails the write parks the promise for as long as
393
269
  * the caller-owned stream holds the callback. A caller wanting a bound races this promise against
394
270
  * one it owns — {@link import('./transports/StdioServerTransport.js').StdioServerTransport}
395
271
  * registers such a bound per send and rejects it on `close()`, so closing the transport settles
396
- * the CALLER's `send` while the abandoned write stays with the stream that still holds its
272
+ * the caller's `send` while the abandoned write stays with the stream that still holds its
397
273
  * callback, reachable from nothing the transport retains.
398
274
  *
399
275
  * @param output - The writable stream that receives the line
@@ -419,16 +295,17 @@ function writeLine(output, line) {
419
295
  }
420
296
  /**
421
297
  * Decodes and delivers each complete newline-framed line onto a {@link
422
- * MCPClientTransportEventMap} emitter — the shared per-chunk dispatch step both stdio
298
+ * MCPMessageTransportEventMap} emitter — the shared per-chunk dispatch step both stdio
423
299
  * transports run their framed lines through: the server transport frames with {@link
424
300
  * extractLines}, the client transport takes its lines from the process supervisor.
425
301
  *
426
302
  * @remarks
427
- * A blank line is skipped (a stray trailing newline). Every other line is decoded
428
- * with {@link decodeEvent} (`JSON.parse` + `parseJSONRPCMessage`, guarded); a
429
- * well-formed {@link JSONRPCMessage} emits `message`, a malformed / non-message line
430
- * emits `error` (total, never throws). Pure w.r.t. its own state the emit is
431
- * the caller-owned side effect.
303
+ * A blank line is skipped (a stray trailing newline). Every other line runs through the
304
+ * shared {@link import('@orkestrel/mcp').deliverMessage} fold, the one inbound decode every
305
+ * transport in this package shares: a well-formed {@link JSONRPCMessage} emits `message`,
306
+ * unparsable text emits the caught parse error, and a well-formed non-message line emits
307
+ * `error` naming a non-JSON-RPC stdio line (total, never throws). Pure w.r.t. its own state
308
+ * — the emit is the caller-owned side effect.
432
309
  *
433
310
  * @param emitter - The transport's {@link EmitterInterface} to emit `message` / `error` onto
434
311
  * @param lines - The complete lines to decode and deliver
@@ -436,93 +313,9 @@ function writeLine(output, line) {
436
313
  function dispatchLines(emitter, lines) {
437
314
  for (const line of lines) {
438
315
  if (line.length === 0) continue;
439
- const message = decodeEvent(line);
440
- if (message === void 0) {
441
- emitter.emit("error", /* @__PURE__ */ new Error("non-JSON-RPC stdio line"));
442
- continue;
443
- }
444
- emitter.emit("message", message);
316
+ deliverMessage(emitter, line, "non-JSON-RPC stdio line");
445
317
  }
446
318
  }
447
- /**
448
- * Bridges a message-channel {@link MCPClientTransportInterface} (the shape the stdio and
449
- * WebSocket SERVER transports already implement) into the environment-agnostic
450
- * {@link import('@orkestrel/mcp').MCPTransportInterface} port — the adapter
451
- * {@link import('./factories.js').createStdioServer} and {@link
452
- * import('./factories.js').createWebSocketServer} pipe through `bindServer`, so the
453
- * request/reply/error pump those factories used to hand-roll identically now
454
- * lives ONCE in the core binder.
455
- *
456
- * @remarks
457
- * `send` decodes the already-serialized reply string back to a {@link JSONRPCMessage}
458
- * and writes it through `transport.send` (the same `JSON.stringify` the underlying
459
- * transport already performs, so the wire bytes are unchanged). `listen` filters
460
- * `transport`'s `message` event to INVOCATIONS ONLY — requests and notifications, never a
461
- * stray response, exactly as the prior hand-rolled pumps did — and re-serializes each one
462
- * back to a string for `bindServer`. `closed` bridges `transport`'s `close` event. `close`
463
- * closes the underlying `transport`.
464
- *
465
- * @remarks A message crossing this bridge is decoded and re-encoded TWICE, and that is
466
- * ACCEPTED rather than accidental. Inbound: the carrier already parsed the frame into a
467
- * {@link JSONRPCMessage}, and `listen` re-serializes it so `bindServer` can decode it again
468
- * under the server's own `limit`. Outbound: `bindServer` serialized the reply, `send` parses
469
- * it back, and the carrier stringifies it once more. The cost is two extra `JSON.parse` /
470
- * `JSON.stringify` round trips per message, paid to keep ONE pump in the core binder instead
471
- * of a hand-rolled one per carrier. It is BOUNDED rather than unbounded because the binder
472
- * decodes within `server.limit.message`, so an oversized frame is refused before the second
473
- * decode rather than after it. Removing the cost means giving `MCPTransportInterface` a
474
- * message-shaped face beside its string one, which every transport would then carry.
475
- *
476
- * @remarks Per {@link import('@orkestrel/mcp').MCPTransportInterface}, `listen`/`closed`
477
- * each hold THE SINGLE current handler (a second call REPLACES the first, never adds).
478
- * Because the underlying `transport.emitter` is ADD-based (`on` subscribes, never
479
- * replaces), this bridge installs ONE stable emitter listener per event on first use
480
- * and re-routes it to whichever handler is active (`undefined` while
481
- * none is), so rebinding never double-dispatches.
482
- *
483
- * @remarks A response whose `result` serializes away (for example, `undefined`) is dropped by
484
- * the message validators on the wire's decode side — an asymmetry the stdio/WS carrier
485
- * shares with the streamable-HTTP face, because both round-trip through `JSON.stringify`
486
- * / `JSON.parse` before re-validation.
487
- *
488
- * @param transport - The message-channel transport to bridge (stdio or WebSocket)
489
- * @returns An {@link import('@orkestrel/mcp').MCPTransportInterface} `bindServer` can drive
490
- *
491
- * @example
492
- * ```ts
493
- * import { bindServer } from '@orkestrel/mcp'
494
- *
495
- * const transport = new StdioServerTransport(process.stdin, process.stdout)
496
- * bindServer(mcp, bridgeMessageTransport(transport))
497
- * ```
498
- */
499
- function bridgeMessageTransport(transport) {
500
- let onMessage;
501
- let onClosed;
502
- transport.emitter.on("message", (message) => {
503
- if (!isJSONRPCInvocation(message)) return;
504
- onMessage?.(JSON.stringify(message));
505
- });
506
- transport.emitter.on("close", () => {
507
- onClosed?.();
508
- });
509
- return {
510
- async send(message) {
511
- const decoded = decodeEvent(message);
512
- if (decoded === void 0) return;
513
- await transport.send(decoded);
514
- },
515
- listen(handler) {
516
- onMessage = handler;
517
- },
518
- closed(handler) {
519
- onClosed = handler;
520
- },
521
- async close() {
522
- await transport.close();
523
- }
524
- };
525
- }
526
319
  //#endregion
527
320
  //#region src/server/inferers.ts
528
321
  /**
@@ -559,7 +352,7 @@ function inferHeaderTarget(request) {
559
352
  }
560
353
  }
561
354
  /**
562
- * Infers the first required MCP HTTP header that is missing or mismatched.
355
+ * Infers the first required MCP HTTP header a request's own body contradicts.
563
356
  *
564
357
  * @remarks
565
358
  * A modern request derives its protocol, method, and name expectations from the JSON-RPC body,
@@ -568,12 +361,14 @@ function inferHeaderTarget(request) {
568
361
  * {@link import('@orkestrel/mcp').decodeSentinel} before the comparison, so a peer that had
569
362
  * to encode its value still matches; a sentinel whose payload is invalid decodes to nothing
570
363
  * and therefore mismatches, which is how an invalid header value is refused. A legacy request
571
- * body requires a protocol header after initialization, while a supplied legacy session
572
- * version additionally diagnoses a header that disagrees with the active session. Messages
573
- * name the expected value but never echo the client-supplied one.
364
+ * body requires a protocol header after initialization. Messages name the expected value but
365
+ * never echo the client-supplied one.
366
+ *
367
+ * The expectation a live session supplies is a different rule over a different input, so it
368
+ * is {@link inferSessionHeaderIssue} rather than a second arm of this one.
574
369
  *
575
370
  * @param request - The HTTP request carrying the headers
576
- * @param reference - The parsed invocation body, or the active legacy session version
371
+ * @param invocation - The parsed invocation body the expectations are derived from
577
372
  * @returns The first header issue, or `undefined` when the applicable headers agree
578
373
  *
579
374
  * @example
@@ -582,30 +377,17 @@ function inferHeaderTarget(request) {
582
377
  * issue?.header // 'Mcp-Method' when that field is absent or mismatched
583
378
  * ```
584
379
  */
585
- function inferHeaderIssue(request, reference) {
380
+ function inferHeaderIssue(request, invocation) {
586
381
  const protocol = request.headers.get(MCP_PROTOCOL_VERSION_HEADER);
587
- if (isString(reference)) {
588
- if (protocol === null) return {
589
- header: "MCP-Protocol-Version",
590
- reason: "missing",
591
- message: `Required MCP-Protocol-Version header is missing; the active session uses '${reference}'.`
592
- };
593
- if (protocol !== reference) return {
594
- header: "MCP-Protocol-Version",
595
- reason: "mismatched",
596
- message: `MCP-Protocol-Version header does not match the active session version '${reference}'.`
597
- };
598
- return;
599
- }
600
- if (!isModernRequest(reference)) {
601
- if (isInitializeRequest(reference) || protocol !== null) return void 0;
382
+ if (!isModernRequest(invocation)) {
383
+ if (isInitializeRequest(invocation) || protocol !== null) return void 0;
602
384
  return {
603
385
  header: "MCP-Protocol-Version",
604
386
  reason: "missing",
605
387
  message: `Required MCP-Protocol-Version header is missing; this server offers '${MCP_HANDSHAKE_VERSION}'.`
606
388
  };
607
389
  }
608
- const message = reference;
390
+ const message = invocation;
609
391
  const version = (isRecord(message.params?.["_meta"]) ? message.params["_meta"] : void 0)?.[MCP_META_VERSION];
610
392
  if (!isString(version)) return void 0;
611
393
  if (protocol === null) return {
@@ -644,12 +426,46 @@ function inferHeaderIssue(request, reference) {
644
426
  };
645
427
  }
646
428
  /**
429
+ * Infers the protocol header issue an active legacy session's pinned revision diagnoses.
430
+ *
431
+ * @remarks
432
+ * The session layer's rule, distinct from the body-derived one {@link inferHeaderIssue} owns:
433
+ * a live legacy session pinned its revision at `initialize`, so every later request on that
434
+ * session must name the same one. An absent header reads as `missing`, which the session
435
+ * middleware answers by supplying the pinned revision rather than refusing; a present header
436
+ * naming another revision reads as `mismatched` and is refused. The message names the session's
437
+ * revision and never echoes the client-supplied value.
438
+ *
439
+ * @param request - The HTTP request carrying the headers
440
+ * @param version - The legacy revision the active session pinned at `initialize`
441
+ * @returns The protocol header issue, or `undefined` when the header agrees
442
+ *
443
+ * @example
444
+ * ```ts
445
+ * const issue = inferSessionHeaderIssue(request, '2025-06-18')
446
+ * issue?.reason // 'missing' when the request carries no protocol header
447
+ * ```
448
+ */
449
+ function inferSessionHeaderIssue(request, version) {
450
+ const protocol = request.headers.get(MCP_PROTOCOL_VERSION_HEADER);
451
+ if (protocol === null) return {
452
+ header: "MCP-Protocol-Version",
453
+ reason: "missing",
454
+ message: `Required MCP-Protocol-Version header is missing; the active session uses '${version}'.`
455
+ };
456
+ if (protocol !== version) return {
457
+ header: "MCP-Protocol-Version",
458
+ reason: "mismatched",
459
+ message: `MCP-Protocol-Version header does not match the active session version '${version}'.`
460
+ };
461
+ }
462
+ /**
647
463
  * Infers the refusal one `tools/call` earns for a `Mcp-Param-*` header the body contradicts.
648
464
  *
649
465
  * @remarks
650
466
  * The custom-header half of the standard-header seam {@link inferHeaderIssue} owns, and it
651
- * takes the SERVED definition's projections rather than a header issue: SEP-2243 scopes the
652
- * rule to the `Mcp-Param-*` names the server's OWN tool definitions annotate, so a name no
467
+ * takes the served definition's projections rather than a header issue: SEP-2243 scopes the
468
+ * rule to the `Mcp-Param-*` names the server's own tool definitions annotate, so a name no
653
469
  * parameter claims is another party's header and travels through untouched.
654
470
  *
655
471
  * For each recognized parameter the body's value at the parameter's own property path fixes
@@ -700,7 +516,7 @@ function inferParameterRefusal(request, parameters, values) {
700
516
  *
701
517
  * @remarks
702
518
  * A supported legacy request is pinned exactly. A modern, malformed, absent, or unsupported
703
- * request selects the newest supported legacy revision. The read is deliberately the SAME one
519
+ * request selects the newest supported legacy revision. The read is deliberately the same one
704
520
  * {@link import('@orkestrel/mcp').buildInitializeResult} performs — `isMCPLegacyVersion` over
705
521
  * the requested revision — because the session version this pins and the version that result
706
522
  * echoes must be the one value. Routing through `inferVersion` cannot do it: that inferer is
@@ -736,27 +552,27 @@ function inferStatus(response, era) {
736
552
  return 200;
737
553
  }
738
554
  //#endregion
739
- //#region src/server/transports/HTTPDisconnect.ts
555
+ //#region src/server/HTTPDisconnect.ts
740
556
  /**
741
557
  * Composes one incoming HTTP request lifetime with one MCP-owned SSE response lifetime.
742
558
  *
743
559
  * @remarks
744
- * The composed {@link signal} observes request abort and EVERY way this response can end
560
+ * The composed {@link signal} observes request abort and every way this response can end
745
561
  * without one: consumer cancellation of the bridged body, a forwarding failure mid-pump, and a
746
562
  * keepalive tick that finds the SSE stream already closed. That last pair is the whole point of
747
563
  * the composition — a client that vanishes mid-stream aborts nothing by itself, so unless this
748
564
  * object raises the signal on its own failure paths, the handler, the controlled stream, and
749
565
  * the producer behind them all keep running for a response that can no longer be written.
750
- * Graceful upstream completion is the one terminal that does NOT abort: the body simply closes,
566
+ * Graceful upstream completion is the one terminal that does not abort: the body closes,
751
567
  * because the exchange finished rather than ended.
752
568
  *
753
569
  * {@link bridge} preserves the source response status and headers, forwards its body bytes, and
754
570
  * owns keepalive comments plus listener/timer cleanup until upstream completion, request abort,
755
571
  * or consumer cancellation. This is a single-response lifecycle object, not a reusable bridge:
756
- * a second {@link bridge} call THROWS rather than arming a second keepalive over one lifecycle.
572
+ * a second {@link bridge} call throws rather than arming a second keepalive over one lifecycle.
757
573
  * It supplies no handler or session policy.
758
574
  *
759
- * The keepalive interval is a BUDGET, sanitized like every other numeric knob in this package:
575
+ * The keepalive interval is a budget, sanitized like every other numeric knob in this package:
760
576
  * anything that is not a positive integer — `0`, a negative, a fractional value, `NaN`,
761
577
  * `Infinity` — falls back to {@link DEFAULT_MCP_KEEPALIVE_INTERVAL}, and a larger value clamps
762
578
  * to Node's `2_147_483_647` ms timer maximum. None may reach the platform's timer floor, where
@@ -765,10 +581,10 @@ function inferStatus(response, era) {
765
581
  * @example
766
582
  * ```ts
767
583
  * import { HTTPDisconnect } from '@orkestrel/mcp/server'
768
- * import { openStream } from '@orkestrel/server'
584
+ * import { createStream } from '@orkestrel/server'
769
585
  *
770
586
  * const disconnect = new HTTPDisconnect(request.signal, { interval: 15_000 })
771
- * const stream = openStream()
587
+ * const stream = createStream()
772
588
  * const response = disconnect.bridge(stream)
773
589
  * ```
774
590
  */
@@ -777,6 +593,9 @@ var HTTPDisconnect = class {
777
593
  #lifecycle = new AbortController();
778
594
  #interval;
779
595
  #signal;
596
+ #pull = (controller) => this.#pump(controller);
597
+ #cancel = (reason) => this.#discard(reason);
598
+ #reader;
780
599
  #timer;
781
600
  #bridged = false;
782
601
  #pulling = false;
@@ -793,8 +612,8 @@ var HTTPDisconnect = class {
793
612
  this.#signal = AbortSignal.any([signal, this.#response.signal]);
794
613
  }
795
614
  /**
796
- * The signal aborted by the incoming request, or by any end of this response that is not
797
- * its graceful completion.
615
+ * Returns the signal aborted by the incoming request, or by any end of this response that
616
+ * is not its graceful completion.
798
617
  *
799
618
  * @returns The composed lifecycle signal
800
619
  */
@@ -820,7 +639,7 @@ var HTTPDisconnect = class {
820
639
  const response = stream.response;
821
640
  const body = response.body;
822
641
  if (body === null) throw new Error("MCP SSE response has no body");
823
- const reader = body.getReader();
642
+ this.#reader = body.getReader();
824
643
  this.#timer = setInterval(() => {
825
644
  if (stream.closed) {
826
645
  if (!this.#pulling) this.#abort();
@@ -832,29 +651,39 @@ var HTTPDisconnect = class {
832
651
  });
833
652
  if (this.#signal.aborted) this.#release();
834
653
  else if (stream.closed) this.#abort();
835
- return new Response(createReadableStream(async (controller) => {
836
- this.#pulling = true;
837
- try {
838
- const chunk = await reader.read();
839
- if (chunk.done) {
840
- this.#release();
841
- controller.close();
842
- } else controller.enqueue(chunk.value);
843
- } catch (error) {
844
- this.#abort();
845
- controller.error(error);
846
- } finally {
847
- this.#pulling = false;
848
- }
849
- }, async (reason) => {
850
- this.#abort();
851
- await reader.cancel(reason);
654
+ return new Response(new ReadableStream({
655
+ pull: this.#pull,
656
+ cancel: this.#cancel
852
657
  }), {
853
658
  status: response.status,
854
659
  statusText: response.statusText,
855
660
  headers: response.headers
856
661
  });
857
662
  }
663
+ async #pump(controller) {
664
+ const reader = this.#reader;
665
+ if (reader === void 0) {
666
+ controller.close();
667
+ return;
668
+ }
669
+ this.#pulling = true;
670
+ try {
671
+ const chunk = await reader.read();
672
+ if (chunk.done) {
673
+ this.#release();
674
+ controller.close();
675
+ } else controller.enqueue(chunk.value);
676
+ } catch (error) {
677
+ this.#abort();
678
+ controller.error(error);
679
+ } finally {
680
+ this.#pulling = false;
681
+ }
682
+ }
683
+ async #discard(reason) {
684
+ this.#abort();
685
+ await this.#reader?.cancel(reason);
686
+ }
858
687
  #release() {
859
688
  if (this.#timer !== void 0) {
860
689
  clearInterval(this.#timer);
@@ -877,7 +706,7 @@ var HTTPDisconnect = class {
877
706
  * method carrying a named target — `tools/call` and `prompts/get` against `params.name`,
878
707
  * `resources/read` against `params.uri` — with a Base64-sentinel value decoded before the
879
708
  * comparison; a missing, mismatched, or invalidly encoded value returns HTTP `400` + `-32020`.
880
- * A protocol header naming a MODERN revision holds the request to that revision whatever shape
709
+ * A protocol header naming a modern revision holds the request to that revision whatever shape
881
710
  * its body arrived in, so a body with no parsable modern `_meta` returns HTTP `400` + `-32602`.
882
711
  * Headerless `initialize` is accepted, while every other headerless request needs a live legacy
883
712
  * session to supply its pinned version. A legacy-shaped request carrying a protocol header is
@@ -921,15 +750,11 @@ function createMCPPostHandler(mcp, options) {
921
750
  } catch {
922
751
  return Response.json(buildJSONRPCError(void 0, JSONRPC_PARSE_ERROR, "Parse error"), { status: 400 });
923
752
  }
924
- let parsed;
925
- try {
926
- parsed = JSON.parse(text);
927
- } catch {
928
- return Response.json(buildJSONRPCError(void 0, JSONRPC_PARSE_ERROR, "Parse error"), { status: 400 });
929
- }
753
+ const parsed = parseJSON(text);
754
+ if (parsed === void 0) return Response.json(buildJSONRPCError(void 0, JSONRPC_PARSE_ERROR, "Parse error"), { status: 400 });
930
755
  const invocation = parseJSONRPCMessage(parsed);
931
756
  if (invocation === void 0 || !("method" in invocation)) return Response.json(buildJSONRPCError(void 0, JSONRPC_INVALID_REQUEST, "Invalid Request"), { status: 400 });
932
- const era = isModernRequest(invocation) ? "modern" : "legacy";
757
+ const era = inferRequestEra(invocation);
933
758
  const id = invocation.id;
934
759
  const protocol = request.headers.get(MCP_PROTOCOL_VERSION_HEADER);
935
760
  if (era === "modern" || isMCPModernVersion(protocol)) {
@@ -981,7 +806,7 @@ function createMCPPostHandler(mcp, options) {
981
806
  ...caller === void 0 ? {} : { caller }
982
807
  });
983
808
  if (response !== void 0 && Symbol.asyncIterator in response) {
984
- const stream = openStream();
809
+ const stream = createStream();
985
810
  stream.response.headers.set(SSE_BUFFERING_HEADER, "no");
986
811
  queueMicrotask(() => void sendEventStream(response, stream));
987
812
  return disconnect.bridge(stream);
@@ -989,7 +814,7 @@ function createMCPPostHandler(mcp, options) {
989
814
  const status = inferStatus(response, era);
990
815
  if (response === void 0) return new Response(null, { status });
991
816
  if (status === 200 && streaming && acceptsEventStream(request)) {
992
- const stream = openStream();
817
+ const stream = createStream();
993
818
  stream.response.headers.set(SSE_BUFFERING_HEADER, "no");
994
819
  stream.write({ data: JSON.stringify(response) });
995
820
  stream.end();
@@ -999,244 +824,43 @@ function createMCPPostHandler(mcp, options) {
999
824
  };
1000
825
  }
1001
826
  //#endregion
1002
- //#region src/server/transports/HTTPClientTransport.ts
1003
- /**
1004
- * The HTTP CLIENT transport for the Model Context Protocol — a
1005
- * {@link MCPClientTransportInterface} that drives a REMOTE Streamable-HTTP MCP server over
1006
- * `fetch`, the egress mirror of the server's `createMCPRoutes`.
1007
- *
1008
- * @remarks
1009
- * - **Request/response over `fetch`.** `send(message)` POSTs the JSON-serialized
1010
- * message to `options.url` with `content-type: application/json` and an
1011
- * `Accept` of BOTH `application/json` and `text/event-stream` (so the server may
1012
- * answer with either framing) — plus any `options.headers` (for example, an `Authorization`
1013
- * bearer). It then decodes the reply and emits each decoded {@link JSONRPCMessage} on
1014
- * the `message` event the {@link import('@orkestrel/mcp').MCPClientInterface} subscribes
1015
- * to.
1016
- * - **Both reply framings.** A `200` with an `application/json` body is parsed with
1017
- * `parseJSONRPCMessage`; a `200` with a `text/event-stream` body is decoded with the
1018
- * `@orkestrel/sse` {@link import('@orkestrel/sse').SSEParserInterface} ({@link
1019
- * readEventStream}) — the inverse of the server's `openStream` seam, so the wire
1020
- * round-trips. A `202`
1021
- * Accepted (a notification) carries no body and emits nothing.
1022
- * - **Session and protocol headers.** `start()` is a no-op (a
1023
- * request/response transport opens no long-lived connection). The
1024
- * `mcp-session-id` response header, when a STATEFUL server sends one (on
1025
- * `initialize`), is captured into `session` and then ECHOED as the
1026
- * `mcp-session-id` request header on every SUBSEQUENT request — so an
1027
- * `MCPClient` passes a stateful server's session validation. The
1028
- * initialize result's `protocolVersion` is likewise captured, but only
1029
- * when it is a SUPPORTED value, and echoed as `mcp-protocol-version` alone on
1030
- * subsequent legacy requests. Modern requests instead derive protocol and method
1031
- * headers from the message, plus the name header only for `tools/call` — carried in the
1032
- * protocol's Base64 sentinel form whenever the tool name cannot ride as plain ASCII.
1033
- * Before initialize returns, neither captured legacy header is sent.
1034
- * `close()` clears the captured protocol so a reconnect's `initialize`
1035
- * POST is headerless; the captured `session` persists across `close()`.
1036
- * - **`close()` releases what is in flight.** Every `fetch` this transport still has open is
1037
- * ABORTED, which cancels the response body a `send` is reading — an SSE reply the server
1038
- * never ends would otherwise outlive the transport, with nothing left able to reach it. The
1039
- * aborted read surfaces on `error` and the `send` reporting it resolves. `close()` is
1040
- * idempotent (one `close` event per connected lifetime), and `start()` opens the next one.
1041
- * - **Total at the boundary.** Every reply is narrowed (`parseJSONRPCMessage`,
1042
- * the SSE decoder). A non-message success reply is dropped, never asserted. A non-success
1043
- * reply that carries no valid JSON-RPC message rejects `send` with its HTTP status and body
1044
- * shape. A valid JSON-RPC error body is emitted at any HTTP status. A `fetch` / decode failure
1045
- * on a success response surfaces on the `error` event rather than escaping `send`.
1046
- * - **Observable.** Owns the `emitter` ({@link MCPClientTransportEventMap}); fires
1047
- * `message` per decoded reply, `error` on a fault, and `close` on `close()`.
1048
- *
1049
- * @example
1050
- * ```ts
1051
- * const transport = new HTTPClientTransport({ url: 'http://localhost:3000/mcp' })
1052
- * const client = new MCPClient({ transport })
1053
- * await client.connect()
1054
- * ```
1055
- */
1056
- var HTTPClientTransport = class {
1057
- #emitter;
1058
- #url;
1059
- #headers;
1060
- #fetch;
1061
- #timeout;
1062
- #pending = /* @__PURE__ */ new Set();
1063
- #parameters = /* @__PURE__ */ new Map();
1064
- #stamps = /* @__PURE__ */ new WeakMap();
1065
- #session = void 0;
1066
- #protocol = void 0;
1067
- #generation = 0;
1068
- #closed = false;
1069
- constructor(options) {
1070
- this.#emitter = new Emitter();
1071
- this.#url = options.url;
1072
- this.#headers = options.headers ?? {};
1073
- this.#fetch = options.fetch ?? globalThis.fetch;
1074
- this.#timeout = options.timeout;
1075
- }
1076
- get emitter() {
1077
- return this.#emitter;
1078
- }
1079
- get session() {
1080
- return this.#session;
1081
- }
1082
- get duplex() {
1083
- return false;
1084
- }
1085
- async start() {
1086
- this.#closed = false;
1087
- }
1088
- async send(message) {
1089
- this.#stamp(message);
1090
- const request = new AbortController();
1091
- this.#pending.add(request);
1092
- try {
1093
- await this.#exchange(message, request.signal);
1094
- } finally {
1095
- this.#pending.delete(request);
1096
- }
1097
- }
1098
- #stamp(message) {
1099
- if (!isModernRequest(message) || message.method !== "tools/list") return;
1100
- if (message.params?.["cursor"] === void 0) this.#generation += 1;
1101
- this.#stamps.set(message, this.#generation);
1102
- }
1103
- async #exchange(message, signal) {
1104
- let response;
1105
- try {
1106
- response = await this.#fetch(this.#url, {
1107
- method: "POST",
1108
- headers: {
1109
- "content-type": "application/json",
1110
- accept: "application/json, text/event-stream",
1111
- ...this.#session === void 0 ? {} : { [MCP_SESSION_HEADER]: this.#session },
1112
- ...this.#buildHeaders(message),
1113
- ...this.#headers
1114
- },
1115
- body: JSON.stringify(message),
1116
- signal: this.#timeout === void 0 ? signal : AbortSignal.any([signal, AbortSignal.timeout(this.#timeout)])
1117
- });
1118
- } catch (error) {
1119
- this.#emitter.emit("error", error);
1120
- return;
1121
- }
1122
- const session = response.headers.get(MCP_SESSION_HEADER);
1123
- if (session !== null) this.#session = session;
1124
- await this.#deliver(response, message);
1125
- }
1126
- async close() {
1127
- if (this.#closed) return;
1128
- this.#closed = true;
1129
- for (const request of this.#pending) request.abort();
1130
- this.#pending.clear();
1131
- this.#protocol = void 0;
1132
- this.#emitter.emit("close");
1133
- }
1134
- #buildHeaders(message) {
1135
- if (isModernRequest(message)) {
1136
- const version = inferRequestVersion(message);
1137
- const name = message.params?.["name"];
1138
- return {
1139
- ...version === void 0 ? {} : { [MCP_PROTOCOL_VERSION_HEADER]: version },
1140
- [MCP_METHOD_HEADER]: message.method,
1141
- ...message.method === "tools/call" && isString(name) ? {
1142
- [MCP_NAME_HEADER]: encodeSentinel(name),
1143
- ...buildHeaderProjection(this.#parameters.get(name) ?? [], message.params?.["arguments"])
1144
- } : {}
1145
- };
1146
- }
1147
- return this.#protocol === void 0 ? {} : { [MCP_PROTOCOL_VERSION_HEADER]: this.#protocol };
1148
- }
1149
- async #deliver(response, sent) {
1150
- if (response.status === 202) return;
1151
- const type = response.headers.get("content-type") ?? "";
1152
- let messages = [];
1153
- let failure;
1154
- try {
1155
- if (type.includes("text/event-stream")) messages = await readEventStream(response);
1156
- else if (type.includes("application/json")) {
1157
- const message = parseJSONRPCMessage(await response.json());
1158
- if (message !== void 0) messages = [message];
1159
- }
1160
- } catch (error) {
1161
- failure = { error };
1162
- }
1163
- for (const message of messages) this.#capture(message, sent);
1164
- if (!response.ok && messages.length === 0) throw buildResponseError(response, type);
1165
- if (failure !== void 0) this.#emitter.emit("error", failure.error);
1166
- }
1167
- #capture(message, sent) {
1168
- if (isJSONRPCResponse(message) && isRecord(message.result) && isMCPVersion(message.result["protocolVersion"])) this.#protocol = message.result["protocolVersion"];
1169
- this.#emitter.emit("message", this.#select(message, sent));
1170
- }
1171
- #select(message, sent) {
1172
- if (!isModernRequest(sent) || sent.method !== "tools/list") return message;
1173
- if (!isJSONRPCResponse(message) || message.error !== void 0) return message;
1174
- const result = message.result;
1175
- const listed = isRecord(result) ? result["tools"] : void 0;
1176
- if (!isRecord(result) || !isArray(listed)) return message;
1177
- const current = this.#stamps.get(sent) === this.#generation;
1178
- if (current && sent.params?.["cursor"] === void 0) this.#parameters.clear();
1179
- const kept = [];
1180
- for (const tool of listed) {
1181
- if (!isRecord(tool) || !isString(tool["name"])) {
1182
- kept.push(tool);
1183
- continue;
1184
- }
1185
- const parameters = buildHeaderParameters(tool["inputSchema"]);
1186
- if (parameters === void 0) {
1187
- this.#emitter.emit("error", /* @__PURE__ */ new Error(`MCP tool '${tool["name"]}' is excluded from tools/list: its inputSchema carries an invalid x-mcp-header annotation`));
1188
- continue;
1189
- }
1190
- if (current) this.#parameters.set(tool["name"], parameters);
1191
- kept.push(tool);
1192
- }
1193
- return {
1194
- ...message,
1195
- result: {
1196
- ...result,
1197
- tools: kept
1198
- }
1199
- };
1200
- }
1201
- };
1202
- //#endregion
1203
827
  //#region src/server/MCPSession.ts
1204
828
  /**
1205
- * One MCP transport session — the per-session entity a {@link
829
+ * Represents one MCP transport session — the per-session entity a {@link
1206
830
  * import('./middlewares.js').createMCPSession} middleware owns, keyed by its `id`, carrying the
1207
- * resumable server→client push channel with its bounded replay log FOLDED IN.
831
+ * resumable server→client push channel with its bounded replay log folded in.
1208
832
  *
1209
833
  * @remarks
1210
- * The single session entity (the old `SessionState` + `EventStore` merged): it holds the
1211
- * session `id`, its OWN bounded, replayable log of pushed server→client messages (the
834
+ * One entity carries the whole session: it holds the
835
+ * session `id`, its own bounded, replayable log of pushed server→client messages (the
1212
836
  * resumable GET-SSE channel — a private `#events` `Map` + a monotone `#counter`, with
1213
837
  * `capacity` / `ttl` eviction, not a separate store), and the set of open
1214
838
  * server→client SSE streams (a resumable `GET {path}` registers through `attach`, unregisters through
1215
839
  * `detach` on disconnect). Still a small entity (not a record), built minimal + extensible.
1216
840
  *
1217
- * - **`push` is the server-initiated primitive.** It APPENDS the message to the log (assigning
1218
- * a monotone base36 event id) and FANS it out to every attached stream as one `id:`-tagged
1219
- * SSE event (`stream.write({ id, data })`). A push with NO attached stream is still logged,
1220
- * so a client that connects (or reconnects with a `Last-Event-ID`) LATER replays it from the
841
+ * - **`push` is the server-initiated primitive.** It appends the message to the log (assigning
842
+ * a monotone base36 event id) and fans it out to every attached stream as one `id:`-tagged
843
+ * SSE event (`stream.write({ id, data })`). A push with no attached stream is still logged,
844
+ * so a client that connects (or reconnects with a `Last-Event-ID`) later replays it from the
1221
845
  * log. A `write` to a closed stream is a safe no-op (the {@link
1222
- * `@orkestrel/server`'s `openStream` contract), so a just-disconnected stream that
846
+ * `@orkestrel/server`'s `createStream` contract), so a just-disconnected stream that
1223
847
  * has not yet been `detach`ed never throws. A replayed event and the live one carry the
1224
- * IDENTICAL id (the log assigns it once).
848
+ * identical id (the log assigns it once).
1225
849
  *
1226
850
  * - **`replay(afterId)` is strictly-after.** It returns every retained log entry whose id sorts
1227
- * AFTER `afterId` in append order — the missed-events list the `GET {path}` handler writes
1228
- * before attaching the stream for live pushes. The decision for an UNKNOWN / already-evicted
851
+ * after `afterId` in append order — the missed-events list the `GET {path}` handler writes
852
+ * before attaching the stream for live pushes. The decision for an unknown / already-evicted
1229
853
  * `afterId` (the client's cursor fell off the back of the capacity window, or never existed):
1230
- * replay NOTHING. Replaying the whole retained log would re-deliver events the client never
1231
- * lost (its cursor is OLDER than everything retained); returning `[]` lets the handler then
854
+ * replay nothing. Replaying the whole retained log would re-deliver events the client never
855
+ * lost (its cursor is older than everything retained); returning `[]` lets the handler then
1232
856
  * stream only the fresh pushes that follow `attach` — the spec-sane resume.
1233
857
  *
1234
- * - **Bounded, append-ordered, plain `Map`.** The log lives in ONE insertion-ordered
1235
- * `Map<id, entry>` — insertion order IS append order IS id order, so `replay` and capacity
1236
- * eviction both walk the map directly. NO database mirror — the log is process-local
858
+ * - **Bounded, append-ordered, plain `Map`.** The log lives in one insertion-ordered
859
+ * `Map<id, entry>` — insertion order is append order is id order, so `replay` and capacity
860
+ * eviction both walk the map directly. No database mirror — the log is process-local
1237
861
  * transport mechanics, not durable state. `push` first drops every entry older than `ttl`
1238
862
  * (lazy TTL — no background timer, the middleware's lazy-window idiom), appends, then evicts
1239
- * the OLDEST entries until at most `capacity` remain; `replay` also runs the lazy TTL sweep
863
+ * the oldest entries until at most `capacity` remain; `replay` also runs the lazy TTL sweep
1240
864
  * first, so a stale entry is never replayed.
1241
865
  *
1242
866
  * - **No transport coupling beyond the SSE seam.** It holds session state + the generic {@link
@@ -1244,9 +868,10 @@ var HTTPClientTransport = class {
1244
868
  * The middleware opens the stream (the spine seam) and registers it here; this class only
1245
869
  * serializes a message onto the already-open streams.
1246
870
  *
1247
- * - **Injected clock.** `push` / `replay` accept an optional `now` (epoch ms), defaulting to
1248
- * `Date.now()` — so a test drives TTL eviction with an elapsed clock rather than a real
1249
- * timer.
871
+ * - **Injected clock.** {@link import('./types.js').MCPSessionOptions.clock} supplies the
872
+ * epoch-ms clock the lazy TTL sweep reads, defaulting to `Date.now` — so a test drives TTL
873
+ * eviction with an elapsed clock rather than a real timer, and the middleware that mints a
874
+ * session hands its own clock down instead of leaving the log on wall-clock time.
1250
875
  *
1251
876
  * @example
1252
877
  * ```ts
@@ -1262,11 +887,13 @@ var MCPSession = class {
1262
887
  #streams = /* @__PURE__ */ new Set();
1263
888
  #capacity;
1264
889
  #ttl;
890
+ #clock;
1265
891
  #counter = 0;
1266
892
  constructor(id, options) {
1267
893
  this.#id = id;
1268
894
  this.#capacity = options?.capacity ?? 1024;
1269
895
  this.#ttl = options?.ttl ?? 3e5;
896
+ this.#clock = options?.clock ?? Date.now;
1270
897
  }
1271
898
  get id() {
1272
899
  return this.#id;
@@ -1277,8 +904,8 @@ var MCPSession = class {
1277
904
  detach(stream) {
1278
905
  this.#streams.delete(stream);
1279
906
  }
1280
- push(message, now = Date.now()) {
1281
- const id = this.#append(message, now);
907
+ push(message) {
908
+ const id = this.#append(message);
1282
909
  const data = JSON.stringify(message);
1283
910
  for (const stream of this.#streams) stream.write({
1284
911
  id,
@@ -1286,15 +913,16 @@ var MCPSession = class {
1286
913
  });
1287
914
  return id;
1288
915
  }
1289
- replay(afterId, now = Date.now()) {
1290
- this.#evict(now);
916
+ replay(afterId) {
917
+ this.#evict(this.#clock());
1291
918
  const out = [];
1292
919
  let found = false;
1293
920
  for (const entry of this.#events.values()) if (found) out.push(entry);
1294
921
  else if (entry.id === afterId) found = true;
1295
922
  return found ? out : [];
1296
923
  }
1297
- #append(message, now) {
924
+ #append(message) {
925
+ const now = this.#clock();
1298
926
  this.#evict(now);
1299
927
  this.#counter += 1;
1300
928
  const id = this.#counter.toString(36);
@@ -1320,30 +948,30 @@ var MCPSession = class {
1320
948
  //#endregion
1321
949
  //#region src/server/transports/WebSocketServerTransport.ts
1322
950
  /**
1323
- * The per-connection JSON-RPC-over-WebSocket SERVER bridge wraps a
1324
- * {@link NodeWebSocketInterface} (the RFC 6455 wire wrapper) as a
1325
- * {@link MCPClientTransportInterface}, the bidirectional JSON-RPC message channel
951
+ * Wraps a {@link NodeWebSocketInterface} (the RFC 6455 wire wrapper) as a
952
+ * {@link MCPMessageTransportInterface} the per-connection JSON-RPC-over-WebSocket server
953
+ * bridge, the bidirectional JSON-RPC message channel
1326
954
  * `createWebSocketServer` pumps `mcp.dispatch` over and the egress mirror's
1327
955
  * {@link import('./WebSocketClientTransport.js').WebSocketClientTransport} reuses.
1328
956
  *
1329
957
  * @remarks
1330
- * - **Reuses `MCPClientTransportInterface`.** It IS the same generic carrier the HTTP
958
+ * - **Reuses `MCPMessageTransportInterface`.** It is the same generic carrier the HTTP
1331
959
  * client transport implements — `emitter` (`message` / `close` / `error`), `start`,
1332
- * `send`, `close` — so the WebSocket server and client both speak ONE transport contract,
960
+ * `send`, `close` — so the WebSocket server and client both speak one transport contract,
1333
961
  * no near-duplicate sibling interface. `session` is `undefined` (the stateless v1; a
1334
962
  * session id is the deferred sessions tier). The name keeps the role explicit even though
1335
963
  * the shape is shared.
1336
964
  * - **Inbound (`message`).** `start()` subscribes to the socket's `message` event; each text
1337
- * frame is `JSON.parse`d inside a try/catch and narrowed with `parseJSONRPCMessage` — a
965
+ * frame runs through the shared `deliverMessage` fold (parse, then narrow) — a
1338
966
  * well-formed {@link JSONRPCMessage} is re-emitted on this transport's `message` event (the
1339
967
  * parsed envelope the {@link import('@orkestrel/mcp').MCPServerInterface} pump dispatches), while
1340
- * a non-JSON or non-message frame is surfaced on `error` and DROPPED, never thrown. It
968
+ * a non-JSON or non-message frame is surfaced on `error` and dropped, never thrown. It
1341
969
  * also bridges the socket's `close` → this transport's `close`, and the socket's `error`.
1342
970
  * - **Outbound (`send`).** `send(message)` writes one text frame
1343
971
  * (`nodeWs.send(JSON.stringify(message))`). The underlying wrapper no-ops a write on a
1344
972
  * non-open socket and confirms nothing, so this bridge answers a closed channel from its own
1345
973
  * state and the socket's `readyState`: a `send` after `close()`, after the peer's close, or on
1346
- * a socket that is not `OPEN` REJECTS with `WebSocket transport is not connected` rather than
974
+ * a socket that is not `OPEN` rejects with `WebSocket transport is not connected` rather than
1347
975
  * resolving on a frame nobody wrote. `bindServer` catches that rejection and routes it to the
1348
976
  * dispatcher's `error` event, and it aborts every in-flight request the moment this transport's
1349
977
  * `close` fires — so a peer that disconnects mid-request is answered by no write at all.
@@ -1352,9 +980,9 @@ var MCPSession = class {
1352
980
  * (idempotent — a second `close`, or a socket-driven close, emits once). A frame that arrives
1353
981
  * between that release and the peer's close echo reaches nothing: the socket-driven close path
1354
982
  * releases the same way, so a closed transport is never subscribed to a live socket.
1355
- * - **Observable.** Owns the `emitter` ({@link MCPClientTransportEventMap}); the emitter
983
+ * - **Observable.** Owns the `emitter` ({@link MCPMessageTransportEventMap}); the emitter
1356
984
  * isolates a listener throw (a buggy observer never corrupts the bridge). `error` is a
1357
- * DOMAIN event (a transport-level fault), distinct from the emitter's listener-error channel.
985
+ * domain event (a transport-level fault), distinct from the emitter's listener-error channel.
1358
986
  */
1359
987
  var WebSocketServerTransport = class {
1360
988
  #emitter;
@@ -1394,19 +1022,7 @@ var WebSocketServerTransport = class {
1394
1022
  this.#emitter.emit("close");
1395
1023
  }
1396
1024
  #receive(text) {
1397
- let parsed;
1398
- try {
1399
- parsed = JSON.parse(text);
1400
- } catch (error) {
1401
- this.#emitter.emit("error", error);
1402
- return;
1403
- }
1404
- const message = parseJSONRPCMessage(parsed);
1405
- if (message === void 0) {
1406
- this.#emitter.emit("error", /* @__PURE__ */ new Error("non-JSON-RPC WebSocket frame"));
1407
- return;
1408
- }
1409
- this.#emitter.emit("message", message);
1025
+ deliverMessage(this.#emitter, text, "non-JSON-RPC WebSocket frame");
1410
1026
  }
1411
1027
  #onClose() {
1412
1028
  if (this.#closed) return;
@@ -1423,49 +1039,49 @@ var WebSocketServerTransport = class {
1423
1039
  //#endregion
1424
1040
  //#region src/server/transports/WebSocketClientTransport.ts
1425
1041
  /**
1426
- * The WebSocket CLIENT transport for the Model Context Protocol — a
1427
- * {@link MCPClientTransportInterface} that drives a REMOTE MCP server over a WebSocket, the
1042
+ * Drives a remote MCP server over a WebSocket — a client
1043
+ * {@link MCPMessageTransportInterface} for the Model Context Protocol, the
1428
1044
  * egress mirror of {@link import('./factories.js').createWebSocketServer} and the WebSocket
1429
- * sibling of {@link import('./HTTPClientTransport.js').HTTPClientTransport}.
1045
+ * sibling of {@link import('@orkestrel/mcp').HTTPClientTransport}.
1430
1046
  *
1431
1047
  * @remarks
1432
1048
  * - **Persistent bidirectional channel (unlike the HTTP transport).** `start()` performs the
1433
1049
  * RFC 6455 client handshake: it opens a `node:http`(`s`) `GET` carrying `Connection: Upgrade`
1434
1050
  * / `Upgrade: websocket` / a random `Sec-WebSocket-Key` / `Sec-WebSocket-Version: 13` /
1435
1051
  * `Sec-WebSocket-Protocol: mcp` (plus any `options.headers`), awaits the client `'upgrade'`
1436
- * event, and VALIDATES `Sec-WebSocket-Accept === computeWebSocketAccept(key)` (the D2 helper)
1437
- * — a mismatch (or a non-`101` response, or a request error) REJECTS `start()` and the socket
1052
+ * event, and validates `Sec-WebSocket-Accept === computeWebSocketAccept(key)` (the D2 helper)
1053
+ * — a mismatch (or a non-`101` response, or a request error) rejects `start()` and the socket
1438
1054
  * is destroyed. On success it wraps the raw upgraded socket in `createNodeWebSocket({ socket,
1439
- * head })` (CLIENT mode — no key → frames are MASKED per RFC 6455 §5.3) and bridges its
1055
+ * head })` (client mode — no key → frames are masked per RFC 6455 §5.3) and bridges its
1440
1056
  * `message`.
1441
1057
  * - **The arriving socket is RE-ASKED for, never assumed.** `start()` suspends across that
1442
1058
  * connect and upgrade, so it re-checks the transport's state before installing anything: a
1443
1059
  * concurrent `start()` that already installed a socket, or a {@link close} that ended the
1444
- * transport while the handshake was on the wire, both WIN — the socket that arrives late is
1445
- * DESTROYED and never bound, so no orphan is left re-emitting frames at nobody. Both
1060
+ * transport while the handshake was on the wire, both win — the socket that arrives late is
1061
+ * destroyed and never bound, so no orphan is left re-emitting frames at nobody. Both
1446
1062
  * `start()` calls still resolve; exactly one socket is ever bound.
1447
- * - **Inbound (`message`).** Each decoded text frame is `JSON.parse`d (guarded) and narrowed
1448
- * with `parseJSONRPCMessage` — a {@link JSONRPCMessage} re-emits on this transport's `message`
1063
+ * - **Inbound (`message`).** Each decoded text frame runs through the shared `deliverMessage`
1064
+ * fold (parse, then narrow) — a {@link JSONRPCMessage} re-emits on this transport's `message`
1449
1065
  * event (the reply the {@link import('@orkestrel/mcp').MCPClientInterface} correlates by `id`); a
1450
1066
  * non-JSON / non-message frame surfaces on `error` and is dropped. The socket's `close`
1451
1067
  * / `error` bridge to this transport's events.
1452
1068
  * - **Outbound (`send`).** `send(message)` writes one masked text frame. A socket write is not
1453
- * confirmed, so this transport answers a closed channel from its own state AND the socket's
1069
+ * confirmed, so this transport answers a closed channel from its own state and the socket's
1454
1070
  * `readyState`: a `send` with no bound socket — before `start()`, after `close()`, or after the
1455
- * peer ended the socket — and a `send` on a bound socket that is not `OPEN` both REJECT with
1071
+ * peer ended the socket — and a `send` on a bound socket that is not `OPEN` both reject with
1456
1072
  * `WebSocket transport is not connected`. It neither drops the message nor queues it for a
1457
1073
  * connection this transport is not holding — the browser face queues a pre-open send, and this
1458
1074
  * one, holding no connection to flush it onto, rejects that too.
1459
1075
  * - **`close()`** unsubscribes from the socket, closes it, and fires `close` (idempotent). An
1460
- * upgrade still on the wire is DESTROYED, so a `close()` during the handshake ends the
1076
+ * upgrade still on the wire is destroyed, so a `close()` during the handshake ends the
1461
1077
  * transport at once instead of waiting for a peer that may never answer — the suspended
1462
1078
  * `start()` resolves, because the close is the outcome its caller asked for.
1463
1079
  * - **URL scheme.** `options.url` accepts a `ws://` / `wss://` URL or an `http://` / `https://`
1464
1080
  * one; a `ws(s)` scheme is converted to `http(s)` for the underlying upgrade request (`wss`
1465
1081
  * → TLS through `node:https`). Either reaches the same endpoint.
1466
- * - **Observable.** Owns the `emitter` ({@link MCPClientTransportEventMap}); every emit
1082
+ * - **Observable.** Owns the `emitter` ({@link MCPMessageTransportEventMap}); every emit
1467
1083
  * the emitter isolates a listener throw (a buggy observer never corrupts the transport);
1468
- * `error` is a DOMAIN event (a transport-level fault).
1084
+ * `error` is a domain event (a transport-level fault).
1469
1085
  *
1470
1086
  * @example
1471
1087
  * ```ts
@@ -1500,7 +1116,7 @@ var WebSocketClientTransport = class {
1500
1116
  if (this.#socket !== void 0) return;
1501
1117
  this.#closed = false;
1502
1118
  try {
1503
- await this.#connect(this.#httpURL(), randomBytes(16).toString("base64"));
1119
+ await this.#connect(this.#toHTTPURL(), randomBytes(16).toString("base64"));
1504
1120
  } finally {
1505
1121
  this.#request = void 0;
1506
1122
  }
@@ -1533,7 +1149,7 @@ var WebSocketClientTransport = class {
1533
1149
  Upgrade: "websocket",
1534
1150
  "Sec-WebSocket-Key": key,
1535
1151
  "Sec-WebSocket-Version": WEBSOCKET_VERSION,
1536
- "Sec-WebSocket-Protocol": "mcp",
1152
+ "Sec-WebSocket-Protocol": MCP_WEBSOCKET_SUBPROTOCOL,
1537
1153
  ...this.#headers
1538
1154
  }
1539
1155
  });
@@ -1585,19 +1201,7 @@ var WebSocketClientTransport = class {
1585
1201
  socket.emitter.off("error", this.#failure);
1586
1202
  }
1587
1203
  #receive(text) {
1588
- let parsed;
1589
- try {
1590
- parsed = JSON.parse(text);
1591
- } catch (error) {
1592
- this.#emitter.emit("error", error);
1593
- return;
1594
- }
1595
- const message = parseJSONRPCMessage(parsed);
1596
- if (message === void 0) {
1597
- this.#emitter.emit("error", /* @__PURE__ */ new Error("non-JSON-RPC WebSocket frame"));
1598
- return;
1599
- }
1600
- this.#emitter.emit("message", message);
1204
+ deliverMessage(this.#emitter, text, "non-JSON-RPC WebSocket frame");
1601
1205
  }
1602
1206
  #onClose() {
1603
1207
  if (this.#closed) return;
@@ -1606,7 +1210,7 @@ var WebSocketClientTransport = class {
1606
1210
  this.#socket = void 0;
1607
1211
  this.#emitter.emit("close");
1608
1212
  }
1609
- #httpURL() {
1213
+ #toHTTPURL() {
1610
1214
  const url = new URL(this.#url);
1611
1215
  if (url.protocol === "ws:") url.protocol = "http:";
1612
1216
  else if (url.protocol === "wss:") url.protocol = "https:";
@@ -1617,10 +1221,9 @@ var WebSocketClientTransport = class {
1617
1221
  //#endregion
1618
1222
  //#region src/server/transports/StdioClientTransport.ts
1619
1223
  /**
1620
- * The stdio CLIENT transport for the Model Context Protocol a
1621
- * {@link StdioClientTransportInterface} that drives a CHILD PROCESS MCP server over
1622
- * newline-delimited JSON-RPC on `stdin`/`stdout`, the stdio sibling of {@link
1623
- * import('./HTTPClientTransport.js').HTTPClientTransport} and {@link
1224
+ * Drives a child process MCP server over newline-delimited JSON-RPC on `stdin`/`stdout`
1225
+ * a {@link StdioClientTransportInterface}, the stdio sibling of {@link
1226
+ * import('@orkestrel/mcp').HTTPClientTransport} and {@link
1624
1227
  * import('./WebSocketClientTransport.js').WebSocketClientTransport}.
1625
1228
  *
1626
1229
  * @remarks
@@ -1634,14 +1237,14 @@ var WebSocketClientTransport = class {
1634
1237
  * line is decoded and delivered through the shared {@link dispatchLines} helper — a well-formed
1635
1238
  * {@link JSONRPCMessage} emits `message`, a malformed line emits `error` (never throws).
1636
1239
  * - **Outbound (`send`).** `send(message)` writes one newline-terminated `JSON.stringify`d line
1637
- * through the supervisor's `send` and AWAITS its answer, so this promise settles only after the
1240
+ * through the supervisor's `send` and awaits its answer, so this promise settles only after the
1638
1241
  * host reports the line handled rather than the moment the write is queued. The supervisor never
1639
1242
  * rejects — it answers `false` for a channel that was closed, destroyed, or ended, for a write
1640
1243
  * that failed, or for one that remained unconfirmed through `delivery`. A call made without a
1641
1244
  * live child rejects as not connected; a `false` answer from a live child rejects as unable to
1642
1245
  * deliver. The supervisor does not disclose which cause produced that answer.
1643
1246
  * - **`close()`** runs the supervisor's bounded termination and teardown, then fires `close` once
1644
- * (idempotent). That teardown reaches the child's TERMINAL MOMENT, where the supervisor freezes
1247
+ * (idempotent). That teardown reaches the child's terminal moment, where the supervisor freezes
1645
1248
  * `evidence`, ends `lines`, and settles `exit` together, so this transport needs no release of
1646
1249
  * its own to get its line pump back: the stream ends under the pump rather than throwing at it.
1647
1250
  * A line the supervisor had already framed behind the one being delivered is dropped rather than
@@ -1654,15 +1257,15 @@ var WebSocketClientTransport = class {
1654
1257
  * child's own process group `SIGTERM`, waits the grace window, then `SIGKILL`s through the same
1655
1258
  * route, so the kill reaches grandchildren rather than orphaning them, while Windows ends the
1656
1259
  * tree with `taskkill /F /T`, which nothing in the child can intercept.
1657
- * - **Evidence.** `evidence` reports that retained stderr tail off the HELD child — its live tail
1260
+ * - **Evidence.** `evidence` reports that retained stderr tail off the held child — its live tail
1658
1261
  * while the child runs, and the value the supervisor froze at that child's terminal moment
1659
1262
  * afterwards. The reference is held past that moment and replaced only by the next `start()`,
1660
1263
  * which is what keeps a post-`close()` read stable without a private copy: the frozen value
1661
1264
  * never moves again, so a detached descendant writing to the inherited stderr after the cutoff
1662
1265
  * cannot grow it. See {@link StdioClientTransportInterface.evidence} for the readings and the
1663
1266
  * byte bound.
1664
- * - **Observable.** Owns the `emitter` ({@link MCPClientTransportEventMap}); the
1665
- * emitter isolates a listener throw; `error` is a DOMAIN event (a transport-level
1267
+ * - **Observable.** Owns the `emitter` ({@link MCPMessageTransportEventMap}); the
1268
+ * emitter isolates a listener throw; `error` is a domain event (a transport-level
1666
1269
  * fault, including the child spawn cause the supervisor surfaces and the notice that this
1667
1270
  * lifetime's `evidence` was cut off at the `drain` bound), distinct from the emitter's own
1668
1271
  * listener-error channel.
@@ -1783,15 +1386,14 @@ var StdioClientTransport = class {
1783
1386
  //#endregion
1784
1387
  //#region src/server/transports/StdioServerTransport.ts
1785
1388
  /**
1786
- * The stdio SERVER transport for the Model Context Protocol — wraps an injectable
1787
- * readable/writable stream pair (`process.stdin`/`process.stdout` in production, a
1788
- * test double in tests) as a {@link MCPClientTransportInterface}, the newline-delimited
1789
- * JSON-RPC channel {@link import('../factories.js').createStdioServer} pumps
1790
- * `mcp.dispatch` over, the stdio mirror of {@link
1389
+ * Wraps an injectable readable/writable stream pair (`process.stdin`/`process.stdout` in
1390
+ * production, a test double in tests) as a {@link MCPMessageTransportInterface} — the
1391
+ * newline-delimited JSON-RPC channel {@link import('../factories.js').createStdioServer}
1392
+ * pumps `mcp.dispatch` over, the stdio mirror of {@link
1791
1393
  * import('./WebSocketServerTransport.js').WebSocketServerTransport}.
1792
1394
  *
1793
1395
  * @remarks
1794
- * - **Reuses `MCPClientTransportInterface`.** The same generic carrier the HTTP
1396
+ * - **Reuses `MCPMessageTransportInterface`.** The same generic carrier the HTTP
1795
1397
  * and WebSocket server transports implement — `emitter` (`message` / `close` /
1796
1398
  * `error`), `start`, `send`, `close`. `session` is `undefined` (the stateless v1).
1797
1399
  * - **Inbound (`message`).** `start()` subscribes to `input`'s `data` event; each
@@ -1807,7 +1409,7 @@ var StdioClientTransport = class {
1807
1409
  * - **`close()`** removes this transport's input and output subscriptions, rejects every
1808
1410
  * pending send, and fires its `close`
1809
1411
  * event (idempotent). It pauses the input only when the caller was not already reading
1810
- * it at `start` (`readableFlowing !== true`) AND no `data` listener remains once this
1412
+ * it at `start` (`readableFlowing !== true`) and no `data` listener remains once this
1811
1413
  * transport's own is removed — so a process holding `process.stdin` can exit, and a
1812
1414
  * caller's own flow is never stopped underneath it. The transport preserves flowing versus
1813
1415
  * non-flowing state and restores every caller-owned listener. A Node stream that had never been
@@ -1817,8 +1419,8 @@ var StdioClientTransport = class {
1817
1419
  * listener receives data. The injected streams are owned by the caller (typically
1818
1420
  * `process.stdin`/`process.stdout`), so the transport never destroys, ends, or blanket-clears
1819
1421
  * them.
1820
- * - **Observable.** Owns the `emitter` ({@link MCPClientTransportEventMap}); the
1821
- * emitter isolates a listener throw; `error` is a DOMAIN event (a transport-level
1422
+ * - **Observable.** Owns the `emitter` ({@link MCPMessageTransportEventMap}); the
1423
+ * emitter isolates a listener throw; `error` is a domain event (a transport-level
1822
1424
  * fault), distinct from the emitter's own listener-error channel.
1823
1425
  */
1824
1426
  var StdioServerTransport = class {
@@ -1923,20 +1525,101 @@ function createMCPContinuation(secret) {
1923
1525
  };
1924
1526
  }
1925
1527
  /**
1528
+ * Creates the server-side mirror of
1529
+ * {@link import('@orkestrel/mcp').createDuplexClientTransport}: the adapter that bridges a
1530
+ * message-channel {@link MCPMessageTransportInterface}
1531
+ * (the shape the stdio and WebSocket server transports already implement) onto the
1532
+ * environment-agnostic {@link import('@orkestrel/mcp').MCPTransportInterface} port — what
1533
+ * {@link createStdioServer} and {@link createWebSocketServer} pipe through `bindServer`, so
1534
+ * the request/reply/error pump those factories used to hand-roll identically now lives once
1535
+ * in the core binder. {@link import('@orkestrel/mcp').createDuplexClientTransport} adapts the
1536
+ * same contracts the other way.
1537
+ *
1538
+ * @remarks
1539
+ * `send` decodes the already-serialized reply string back to a {@link JSONRPCMessage}
1540
+ * and writes it through `transport.send` (the same `JSON.stringify` the underlying
1541
+ * transport already performs, so the wire bytes are unchanged). `listen` filters
1542
+ * `transport`'s `message` event to invocations only — requests and notifications, never a
1543
+ * stray response, exactly as the prior hand-rolled pumps did — and re-serializes each one
1544
+ * back to a string for `bindServer`. `closed` bridges `transport`'s `close` event. `close`
1545
+ * closes the underlying `transport`.
1546
+ *
1547
+ * @remarks A message crossing this bridge is decoded and re-encoded twice, and that is
1548
+ * accepted rather than accidental. Inbound: the carrier already parsed the frame into a
1549
+ * {@link JSONRPCMessage}, and `listen` re-serializes it so `bindServer` can decode it again
1550
+ * under the server's own `limit`. Outbound: `bindServer` serialized the reply, `send` parses
1551
+ * it back, and the carrier stringifies it once more. The cost is two extra `JSON.parse` /
1552
+ * `JSON.stringify` round trips per message, paid to keep one pump in the core binder instead
1553
+ * of a hand-rolled one per carrier. It is bounded rather than unbounded because the binder
1554
+ * decodes within `server.limit.message`, so an oversized frame is refused before the second
1555
+ * decode rather than after it. Removing the cost means giving `MCPTransportInterface` a
1556
+ * message-shaped face beside its string one, which every transport would then carry.
1557
+ *
1558
+ * @remarks Per {@link import('@orkestrel/mcp').MCPTransportInterface}, `listen`/`closed`
1559
+ * each hold the single current handler (a second call replaces the first, never adds).
1560
+ * Because the underlying `transport.emitter` is ADD-based (`on` subscribes, never
1561
+ * replaces), this bridge installs one stable emitter listener per event on first use
1562
+ * and re-routes it to whichever handler is active (`undefined` while
1563
+ * none is), so rebinding never double-dispatches.
1564
+ *
1565
+ * @remarks A response whose `result` serializes away (for example, `undefined`) is dropped by
1566
+ * the message validators on the wire's decode side — an asymmetry the stdio/WS carrier
1567
+ * shares with the streamable-HTTP face, because both round-trip through `JSON.stringify`
1568
+ * / `JSON.parse` before re-validation.
1569
+ *
1570
+ * @param transport - The message-channel transport to bridge (stdio or WebSocket)
1571
+ * @returns An {@link import('@orkestrel/mcp').MCPTransportInterface} `bindServer` can drive
1572
+ *
1573
+ * @example
1574
+ * ```ts
1575
+ * import { bindServer } from '@orkestrel/mcp'
1576
+ *
1577
+ * const transport = new StdioServerTransport(process.stdin, process.stdout)
1578
+ * bindServer(mcp, createDuplexServerTransport(transport))
1579
+ * ```
1580
+ */
1581
+ function createDuplexServerTransport(transport) {
1582
+ let onMessage;
1583
+ let onClosed;
1584
+ transport.emitter.on("message", (message) => {
1585
+ if (!isJSONRPCInvocation(message)) return;
1586
+ onMessage?.(JSON.stringify(message));
1587
+ });
1588
+ transport.emitter.on("close", () => {
1589
+ onClosed?.();
1590
+ });
1591
+ return {
1592
+ async send(message) {
1593
+ const decoded = decodeEvent(message);
1594
+ if (decoded === void 0) return;
1595
+ await transport.send(decoded);
1596
+ },
1597
+ listen(handler) {
1598
+ onMessage = handler;
1599
+ },
1600
+ closed(handler) {
1601
+ onClosed = handler;
1602
+ },
1603
+ async close() {
1604
+ await transport.close();
1605
+ }
1606
+ };
1607
+ }
1608
+ /**
1926
1609
  * Creates the MCP Streamable-HTTP transport routes — mounts a transport-agnostic
1927
1610
  * {@link MCPDispatcherInterface} (the `@orkestrel/mcp` dispatch boundary) on the fetch-standard router
1928
1611
  * spine, pumping each `POST` body through `mcp.dispatch`. Returns the {@link RouteInput}s to
1929
1612
  * hand to `router.add(...)`.
1930
1613
  *
1931
1614
  * @remarks
1932
- * A SINGLE `POST {path}` route — `createMCPRoutes` is STATELESS. The handler reads its own
1615
+ * A single `POST {path}` route — `createMCPRoutes` is stateless. The handler reads its own
1933
1616
  * request body (its own JSON parse try/catch), so it works with or without a session
1934
1617
  * middleware mounted in front. It draws a sharp line between TRANSPORT-level and
1935
1618
  * DISPATCH-level outcomes:
1936
1619
  *
1937
1620
  * - A **transport** failure — a malformed JSON body, or a parsed value that is not a
1938
- * JSON-RPC INVOCATION — is an HTTP `400` carrying a JSON-RPC error BODY (`-32700` Parse
1939
- * error / `-32600` Invalid Request), with the `id` it could not read OMITTED.
1621
+ * JSON-RPC invocation — is an HTTP `400` carrying a JSON-RPC error body (`-32700` Parse
1622
+ * error / `-32600` Invalid Request), with the `id` it could not read omitted.
1940
1623
  * - Modern protocol/method/name headers are validated against the body; a mismatch is
1941
1624
  * HTTP `400` + `-32020`. Headerless initialize is accepted, a live legacy session supplies
1942
1625
  * its pinned revision, and every other headerless request is rejected.
@@ -1948,16 +1631,16 @@ function createMCPContinuation(secret) {
1948
1631
  * When `streaming` is enabled (the default) and the client `Accept`s `text/event-stream`,
1949
1632
  * the `200` reply is framed as a Streamable-HTTP SSE response (one `data:` event carrying
1950
1633
  * the JSON-RPC envelope, then the stream ends) through `@orkestrel/server`'s generic
1951
- * {@link import('@orkestrel/server').openStream} seam; otherwise it is a plain JSON body.
1634
+ * {@link import('@orkestrel/server').createStream} seam; otherwise it is a plain JSON body.
1952
1635
  *
1953
- * **Sessions are a SEPARATE, plug-and-play middleware.** `createMCPRoutes` mints / reads no
1954
- * session id. To make the transport STATEFUL, mount {@link
1955
- * import('./middlewares.js').createMCPSession} IN FRONT — it owns the same `path`, mints +
1636
+ * **Sessions are a separate, plug-and-play middleware.** `createMCPRoutes` mints / reads no
1637
+ * session id. To make the transport stateful, mount {@link
1638
+ * import('./middlewares.js').createMCPSession} in front — it owns the same `path`, mints +
1956
1639
  * validates the `mcp-session-id`, and serves the resumable `GET {path}` + `DELETE {path}`,
1957
1640
  * leaving this route to dispatch the validated `POST`.
1958
1641
  *
1959
- * This is MECHANISM, not policy: compose auth / rate-limiting (and the session middleware)
1960
- * IN FRONT as ordinary middleware; the optional `origin` group carries the deployment's shared
1642
+ * This is mechanism, not policy: compose auth / rate-limiting (and the session middleware)
1643
+ * in front as ordinary middleware; the optional `origin` group carries the deployment's shared
1961
1644
  * allowlist or explicitly delegates validation to an upstream layer.
1962
1645
  *
1963
1646
  * @typeParam TState - The consumer's opaque per-request state type
@@ -1986,27 +1669,32 @@ function createMCPRoutes(mcp, options) {
1986
1669
  }];
1987
1670
  }
1988
1671
  /**
1989
- * Creates the HTTP CLIENT transport for an {@link import('@orkestrel/mcp').MCPClientInterface}
1990
- * — a {@link MCPClientTransportInterface} that drives a REMOTE Streamable-HTTP MCP server
1672
+ * Creates the HTTP client transport for an {@link import('@orkestrel/mcp').MCPClientInterface}
1673
+ * — a {@link MCPMessageTransportInterface} that drives a remote Streamable-HTTP MCP server
1991
1674
  * over `fetch`. The egress mirror of {@link createMCPRoutes}.
1992
1675
  *
1993
1676
  * @remarks
1677
+ * It returns the core {@link import('@orkestrel/mcp').HTTPClientTransport}, the same class the
1678
+ * browser face's `createHTTPClientTransport` returns, because the class touches `fetch`,
1679
+ * `Response`, `AbortController`, `AbortSignal`, and `WeakMap` alone.
1680
+ *
1681
+ * @remarks
1994
1682
  * Hand it to `createMCPClient({ transport })`: each JSON-RPC message the client sends is
1995
1683
  * `POST`ed to `options.url` with `content-type: application/json` and an `Accept` of
1996
- * both `application/json` and `text/event-stream` (the server answers with EITHER — a
1684
+ * both `application/json` and `text/event-stream` (the server answers with either — a
1997
1685
  * plain JSON envelope or a Streamable-HTTP SSE `data:` event, decoded with `@orkestrel/sse`),
1998
1686
  * and the reply is surfaced on the transport's `message` event for the client's id
1999
1687
  * correlation. Add `options.headers` (for example, an `Authorization` bearer) to reach a guarded
2000
- * server. `start` / `close` hold no connection; against a STATEFUL server it captures the
1688
+ * server. `start` / `close` hold no connection; against a stateful server it captures the
2001
1689
  * `mcp-session-id` from `initialize` and echoes it on later requests. It also captures
2002
1690
  * the initialize result's `protocolVersion` and sends `mcp-protocol-version` alone on each
2003
1691
  * subsequent legacy request. Modern requests derive protocol and method headers directly
2004
1692
  * from the message, plus a name header only for `tools/call`.
2005
1693
  *
2006
- * @param options - `url` (the remote endpoint; REQUIRED), optional `headers` merged onto
1694
+ * @param options - `url` (the remote endpoint; required), optional `headers` merged onto
2007
1695
  * every request, optional `fetch` (default `globalThis.fetch`), and optional `timeout`
2008
1696
  * (ms, applied with `AbortSignal.timeout`); see {@link HTTPClientTransportOptions}
2009
- * @returns A working {@link MCPClientTransportInterface} over `fetch`
1697
+ * @returns A working {@link MCPMessageTransportInterface} over `fetch`
2010
1698
  *
2011
1699
  * @example
2012
1700
  * ```ts
@@ -2024,7 +1712,7 @@ function createHTTPClientTransport(options) {
2024
1712
  return new HTTPClientTransport(options);
2025
1713
  }
2026
1714
  /**
2027
- * Creates the MCP WebSocket transport INGRESS — an {@link UpgradeHandler} that exposes a
1715
+ * Creates the MCP WebSocket transport ingress — an {@link UpgradeHandler} that exposes a
2028
1716
  * transport-agnostic {@link MCPDispatcherInterface} over a WebSocket, the WebSocket mirror of
2029
1717
  * {@link createMCPRoutes}. Register it on the spine's upgrade seam.
2030
1718
  *
@@ -2036,16 +1724,16 @@ function createHTTPClientTransport(options) {
2036
1724
  * socket to the next handler (or destroys an unclaimed one): the `Upgrade` header is not
2037
1725
  * `websocket`, the request path is not `options.path` (default {@link DEFAULT_MCP_PATH},
2038
1726
  * `'/mcp'`), the `Sec-WebSocket-Key` is absent, or the `Sec-WebSocket-Version` is not `13`.
2039
- * A decline NEVER writes to the socket (it is not yet ours) — the spine owns the unclaimed
1727
+ * A decline never writes to the socket (it is not yet ours) — the spine owns the unclaimed
2040
1728
  * outcome.
2041
1729
  * - **Claims (returns `true`)** otherwise: it builds `createNodeWebSocket({ socket, key, head,
2042
1730
  * protocol })` (SERVER mode → writes the `101` handshake, selects the configured subprotocol
2043
- * only when the client's offer contains it, and sends UNMASKED frames), wraps it in a
1731
+ * only when the client's offer contains it, and sends unmasked frames), wraps it in a
2044
1732
  * {@link WebSocketServerTransport}, and pipes it through the core {@link
2045
1733
  * import('@orkestrel/mcp').MCPTransportInterface} port through {@link
2046
- * import('./helpers.js').bridgeMessageTransport} + {@link import('@orkestrel/mcp').bindServer}:
2047
- * each inbound REQUEST runs through `mcp.dispatch`, and a defined response is written back
2048
- * as a frame — a NOTIFICATION sends nothing, and a non-request message (a stray response) is
1734
+ * createDuplexServerTransport} + {@link import('@orkestrel/mcp').bindServer}:
1735
+ * each inbound request runs through `mcp.dispatch`, and a defined response is written back
1736
+ * as a frame — a notification sends nothing, and a non-request message (a stray response) is
2049
1737
  * ignored. A `dispatch` / `send` fault surfaces on `mcp.emitter`'s `error` event rather than
2050
1738
  * escaping the (async) message pump.
2051
1739
  * - **Closes on the spine's `stop`.** It holds every socket it claimed and, on `options.emitter`'s
@@ -2056,12 +1744,12 @@ function createHTTPClientTransport(options) {
2056
1744
  * then have the connection cut mid-protocol. A socket the peer already dropped is gone from
2057
1745
  * the set (its transport's `close` removes it), and closing a dead one is a no-op either way.
2058
1746
  *
2059
- * It is MECHANISM, not policy: compose an auth guard IN FRONT by registering an upgrade
2060
- * handler BEFORE this one — that handler can claim (decline + destroy) an unauthenticated
1747
+ * It is mechanism, not policy: compose an auth guard in front by registering an upgrade
1748
+ * handler before this one — that handler can claim (decline + destroy) an unauthenticated
2061
1749
  * upgrade so it never reaches this pump.
2062
1750
  *
2063
1751
  * @param mcp - The transport-agnostic {@link MCPDispatcherInterface} to expose over WebSocket
2064
- * @param options - The spine's `emitter` (REQUIRED — the `stop` event this ingress closes its
1752
+ * @param options - The spine's `emitter` (required — the `stop` event this ingress closes its
2065
1753
  * sockets on), plus optional `path` (default {@link DEFAULT_MCP_PATH}) and `subprotocol`
2066
1754
  * (default {@link MCP_WEBSOCKET_SUBPROTOCOL}); see {@link WebSocketServerOptions}
2067
1755
  * @returns An {@link UpgradeHandler} to register with the spine's `upgrade` seam
@@ -2079,7 +1767,7 @@ function createHTTPClientTransport(options) {
2079
1767
  */
2080
1768
  function createWebSocketServer(mcp, options) {
2081
1769
  const path = options.path ?? "/mcp";
2082
- const subprotocol = options.subprotocol ?? "mcp";
1770
+ const subprotocol = options.subprotocol ?? MCP_WEBSOCKET_SUBPROTOCOL;
2083
1771
  const live = /* @__PURE__ */ new Map();
2084
1772
  options.emitter.on("stop", () => {
2085
1773
  for (const [transport, unbind] of live) {
@@ -2103,7 +1791,7 @@ function createWebSocketServer(mcp, options) {
2103
1791
  head,
2104
1792
  ...protocol === void 0 ? {} : { protocol }
2105
1793
  }));
2106
- const unbind = bindServer(mcp, bridgeMessageTransport(transport));
1794
+ const unbind = bindServer(mcp, createDuplexServerTransport(transport));
2107
1795
  live.set(transport, unbind);
2108
1796
  transport.emitter.on("close", () => {
2109
1797
  live.delete(transport);
@@ -2114,8 +1802,8 @@ function createWebSocketServer(mcp, options) {
2114
1802
  };
2115
1803
  }
2116
1804
  /**
2117
- * Creates the WebSocket CLIENT transport for an {@link import('@orkestrel/mcp').MCPClientInterface}
2118
- * — a {@link MCPClientTransportInterface} that drives a REMOTE MCP server over a WebSocket. The
1805
+ * Creates the WebSocket client transport for an {@link import('@orkestrel/mcp').MCPClientInterface}
1806
+ * — a {@link MCPMessageTransportInterface} that drives a remote MCP server over a WebSocket. The
2119
1807
  * egress mirror of {@link createWebSocketServer} and the WebSocket sibling of {@link
2120
1808
  * createHTTPClientTransport}.
2121
1809
  *
@@ -2129,9 +1817,9 @@ function createWebSocketServer(mcp, options) {
2129
1817
  * surfaced on the transport's `message` event for the client's id correlation. Add
2130
1818
  * `options.headers` (for example, an `Authorization` bearer) to reach a guarded server.
2131
1819
  *
2132
- * @param options - `url` (the remote WebSocket endpoint; REQUIRED) and optional `headers`
1820
+ * @param options - `url` (the remote WebSocket endpoint; required) and optional `headers`
2133
1821
  * merged onto the upgrade request; see {@link WebSocketClientTransportOptions}
2134
- * @returns A working {@link MCPClientTransportInterface} over a WebSocket
1822
+ * @returns A working {@link MCPMessageTransportInterface} over a WebSocket
2135
1823
  *
2136
1824
  * @example
2137
1825
  * ```ts
@@ -2149,8 +1837,8 @@ function createWebSocketClientTransport(options) {
2149
1837
  return new WebSocketClientTransport(options);
2150
1838
  }
2151
1839
  /**
2152
- * Creates the stdio CLIENT transport for an {@link import('@orkestrel/mcp').MCPClientInterface}
2153
- * — a {@link StdioClientTransportInterface} that spawns and drives a CHILD PROCESS MCP server
1840
+ * Creates the stdio client transport for an {@link import('@orkestrel/mcp').MCPClientInterface}
1841
+ * — a {@link StdioClientTransportInterface} that spawns and drives a child process MCP server
2154
1842
  * over newline-delimited JSON-RPC on `stdin`/`stdout`, the stdio sibling of {@link
2155
1843
  * createHTTPClientTransport} and {@link createWebSocketClientTransport}.
2156
1844
  *
@@ -2167,7 +1855,7 @@ function createWebSocketClientTransport(options) {
2167
1855
  * waits before the `send` rejects. An omitted `delivery` selects {@link
2168
1856
  * import('./constants.js').DEFAULT_MCP_DELIVERY}; an explicit `0` removes the bound.
2169
1857
  *
2170
- * @param options - `command` (the executable to spawn; REQUIRED), optional `args`,
1858
+ * @param options - `command` (the executable to spawn; required), optional `args`,
2171
1859
  * optional `env`, and an optional `delivery` bound in milliseconds on an unconfirmed
2172
1860
  * `stdin` write; see {@link StdioClientTransportOptions}
2173
1861
  * @returns A working {@link StdioClientTransportInterface} over a child process's stdio,
@@ -2189,7 +1877,7 @@ function createStdioClientTransport(options) {
2189
1877
  return new StdioClientTransport(options);
2190
1878
  }
2191
1879
  /**
2192
- * Creates the MCP stdio transport INGRESS — pumps a transport-agnostic {@link
1880
+ * Creates the MCP stdio transport ingress — pumps a transport-agnostic {@link
2193
1881
  * MCPDispatcherInterface} over newline-delimited JSON-RPC on `stdin`/`stdout` (or an
2194
1882
  * injected stream pair), the stdio mirror of {@link createWebSocketServer}.
2195
1883
  *
@@ -2197,9 +1885,9 @@ function createStdioClientTransport(options) {
2197
1885
  * Wraps `options.input` (default `process.stdin`) / `options.output` (default
2198
1886
  * `process.stdout`) in a {@link import('./transports/StdioServerTransport.js').StdioServerTransport}
2199
1887
  * and pipes it through the core {@link import('@orkestrel/mcp').MCPTransportInterface} port
2200
- * through {@link import('./helpers.js').bridgeMessageTransport} + {@link
2201
- * import('@orkestrel/mcp').bindServer}: each inbound REQUEST runs through `mcp.dispatch`, and
2202
- * a defined response is written back as a newline-terminated line — a NOTIFICATION
1888
+ * through {@link createDuplexServerTransport} + {@link
1889
+ * import('@orkestrel/mcp').bindServer}: each inbound request runs through `mcp.dispatch`, and
1890
+ * a defined response is written back as a newline-terminated line — a notification
2203
1891
  * writes nothing, and a non-request message is ignored. A `dispatch` / `send` fault
2204
1892
  * surfaces on `mcp.emitter`'s `error` event rather than escaping the (async) message
2205
1893
  * pump.
@@ -2223,7 +1911,7 @@ function createStdioClientTransport(options) {
2223
1911
  */
2224
1912
  function createStdioServer(mcp, options) {
2225
1913
  const transport = new StdioServerTransport(options?.input ?? process.stdin, options?.output ?? process.stdout);
2226
- const unbind = bindServer(mcp, bridgeMessageTransport(transport));
1914
+ const unbind = bindServer(mcp, createDuplexServerTransport(transport));
2227
1915
  return {
2228
1916
  start() {
2229
1917
  transport.start();
@@ -2240,7 +1928,7 @@ function createStdioServer(mcp, options) {
2240
1928
  * Creates the native MCP session {@link MiddlewareHandler} — the plug-and-play stateful layer
2241
1929
  * that fronts a session-agnostic {@link import('./factories.js').createMCPRoutes}. Compose it
2242
1930
  * with `router.use(createMCPSession())` (or the equivalent middleware seam), mirroring any
2243
- * other closure-scoped stateful middleware. Has NO dependency on `@orkestrel/middleware` — the
1931
+ * other closure-scoped stateful middleware. Has no dependency on `@orkestrel/middleware` — the
2244
1932
  * session store, mint-on-`initialize`, and resumable stream are all native to this package.
2245
1933
  *
2246
1934
  * @remarks
@@ -2253,39 +1941,41 @@ function createStdioServer(mcp, options) {
2253
1941
  *
2254
1942
  * - **`POST {path}`.** Buffers `const text = await request.text()` (so the downstream route
2255
1943
  * can re-read it from a freshly-built forwarded `Request`). Resolves a session through {@link
2256
- * readSessionHeader}: a VALID id touches the entry and sets `context.state.session`; an
2257
- * ABSENT / unknown id whose (guarded) body parses to an `initialize` request ({@link
2258
- * isInitializeRequest}) MINTS a fresh {@link MCPSession} (`crypto.randomUUID()`, `capacity`)
2259
- * and sets `context.state.session`; neither → {@link rejectUnknownSession} (`404`). The
1944
+ * readSessionHeader}: a valid id touches the entry and sets `context.state.session`; an
1945
+ * absent / unknown id whose (guarded) body parses to an `initialize` request ({@link
1946
+ * isInitializeRequest}) mints a fresh {@link MCPSession} (`crypto.randomUUID()`, the `session`
1947
+ * options group) and sets `context.state.session`; neither → {@link rejectUnknownSession}
1948
+ * (`404`). The
2260
1949
  * minted entry pins the negotiated legacy revision, which is supplied to a later headerless
2261
1950
  * live-session request. It then
2262
- * FORWARDS a fresh `Request` carrying the buffered `text` (`next(forwarded)`) — never the
1951
+ * forwards a fresh `Request` carrying the buffered `text` (`next(forwarded)`) — never the
2263
1952
  * already-consumed original — so the route re-reads the same body, and stamps the response
2264
- * with {@link MCP_SESSION_HEADER}. The entry's `touched` instant is read AFTER that
2265
- * downstream response, because it means the LAST ACCESS: a request slower than `ttl` would
1953
+ * with {@link MCP_SESSION_HEADER}. The entry's `touched` instant is read after that
1954
+ * downstream response, because it means the last access: a request slower than `ttl` would
2266
1955
  * otherwise store a session that is already expired, and the write-back RE-ASKS the store, so
2267
1956
  * a `DELETE` arriving while the request was suspended is not undone.
2268
1957
  * - **`GET {path}`.** Resolves the session the same way (no mint — only `initialize` mints);
2269
1958
  * an invalid / unknown id is the same `404`. A valid session opens the resumable
2270
- * server→client stream through `@orkestrel/server`'s {@link import('@orkestrel/server').openStream}:
2271
- * replays every event after the client's `Last-Event-ID` ({@link readLastEventId}) BEFORE
1959
+ * server→client stream through `@orkestrel/server`'s {@link import('@orkestrel/server').createStream}:
1960
+ * replays every event after the client's `Last-Event-ID` ({@link readLastEventId}) before
2272
1961
  * attaching the stream for live pushes, then attaches; cancellation of the streamed response
2273
1962
  * body composes with `request.signal` and detaches it. Long-lived — never `end()`ed here.
2274
1963
  * - **`DELETE {path}`.** Resolves the session; a valid id deletes it from the store and answers
2275
1964
  * `204`; an invalid / unknown id is the same `404`.
2276
1965
  *
2277
- * It is MECHANISM, not policy, and ADDITIVE: omit it entirely for the stateless default
1966
+ * It is mechanism, not policy, and additive: omit it entirely for the stateless default
2278
1967
  * ({@link import('./factories.js').createMCPRoutes}'s only behavior). The `path` MUST match the
2279
1968
  * `createMCPRoutes` `path` it fronts. The WebSocket transport is inherently one session per
2280
- * connection (the socket IS the session), so this middleware does not apply to it.
1969
+ * connection (the socket is the session), so this middleware does not apply to it.
2281
1970
  *
2282
1971
  * @typeParam TState - The consumer's `TState`, which MUST extend {@link MCPSessionState} so
2283
1972
  * the resolved session can be threaded through `context.state.session`
2284
1973
  * @param options - Optional `path` (default {@link DEFAULT_MCP_PATH}), `ttl` (idle-session
2285
- * sweep window, ms — omit for sessions that live until an explicit `DELETE`), `capacity`
2286
- * (the folded per-session replay-log bound), and `clock` (the deterministic epoch-ms clock;
2287
- * defaults to `Date.now`), plus the shared `origin` validation options; see
2288
- * {@link MCPSessionOptions}
1974
+ * sweep window, ms — omit for sessions that live until an explicit `DELETE`), `session`
1975
+ * (the knobs each minted {@link MCPSession} takes — `capacity`, the log's own `ttl`, and its
1976
+ * `clock`), and `clock` (the deterministic epoch-ms clock this middleware keeps its own
1977
+ * bookkeeping on and hands down to a session that names none; defaults to `Date.now`), plus
1978
+ * the shared `origin` validation options; see {@link MCPSessionMiddlewareOptions}
2289
1979
  * @returns A {@link MiddlewareHandler} that mints / validates sessions + serves the resumable
2290
1980
  * `GET` / `DELETE`
2291
1981
  *
@@ -2303,7 +1993,7 @@ function createStdioServer(mcp, options) {
2303
1993
  */
2304
1994
  function createMCPSession(options) {
2305
1995
  const path = options?.path ?? "/mcp";
2306
- const capacity = options?.capacity;
1996
+ const sessionOptions = options?.session ?? {};
2307
1997
  const ttl = options?.ttl;
2308
1998
  const clock = options?.clock ?? Date.now;
2309
1999
  const origin = options?.origin;
@@ -2316,10 +2006,10 @@ function createMCPSession(options) {
2316
2006
  if (context.method === "POST") {
2317
2007
  try {
2318
2008
  text = await request.text();
2319
- parsed = parseJSONRPCMessage(JSON.parse(text));
2320
2009
  } catch {
2321
- parsed = void 0;
2010
+ text = void 0;
2322
2011
  }
2012
+ parsed = text === void 0 ? void 0 : parseJSONRPCMessage(parseJSON(text));
2323
2013
  if (text !== void 0 && parsed !== void 0 && isModernRequest(parsed)) return next(new Request(context.url, {
2324
2014
  method: "POST",
2325
2015
  headers: request.headers,
@@ -2352,7 +2042,7 @@ function createMCPSession(options) {
2352
2042
  if (context.method === "GET") {
2353
2043
  if (entry === void 0) return rejectUnknownSession();
2354
2044
  const session = entry.session;
2355
- const stream = openStream();
2045
+ const stream = createStream();
2356
2046
  const disconnect = new HTTPDisconnect(request.signal, options?.keepalive);
2357
2047
  stream.response.headers.set(SSE_BUFFERING_HEADER, "no");
2358
2048
  stream.comment("open");
@@ -2371,7 +2061,10 @@ function createMCPSession(options) {
2371
2061
  if (entry === void 0) {
2372
2062
  if (parsed !== void 0 && isInitializeRequest(parsed)) {
2373
2063
  created = {
2374
- session: new MCPSession(crypto.randomUUID(), capacity !== void 0 ? { capacity } : {}),
2064
+ session: new MCPSession(crypto.randomUUID(), {
2065
+ ...sessionOptions,
2066
+ clock: sessionOptions.clock ?? clock
2067
+ }),
2375
2068
  touched: clock(),
2376
2069
  version: inferLegacyVersion(parsed)
2377
2070
  };
@@ -2381,7 +2074,7 @@ function createMCPSession(options) {
2381
2074
  if (!Reflect.set(context.state, "session", entry.session)) throw new Error("MCP session state is not writable");
2382
2075
  const headers = new Headers(request.headers);
2383
2076
  if (parsed === void 0 || !isInitializeRequest(parsed)) {
2384
- const issue = inferHeaderIssue(request, entry.version);
2077
+ const issue = inferSessionHeaderIssue(request, entry.version);
2385
2078
  if (issue?.reason === "missing") headers.set(MCP_PROTOCOL_VERSION_HEADER, entry.version);
2386
2079
  else if (issue !== void 0) {
2387
2080
  const requestId = parsed !== void 0 && "method" in parsed ? parsed.id : void 0;
@@ -2409,6 +2102,6 @@ function createMCPSession(options) {
2409
2102
  };
2410
2103
  }
2411
2104
  //#endregion
2412
- export { DEFAULT_MCP_DELIVERY, DEFAULT_MCP_KEEPALIVE_INTERVAL, DEFAULT_MCP_PATH, DEFAULT_MCP_SESSION_CAPACITY, DEFAULT_MCP_SESSION_TTL, HTTPClientTransport, HTTPDisconnect, MCPSession, MCP_METHOD_HEADER, MCP_NAME_HEADER, MCP_PROTOCOL_VERSION_HEADER, MCP_SESSION_HEADER, MCP_WEBSOCKET_SUBPROTOCOL, SSE_BUFFERING_DISABLED, SSE_BUFFERING_HEADER, SSE_KEEPALIVE_COMMENT, StdioClientTransport, StdioServerTransport, WebSocketClientTransport, WebSocketServerTransport, acceptsEventStream, allowsOrigin, bridgeMessageTransport, buildResponseError, createHTTPClientTransport, createMCPContinuation, createMCPPostHandler, createMCPRoutes, createMCPSession, createReadableStream, createStdioClientTransport, createStdioServer, createWebSocketClientTransport, createWebSocketServer, decodeEvent, dispatchLines, extractLines, inferHeaderIssue, inferHeaderTarget, inferLegacyVersion, inferParameterRefusal, inferStatus, readEventStream, readLastEventId, readSessionHeader, rejectUnknownSession, sendEventStream, upgradeRequestPath, writeLine };
2105
+ export { DEFAULT_MCP_DELIVERY, DEFAULT_MCP_KEEPALIVE_INTERVAL, DEFAULT_MCP_PATH, DEFAULT_MCP_SESSION_CAPACITY, DEFAULT_MCP_SESSION_TTL, HTTPDisconnect, MCPSession, SSE_BUFFERING_DISABLED, SSE_BUFFERING_HEADER, SSE_KEEPALIVE_COMMENT, StdioClientTransport, StdioServerTransport, WebSocketClientTransport, WebSocketServerTransport, acceptsEventStream, allowsOrigin, createDuplexServerTransport, createHTTPClientTransport, createMCPContinuation, createMCPPostHandler, createMCPRoutes, createMCPSession, createStdioClientTransport, createStdioServer, createWebSocketClientTransport, createWebSocketServer, dispatchLines, extractLines, inferHeaderIssue, inferHeaderTarget, inferLegacyVersion, inferParameterRefusal, inferSessionHeaderIssue, inferStatus, readLastEventId, readSessionHeader, rejectUnknownSession, sendEventStream, upgradeRequestPath, writeLine };
2413
2106
 
2414
2107
  //# sourceMappingURL=index.js.map