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