@orkestrel/mcp 0.0.26 → 0.0.28

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,46 +1,24 @@
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");
6
5
  let _orkestrel_emitter = require("@orkestrel/emitter");
6
+ let _orkestrel_websocket = require("@orkestrel/websocket");
7
7
  let node_crypto = require("node:crypto");
8
8
  let node_http = require("node:http");
9
9
  let node_https = require("node:https");
10
- let _orkestrel_websocket = require("@orkestrel/websocket");
11
10
  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
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,7 +52,7 @@ 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
@@ -100,37 +66,6 @@ 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
70
  * notification in order, then the terminating response — and END the exchange however the
136
71
  * pump leaves.
@@ -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
  /**
@@ -280,65 +215,6 @@ 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
  *
@@ -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,106 +314,62 @@ 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
  }
320
+ //#endregion
321
+ //#region src/server/inferers.ts
448
322
  /**
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.
323
+ * Infers the target one modern request's `Mcp-Name` header must carry.
456
324
  *
457
325
  * @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`.
326
+ * The protocol scopes the header to the methods whose body carries a name-shaped field, and
327
+ * names the field per method: `tools/call` and `prompts/get` carry `params.name`, and
328
+ * `resources/read` carries `params.uri`. Every other method `server/discover`, `tools/list`,
329
+ * `resources/list`, `prompts/list` has nothing to derive a target from, so the header is not
330
+ * required there and a peer that sent one anyway is not held to it.
465
331
  *
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.
332
+ * A method within the scope whose named member is absent or is not a string reads as no
333
+ * target. There is nothing for a header to match, and refusing the request over a body member
334
+ * the header rule does not own would report a parameter fault as a header fault. Total.
476
335
  *
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
336
+ * @param request - The parsed modern invocation to read the target from
337
+ * @returns The target the header must carry, or `undefined` when the method carries none
491
338
  *
492
339
  * @example
493
340
  * ```ts
494
- * import { bindServer } from '@orkestrel/mcp'
495
- *
496
- * const transport = new StdioServerTransport(process.stdin, process.stdout)
497
- * bindServer(mcp, bridgeMessageTransport(transport))
341
+ * inferHeaderTarget({ jsonrpc: '2.0', id: 1, method: 'resources/read', params: { uri: 'file:///a' } })
342
+ * // → 'file:///a'
498
343
  * ```
499
344
  */
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
- };
345
+ function inferHeaderTarget(request) {
346
+ if (request.method === "tools/call" || request.method === "prompts/get") {
347
+ const name = request.params?.["name"];
348
+ return (0, _orkestrel_contract.isString)(name) ? name : void 0;
349
+ }
350
+ if (request.method === "resources/read") {
351
+ const uri = request.params?.["uri"];
352
+ return (0, _orkestrel_contract.isString)(uri) ? uri : void 0;
353
+ }
526
354
  }
527
- //#endregion
528
- //#region src/server/inferers.ts
529
355
  /**
530
- * 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.
531
357
  *
532
358
  * @remarks
533
- * A modern request derives its protocol, method, and tools/call-only name expectations from
534
- * the JSON-RPC body. A legacy request body requires a protocol header after initialization,
535
- * while a supplied legacy session version additionally diagnoses a header that disagrees with
536
- * the active session. Messages name the expected value but never echo the client-supplied one.
359
+ * A modern request derives its protocol, method, and name expectations from the JSON-RPC body,
360
+ * the name expectation scoped to the methods {@link inferHeaderTarget} reads a target for. A
361
+ * name header carrying the Base64 sentinel is decoded through
362
+ * {@link import('@orkestrel/mcp').decodeSentinel} before the comparison, so a peer that had
363
+ * to encode its value still matches; a sentinel whose payload is invalid decodes to nothing
364
+ * and therefore mismatches, which is how an invalid header value is refused. A legacy request
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.
537
370
  *
538
371
  * @param request - The HTTP request carrying the headers
539
- * @param reference - The parsed invocation body, or the active legacy session version
372
+ * @param invocation - The parsed invocation body the expectations are derived from
540
373
  * @returns The first header issue, or `undefined` when the applicable headers agree
541
374
  *
542
375
  * @example
@@ -545,30 +378,17 @@ function bridgeMessageTransport(transport) {
545
378
  * issue?.header // 'Mcp-Method' when that field is absent or mismatched
546
379
  * ```
547
380
  */
548
- function inferHeaderIssue(request, reference) {
549
- const protocol = request.headers.get(MCP_PROTOCOL_VERSION_HEADER);
550
- if ((0, _orkestrel_contract.isString)(reference)) {
551
- if (protocol === null) return {
552
- header: "MCP-Protocol-Version",
553
- reason: "missing",
554
- message: `Required MCP-Protocol-Version header is missing; the active session uses '${reference}'.`
555
- };
556
- if (protocol !== reference) return {
557
- header: "MCP-Protocol-Version",
558
- reason: "mismatched",
559
- message: `MCP-Protocol-Version header does not match the active session version '${reference}'.`
560
- };
561
- return;
562
- }
563
- if (!(0, _src_core.isModernRequest)(reference)) {
564
- 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;
565
385
  return {
566
386
  header: "MCP-Protocol-Version",
567
387
  reason: "missing",
568
388
  message: `Required MCP-Protocol-Version header is missing; this server offers '${_src_core.MCP_HANDSHAKE_VERSION}'.`
569
389
  };
570
390
  }
571
- const message = reference;
391
+ const message = invocation;
572
392
  const version = ((0, _orkestrel_contract.isRecord)(message.params?.["_meta"]) ? message.params["_meta"] : void 0)?.[_src_core.MCP_META_VERSION];
573
393
  if (!(0, _orkestrel_contract.isString)(version)) return void 0;
574
394
  if (protocol === null) return {
@@ -581,7 +401,7 @@ function inferHeaderIssue(request, reference) {
581
401
  reason: "mismatched",
582
402
  message: `MCP-Protocol-Version header does not match the request body version '${version}'.`
583
403
  };
584
- const method = request.headers.get(MCP_METHOD_HEADER);
404
+ const method = request.headers.get(_src_core.MCP_METHOD_HEADER);
585
405
  if (method === null) return {
586
406
  header: "Mcp-Method",
587
407
  reason: "missing",
@@ -592,22 +412,107 @@ function inferHeaderIssue(request, reference) {
592
412
  reason: "mismatched",
593
413
  message: `Mcp-Method header does not match the request body method '${message.method}'.`
594
414
  };
595
- if (message.method !== "tools/call") return void 0;
596
- const name = message.params?.["name"];
597
- if (!(0, _orkestrel_contract.isString)(name)) return void 0;
598
- const header = request.headers.get(MCP_NAME_HEADER);
415
+ const target = inferHeaderTarget(message);
416
+ if (target === void 0) return void 0;
417
+ const header = request.headers.get(_src_core.MCP_NAME_HEADER);
599
418
  if (header === null) return {
600
419
  header: "Mcp-Name",
601
420
  reason: "missing",
602
- message: `Required Mcp-Name header is missing; the request body tool name is '${name}'.`
421
+ message: `Required Mcp-Name header is missing; the request body target is '${target}'.`
603
422
  };
604
- if (header !== name) return {
423
+ if ((0, _src_core.decodeSentinel)(header) !== target) return {
605
424
  header: "Mcp-Name",
606
425
  reason: "mismatched",
607
- message: `Mcp-Name header does not match the request body tool name '${name}'.`
426
+ message: `Mcp-Name header does not match the request body target '${target}'.`
427
+ };
428
+ }
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}'.`
608
461
  };
609
462
  }
610
463
  /**
464
+ * Infers the refusal one `tools/call` earns for a `Mcp-Param-*` header the body contradicts.
465
+ *
466
+ * @remarks
467
+ * The custom-header half of the standard-header seam {@link inferHeaderIssue} owns, and it
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
470
+ * parameter claims is another party's header and travels through untouched.
471
+ *
472
+ * For each recognized parameter the body's value at the parameter's own property path fixes
473
+ * the expectation. A value the call omits or supplies as `null` requires no header, and a
474
+ * header sent anyway is refused because it asserts something the body never said. A value the
475
+ * call does supply requires its header: an absent one, a Base64 sentinel whose payload is
476
+ * invalid, and a decoded value that disagrees are each refused. An `integer` parameter
477
+ * compares numerically, so a peer that padded its decimal still matches. A supplied value
478
+ * whose runtime shape contradicts the declared type is left alone — the tool's own argument
479
+ * validation owns that disagreement, and refusing it here would report an argument fault as a
480
+ * header fault.
481
+ *
482
+ * Messages name the field and the body path the expectation came from, and never echo the
483
+ * value the peer supplied.
484
+ *
485
+ * @param request - The HTTP request carrying the headers
486
+ * @param parameters - The projections the served tool definition declares
487
+ * @param values - The call's `arguments` record
488
+ * @returns The refusal message for the first disagreeing parameter, or `undefined`
489
+ *
490
+ * @example
491
+ * ```ts
492
+ * inferParameterRefusal(request, [{ name: 'Region', path: ['region'], primitive: 'string' }], {})
493
+ * // → undefined when the request carries no `Mcp-Param-Region` either
494
+ * ```
495
+ */
496
+ function inferParameterRefusal(request, parameters, values) {
497
+ for (const parameter of parameters) {
498
+ let carried = values;
499
+ for (const key of parameter.path) carried = (0, _orkestrel_contract.isRecord)(carried) ? carried[key] : void 0;
500
+ const field = `${_src_core.MCP_PARAM_PREFIX}${parameter.name}`;
501
+ const path = parameter.path.join(".");
502
+ const header = request.headers.get(field);
503
+ if (carried === void 0 || carried === null) {
504
+ if (header === null) continue;
505
+ return `${field} header carries a value the request body omits at '${path}'.`;
506
+ }
507
+ const expected = (0, _src_core.renderHeaderValue)(carried, parameter.primitive);
508
+ if (expected === void 0) continue;
509
+ if (header === null) return `Required ${field} header is missing; the request body carries '${path}'.`;
510
+ const decoded = (0, _src_core.decodeSentinel)(header);
511
+ if (decoded === void 0) return `${field} header value is not a valid Base64 sentinel.`;
512
+ if (!(parameter.primitive === "integer" ? decoded.trim() !== "" && Number(decoded) === Number(expected) : decoded === expected)) return `${field} header does not match the request body value at '${path}'.`;
513
+ }
514
+ }
515
+ /**
611
516
  * Infers the legacy revision an `initialize` request negotiates.
612
517
  *
613
518
  * @remarks
@@ -648,7 +553,7 @@ function inferStatus(response, era) {
648
553
  return 200;
649
554
  }
650
555
  //#endregion
651
- //#region src/server/transports/HTTPDisconnect.ts
556
+ //#region src/server/HTTPDisconnect.ts
652
557
  /**
653
558
  * Composes one incoming HTTP request lifetime with one MCP-owned SSE response lifetime.
654
559
  *
@@ -677,10 +582,10 @@ function inferStatus(response, era) {
677
582
  * @example
678
583
  * ```ts
679
584
  * import { HTTPDisconnect } from '@orkestrel/mcp/server'
680
- * import { openStream } from '@orkestrel/server'
585
+ * import { createStream } from '@orkestrel/server'
681
586
  *
682
587
  * const disconnect = new HTTPDisconnect(request.signal, { interval: 15_000 })
683
- * const stream = openStream()
588
+ * const stream = createStream()
684
589
  * const response = disconnect.bridge(stream)
685
590
  * ```
686
591
  */
@@ -689,6 +594,9 @@ var HTTPDisconnect = class {
689
594
  #lifecycle = new AbortController();
690
595
  #interval;
691
596
  #signal;
597
+ #pull = (controller) => this.#pump(controller);
598
+ #cancel = (reason) => this.#discard(reason);
599
+ #reader;
692
600
  #timer;
693
601
  #bridged = false;
694
602
  #pulling = false;
@@ -705,8 +613,8 @@ var HTTPDisconnect = class {
705
613
  this.#signal = AbortSignal.any([signal, this.#response.signal]);
706
614
  }
707
615
  /**
708
- * The signal aborted by the incoming request, or by any end of this response that is not
709
- * 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.
710
618
  *
711
619
  * @returns The composed lifecycle signal
712
620
  */
@@ -732,7 +640,7 @@ var HTTPDisconnect = class {
732
640
  const response = stream.response;
733
641
  const body = response.body;
734
642
  if (body === null) throw new Error("MCP SSE response has no body");
735
- const reader = body.getReader();
643
+ this.#reader = body.getReader();
736
644
  this.#timer = setInterval(() => {
737
645
  if (stream.closed) {
738
646
  if (!this.#pulling) this.#abort();
@@ -744,29 +652,39 @@ var HTTPDisconnect = class {
744
652
  });
745
653
  if (this.#signal.aborted) this.#release();
746
654
  else if (stream.closed) this.#abort();
747
- return new Response(createReadableStream(async (controller) => {
748
- this.#pulling = true;
749
- try {
750
- const chunk = await reader.read();
751
- if (chunk.done) {
752
- this.#release();
753
- controller.close();
754
- } else controller.enqueue(chunk.value);
755
- } catch (error) {
756
- this.#abort();
757
- controller.error(error);
758
- } finally {
759
- this.#pulling = false;
760
- }
761
- }, async (reason) => {
762
- this.#abort();
763
- await reader.cancel(reason);
655
+ return new Response(new ReadableStream({
656
+ pull: this.#pull,
657
+ cancel: this.#cancel
764
658
  }), {
765
659
  status: response.status,
766
660
  statusText: response.statusText,
767
661
  headers: response.headers
768
662
  });
769
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
+ }
770
688
  #release() {
771
689
  if (this.#timer !== void 0) {
772
690
  clearInterval(this.#timer);
@@ -785,12 +703,17 @@ var HTTPDisconnect = class {
785
703
  * Creates the Streamable-HTTP POST handler used by `createMCPRoutes`.
786
704
  *
787
705
  * @remarks
788
- * Modern requests require matching protocol/method headers and a matching name header only
789
- * for `tools/call`; mismatch returns HTTP `400` + `-32020`. Headerless `initialize` is
790
- * accepted, while every other headerless request needs a live legacy session to supply its
791
- * pinned version. A legacy-shaped request carrying a protocol header is admitted only for a
792
- * legacy revision; any other value, the modern revision included, returns HTTP `400` + `-32022`
793
- * whose `supported` names the legacy revisions this door accepts. A present origin must occur in `origin.origins` unless validation is
706
+ * Modern requests require matching protocol/method headers and a matching name header on each
707
+ * method carrying a named target — `tools/call` and `prompts/get` against `params.name`,
708
+ * `resources/read` against `params.uri` with a Base64-sentinel value decoded before the
709
+ * comparison; a missing, mismatched, or invalidly encoded value returns HTTP `400` + `-32020`.
710
+ * A protocol header naming a MODERN revision holds the request to that revision whatever shape
711
+ * its body arrived in, so a body with no parsable modern `_meta` returns HTTP `400` + `-32602`.
712
+ * Headerless `initialize` is accepted, while every other headerless request needs a live legacy
713
+ * session to supply its pinned version. A legacy-shaped request carrying a protocol header is
714
+ * otherwise admitted only for a legacy revision; a revision this server does not implement
715
+ * returns HTTP `400` + `-32022` whose `supported` names the legacy revisions this door accepts.
716
+ * A present origin must occur in `origin.origins` unless validation is
794
717
  * explicitly delegated upstream. Modern dispatch errors use their protocol status map; legacy
795
718
  * errors remain in-band at HTTP `200`. A streamed response composes the fetch-standard request
796
719
  * signal with response-body cancellation and supplies the result to every dispatched modern
@@ -828,22 +751,49 @@ function createMCPPostHandler(mcp, options) {
828
751
  } catch {
829
752
  return Response.json((0, _src_core.buildJSONRPCError)(void 0, _src_core.JSONRPC_PARSE_ERROR, "Parse error"), { status: 400 });
830
753
  }
831
- let parsed;
832
- try {
833
- parsed = JSON.parse(text);
834
- } catch {
835
- return Response.json((0, _src_core.buildJSONRPCError)(void 0, _src_core.JSONRPC_PARSE_ERROR, "Parse error"), { status: 400 });
836
- }
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 });
837
756
  const invocation = (0, _src_core.parseJSONRPCMessage)(parsed);
838
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 });
839
- const era = (0, _src_core.isModernRequest)(invocation) ? "modern" : "legacy";
758
+ const era = (0, _src_core.inferRequestEra)(invocation);
840
759
  const id = invocation.id;
841
- const protocol = request.headers.get(MCP_PROTOCOL_VERSION_HEADER);
842
- if (era === "modern") {
760
+ const protocol = request.headers.get(_src_core.MCP_PROTOCOL_VERSION_HEADER);
761
+ if (era === "modern" || (0, _src_core.isMCPModernVersion)(protocol)) {
843
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 });
844
763
  }
845
764
  const issue = inferHeaderIssue(request, invocation);
846
765
  if (issue !== void 0) return Response.json((0, _src_core.buildJSONRPCError)(id, _src_core.MCP_HEADER_MISMATCH, issue.message), { status: 400 });
766
+ const called = invocation.params?.["name"];
767
+ if (era === "modern" && invocation.method === "tools/call" && (0, _orkestrel_contract.isString)(called)) {
768
+ let parameters = [];
769
+ let cursor = void 0;
770
+ for (let page = 0; page < _src_core.MCP_LOOKUP_PAGES; page += 1) {
771
+ const answer = await mcp.dispatch({
772
+ jsonrpc: "2.0",
773
+ id: 0,
774
+ method: "tools/list",
775
+ params: {
776
+ _meta: invocation.params?.["_meta"],
777
+ ...cursor === void 0 ? {} : { cursor }
778
+ }
779
+ });
780
+ if (Symbol.asyncIterator in answer) {
781
+ answer.stop();
782
+ break;
783
+ }
784
+ const schema = (0, _src_core.extractToolSchema)(answer, called);
785
+ if (schema !== void 0) {
786
+ parameters = (0, _src_core.buildHeaderParameters)(schema) ?? [];
787
+ break;
788
+ }
789
+ const listing = answer.result;
790
+ const next = (0, _orkestrel_contract.isRecord)(listing) ? listing["nextCursor"] : void 0;
791
+ if (!(0, _orkestrel_contract.isString)(next)) break;
792
+ cursor = next;
793
+ }
794
+ const refusal = inferParameterRefusal(request, parameters, invocation.params?.["arguments"]);
795
+ if (refusal !== void 0) return Response.json((0, _src_core.buildJSONRPCError)(id, _src_core.MCP_HEADER_MISMATCH, refusal), { status: 400 });
796
+ }
847
797
  if (era === "legacy") {
848
798
  if (protocol !== null && !(0, _src_core.isMCPLegacyVersion)(protocol)) return Response.json((0, _src_core.buildJSONRPCError)(id, _src_core.MCP_UNSUPPORTED_VERSION, `Unsupported MCP protocol version '${protocol}'`, {
849
799
  supported: _src_core.SUPPORTED_LEGACY_PROTOCOL_VERSIONS,
@@ -857,7 +807,7 @@ function createMCPPostHandler(mcp, options) {
857
807
  ...caller === void 0 ? {} : { caller }
858
808
  });
859
809
  if (response !== void 0 && Symbol.asyncIterator in response) {
860
- const stream = (0, _orkestrel_server.openStream)();
810
+ const stream = (0, _orkestrel_server.createStream)();
861
811
  stream.response.headers.set(SSE_BUFFERING_HEADER, "no");
862
812
  queueMicrotask(() => void sendEventStream(response, stream));
863
813
  return disconnect.bridge(stream);
@@ -865,7 +815,7 @@ function createMCPPostHandler(mcp, options) {
865
815
  const status = inferStatus(response, era);
866
816
  if (response === void 0) return new Response(null, { status });
867
817
  if (status === 200 && streaming && acceptsEventStream(request)) {
868
- const stream = (0, _orkestrel_server.openStream)();
818
+ const stream = (0, _orkestrel_server.createStream)();
869
819
  stream.response.headers.set(SSE_BUFFERING_HEADER, "no");
870
820
  stream.write({ data: JSON.stringify(response) });
871
821
  stream.end();
@@ -875,172 +825,14 @@ function createMCPPostHandler(mcp, options) {
875
825
  };
876
826
  }
877
827
  //#endregion
878
- //#region src/server/transports/HTTPClientTransport.ts
879
- /**
880
- * The HTTP CLIENT transport for the Model Context Protocol — a
881
- * {@link MCPClientTransportInterface} that drives a REMOTE Streamable-HTTP MCP server over
882
- * `fetch`, the egress mirror of the server's `createMCPRoutes`.
883
- *
884
- * @remarks
885
- * - **Request/response over `fetch`.** `send(message)` POSTs the JSON-serialized
886
- * message to `options.url` with `content-type: application/json` and an
887
- * `Accept` of BOTH `application/json` and `text/event-stream` (so the server may
888
- * answer with either framing) — plus any `options.headers` (for example, an `Authorization`
889
- * bearer). It then decodes the reply and emits each decoded {@link JSONRPCMessage} on
890
- * the `message` event the {@link import('@orkestrel/mcp').MCPClientInterface} subscribes
891
- * to.
892
- * - **Both reply framings.** A `200` with an `application/json` body is parsed with
893
- * `parseJSONRPCMessage`; a `200` with a `text/event-stream` body is decoded with the
894
- * `@orkestrel/sse` {@link import('@orkestrel/sse').SSEParserInterface} ({@link
895
- * readEventStream}) — the inverse of the server's `openStream` seam, so the wire
896
- * round-trips. A `202`
897
- * Accepted (a notification) carries no body and emits nothing.
898
- * - **Session and protocol headers.** `start()` is a no-op (a
899
- * request/response transport opens no long-lived connection). The
900
- * `mcp-session-id` response header, when a STATEFUL server sends one (on
901
- * `initialize`), is captured into `session` and then ECHOED as the
902
- * `mcp-session-id` request header on every SUBSEQUENT request — so an
903
- * `MCPClient` passes a stateful server's session validation. The
904
- * initialize result's `protocolVersion` is likewise captured, but only
905
- * when it is a SUPPORTED value, and echoed as `mcp-protocol-version` alone on
906
- * subsequent legacy requests. Modern requests instead derive protocol and method
907
- * headers from the message, plus the name header only for `tools/call`.
908
- * Before initialize returns, neither captured legacy header is sent.
909
- * `close()` clears the captured protocol so a reconnect's `initialize`
910
- * POST is headerless; the captured `session` persists across `close()`.
911
- * - **`close()` releases what is in flight.** Every `fetch` this transport still has open is
912
- * ABORTED, which cancels the response body a `send` is reading — an SSE reply the server
913
- * never ends would otherwise outlive the transport, with nothing left able to reach it. The
914
- * aborted read surfaces on `error` and the `send` reporting it resolves. `close()` is
915
- * idempotent (one `close` event per connected lifetime), and `start()` opens the next one.
916
- * - **Total at the boundary.** Every reply is narrowed (`parseJSONRPCMessage`,
917
- * the SSE decoder). A non-message success reply is dropped, never asserted. A non-success
918
- * reply that carries no valid JSON-RPC message rejects `send` with its HTTP status and body
919
- * shape. A valid JSON-RPC error body is emitted at any HTTP status. A `fetch` / decode failure
920
- * on a success response surfaces on the `error` event rather than escaping `send`.
921
- * - **Observable.** Owns the `emitter` ({@link MCPClientTransportEventMap}); fires
922
- * `message` per decoded reply, `error` on a fault, and `close` on `close()`.
923
- *
924
- * @example
925
- * ```ts
926
- * const transport = new HTTPClientTransport({ url: 'http://localhost:3000/mcp' })
927
- * const client = new MCPClient({ transport })
928
- * await client.connect()
929
- * ```
930
- */
931
- var HTTPClientTransport = class {
932
- #emitter;
933
- #url;
934
- #headers;
935
- #fetch;
936
- #timeout;
937
- #pending = /* @__PURE__ */ new Set();
938
- #session = void 0;
939
- #protocol = void 0;
940
- #closed = false;
941
- constructor(options) {
942
- this.#emitter = new _orkestrel_emitter.Emitter();
943
- this.#url = options.url;
944
- this.#headers = options.headers ?? {};
945
- this.#fetch = options.fetch ?? globalThis.fetch;
946
- this.#timeout = options.timeout;
947
- }
948
- get emitter() {
949
- return this.#emitter;
950
- }
951
- get session() {
952
- return this.#session;
953
- }
954
- get duplex() {
955
- return false;
956
- }
957
- async start() {
958
- this.#closed = false;
959
- }
960
- async send(message) {
961
- const request = new AbortController();
962
- this.#pending.add(request);
963
- try {
964
- await this.#exchange(message, request.signal);
965
- } finally {
966
- this.#pending.delete(request);
967
- }
968
- }
969
- async #exchange(message, signal) {
970
- let response;
971
- try {
972
- response = await this.#fetch(this.#url, {
973
- method: "POST",
974
- headers: {
975
- "content-type": "application/json",
976
- accept: "application/json, text/event-stream",
977
- ...this.#session === void 0 ? {} : { [MCP_SESSION_HEADER]: this.#session },
978
- ...this.#buildHeaders(message),
979
- ...this.#headers
980
- },
981
- body: JSON.stringify(message),
982
- signal: this.#timeout === void 0 ? signal : AbortSignal.any([signal, AbortSignal.timeout(this.#timeout)])
983
- });
984
- } catch (error) {
985
- this.#emitter.emit("error", error);
986
- return;
987
- }
988
- const session = response.headers.get(MCP_SESSION_HEADER);
989
- if (session !== null) this.#session = session;
990
- await this.#deliver(response);
991
- }
992
- async close() {
993
- if (this.#closed) return;
994
- this.#closed = true;
995
- for (const request of this.#pending) request.abort();
996
- this.#pending.clear();
997
- this.#protocol = void 0;
998
- this.#emitter.emit("close");
999
- }
1000
- #buildHeaders(message) {
1001
- if ((0, _src_core.isModernRequest)(message)) {
1002
- const version = (0, _src_core.inferRequestVersion)(message);
1003
- const name = message.params?.["name"];
1004
- return {
1005
- ...version === void 0 ? {} : { [MCP_PROTOCOL_VERSION_HEADER]: version },
1006
- [MCP_METHOD_HEADER]: message.method,
1007
- ...message.method === "tools/call" && (0, _orkestrel_contract.isString)(name) ? { [MCP_NAME_HEADER]: name } : {}
1008
- };
1009
- }
1010
- return this.#protocol === void 0 ? {} : { [MCP_PROTOCOL_VERSION_HEADER]: this.#protocol };
1011
- }
1012
- async #deliver(response) {
1013
- if (response.status === 202) return;
1014
- const type = response.headers.get("content-type") ?? "";
1015
- let messages = [];
1016
- let failure;
1017
- try {
1018
- if (type.includes("text/event-stream")) messages = await readEventStream(response);
1019
- else if (type.includes("application/json")) {
1020
- const message = (0, _src_core.parseJSONRPCMessage)(await response.json());
1021
- if (message !== void 0) messages = [message];
1022
- }
1023
- } catch (error) {
1024
- failure = { error };
1025
- }
1026
- for (const message of messages) this.#capture(message);
1027
- if (!response.ok && messages.length === 0) throw buildResponseError(response, type);
1028
- if (failure !== void 0) this.#emitter.emit("error", failure.error);
1029
- }
1030
- #capture(message) {
1031
- if ((0, _src_core.isJSONRPCResponse)(message) && (0, _orkestrel_contract.isRecord)(message.result) && (0, _src_core.isMCPVersion)(message.result["protocolVersion"])) this.#protocol = message.result["protocolVersion"];
1032
- this.#emitter.emit("message", message);
1033
- }
1034
- };
1035
- //#endregion
1036
828
  //#region src/server/MCPSession.ts
1037
829
  /**
1038
- * One MCP transport session — the per-session entity a {@link
830
+ * Represents one MCP transport session — the per-session entity a {@link
1039
831
  * import('./middlewares.js').createMCPSession} middleware owns, keyed by its `id`, carrying the
1040
832
  * resumable server→client push channel with its bounded replay log FOLDED IN.
1041
833
  *
1042
834
  * @remarks
1043
- * The single session entity (the old `SessionState` + `EventStore` merged): it holds the
835
+ * One entity carries the whole session: it holds the
1044
836
  * session `id`, its OWN bounded, replayable log of pushed server→client messages (the
1045
837
  * resumable GET-SSE channel — a private `#events` `Map` + a monotone `#counter`, with
1046
838
  * `capacity` / `ttl` eviction, not a separate store), and the set of open
@@ -1052,7 +844,7 @@ var HTTPClientTransport = class {
1052
844
  * SSE event (`stream.write({ id, data })`). A push with NO attached stream is still logged,
1053
845
  * so a client that connects (or reconnects with a `Last-Event-ID`) LATER replays it from the
1054
846
  * log. A `write` to a closed stream is a safe no-op (the {@link
1055
- * `@orkestrel/server`'s `openStream` contract), so a just-disconnected stream that
847
+ * `@orkestrel/server`'s `createStream` contract), so a just-disconnected stream that
1056
848
  * has not yet been `detach`ed never throws. A replayed event and the live one carry the
1057
849
  * IDENTICAL id (the log assigns it once).
1058
850
  *
@@ -1077,9 +869,10 @@ var HTTPClientTransport = class {
1077
869
  * The middleware opens the stream (the spine seam) and registers it here; this class only
1078
870
  * serializes a message onto the already-open streams.
1079
871
  *
1080
- * - **Injected clock.** `push` / `replay` accept an optional `now` (epoch ms), defaulting to
1081
- * `Date.now()` — so a test drives TTL eviction with an elapsed clock rather than a real
1082
- * 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.
1083
876
  *
1084
877
  * @example
1085
878
  * ```ts
@@ -1095,11 +888,13 @@ var MCPSession = class {
1095
888
  #streams = /* @__PURE__ */ new Set();
1096
889
  #capacity;
1097
890
  #ttl;
891
+ #clock;
1098
892
  #counter = 0;
1099
893
  constructor(id, options) {
1100
894
  this.#id = id;
1101
895
  this.#capacity = options?.capacity ?? 1024;
1102
896
  this.#ttl = options?.ttl ?? 3e5;
897
+ this.#clock = options?.clock ?? Date.now;
1103
898
  }
1104
899
  get id() {
1105
900
  return this.#id;
@@ -1110,8 +905,8 @@ var MCPSession = class {
1110
905
  detach(stream) {
1111
906
  this.#streams.delete(stream);
1112
907
  }
1113
- push(message, now = Date.now()) {
1114
- const id = this.#append(message, now);
908
+ push(message) {
909
+ const id = this.#append(message);
1115
910
  const data = JSON.stringify(message);
1116
911
  for (const stream of this.#streams) stream.write({
1117
912
  id,
@@ -1119,15 +914,16 @@ var MCPSession = class {
1119
914
  });
1120
915
  return id;
1121
916
  }
1122
- replay(afterId, now = Date.now()) {
1123
- this.#evict(now);
917
+ replay(afterId) {
918
+ this.#evict(this.#clock());
1124
919
  const out = [];
1125
920
  let found = false;
1126
921
  for (const entry of this.#events.values()) if (found) out.push(entry);
1127
922
  else if (entry.id === afterId) found = true;
1128
923
  return found ? out : [];
1129
924
  }
1130
- #append(message, now) {
925
+ #append(message) {
926
+ const now = this.#clock();
1131
927
  this.#evict(now);
1132
928
  this.#counter += 1;
1133
929
  const id = this.#counter.toString(36);
@@ -1153,34 +949,39 @@ var MCPSession = class {
1153
949
  //#endregion
1154
950
  //#region src/server/transports/WebSocketServerTransport.ts
1155
951
  /**
1156
- * The per-connection JSON-RPC-over-WebSocket SERVER bridge wraps a
1157
- * {@link NodeWebSocketInterface} (the RFC 6455 wire wrapper) as a
1158
- * {@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
1159
955
  * `createWebSocketServer` pumps `mcp.dispatch` over and the egress mirror's
1160
956
  * {@link import('./WebSocketClientTransport.js').WebSocketClientTransport} reuses.
1161
957
  *
1162
958
  * @remarks
1163
- * - **Reuses `MCPClientTransportInterface`.** It IS the same generic carrier the HTTP
959
+ * - **Reuses `MCPMessageTransportInterface`.** It IS the same generic carrier the HTTP
1164
960
  * client transport implements — `emitter` (`message` / `close` / `error`), `start`,
1165
961
  * `send`, `close` — so the WebSocket server and client both speak ONE transport contract,
1166
962
  * no near-duplicate sibling interface. `session` is `undefined` (the stateless v1; a
1167
963
  * session id is the deferred sessions tier). The name keeps the role explicit even though
1168
964
  * the shape is shared.
1169
965
  * - **Inbound (`message`).** `start()` subscribes to the socket's `message` event; each text
1170
- * 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
1171
967
  * well-formed {@link JSONRPCMessage} is re-emitted on this transport's `message` event (the
1172
968
  * parsed envelope the {@link import('@orkestrel/mcp').MCPServerInterface} pump dispatches), while
1173
969
  * a non-JSON or non-message frame is surfaced on `error` and DROPPED, never thrown. It
1174
970
  * also bridges the socket's `close` → this transport's `close`, and the socket's `error`.
1175
971
  * - **Outbound (`send`).** `send(message)` writes one text frame
1176
- * (`nodeWs.send(JSON.stringify(message))`); the underlying wrapper no-ops a write on a
1177
- * non-open socket, so a closed connection drops silently rather than throwing.
972
+ * (`nodeWs.send(JSON.stringify(message))`). The underlying wrapper no-ops a write on a
973
+ * non-open socket and confirms nothing, so this bridge answers a closed channel from its own
974
+ * state and the socket's `readyState`: a `send` after `close()`, after the peer's close, or on
975
+ * a socket that is not `OPEN` REJECTS with `WebSocket transport is not connected` rather than
976
+ * resolving on a frame nobody wrote. `bindServer` catches that rejection and routes it to the
977
+ * dispatcher's `error` event, and it aborts every in-flight request the moment this transport's
978
+ * `close` fires — so a peer that disconnects mid-request is answered by no write at all.
1178
979
  * - **`close()`** removes the subscriptions `start()` installed on the socket, closes the
1179
980
  * underlying socket (the RFC 6455 close handshake), and fires the transport's `close` event
1180
981
  * (idempotent — a second `close`, or a socket-driven close, emits once). A frame that arrives
1181
982
  * between that release and the peer's close echo reaches nothing: the socket-driven close path
1182
983
  * releases the same way, so a closed transport is never subscribed to a live socket.
1183
- * - **Observable.** Owns the `emitter` ({@link MCPClientTransportEventMap}); the emitter
984
+ * - **Observable.** Owns the `emitter` ({@link MCPMessageTransportEventMap}); the emitter
1184
985
  * isolates a listener throw (a buggy observer never corrupts the bridge). `error` is a
1185
986
  * DOMAIN event (a transport-level fault), distinct from the emitter's listener-error channel.
1186
987
  */
@@ -1211,6 +1012,7 @@ var WebSocketServerTransport = class {
1211
1012
  this.#socket.emitter.on("error", this.#failure);
1212
1013
  }
1213
1014
  async send(message) {
1015
+ if (this.#closed || this.#socket.readyState !== _orkestrel_websocket.WEBSOCKET_READY_OPEN) throw new Error("WebSocket transport is not connected");
1214
1016
  this.#socket.send(JSON.stringify(message));
1215
1017
  }
1216
1018
  async close() {
@@ -1221,19 +1023,7 @@ var WebSocketServerTransport = class {
1221
1023
  this.#emitter.emit("close");
1222
1024
  }
1223
1025
  #receive(text) {
1224
- let parsed;
1225
- try {
1226
- parsed = JSON.parse(text);
1227
- } catch (error) {
1228
- this.#emitter.emit("error", error);
1229
- return;
1230
- }
1231
- const message = (0, _src_core.parseJSONRPCMessage)(parsed);
1232
- if (message === void 0) {
1233
- this.#emitter.emit("error", /* @__PURE__ */ new Error("non-JSON-RPC WebSocket frame"));
1234
- return;
1235
- }
1236
- this.#emitter.emit("message", message);
1026
+ (0, _src_core.deliverMessage)(this.#emitter, text, "non-JSON-RPC WebSocket frame");
1237
1027
  }
1238
1028
  #onClose() {
1239
1029
  if (this.#closed) return;
@@ -1250,10 +1040,10 @@ var WebSocketServerTransport = class {
1250
1040
  //#endregion
1251
1041
  //#region src/server/transports/WebSocketClientTransport.ts
1252
1042
  /**
1253
- * The WebSocket CLIENT transport for the Model Context Protocol — a
1254
- * {@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
1255
1045
  * egress mirror of {@link import('./factories.js').createWebSocketServer} and the WebSocket
1256
- * sibling of {@link import('./HTTPClientTransport.js').HTTPClientTransport}.
1046
+ * sibling of {@link import('@orkestrel/mcp').HTTPClientTransport}.
1257
1047
  *
1258
1048
  * @remarks
1259
1049
  * - **Persistent bidirectional channel (unlike the HTTP transport).** `start()` performs the
@@ -1271,16 +1061,18 @@ var WebSocketServerTransport = class {
1271
1061
  * transport while the handshake was on the wire, both WIN — the socket that arrives late is
1272
1062
  * DESTROYED and never bound, so no orphan is left re-emitting frames at nobody. Both
1273
1063
  * `start()` calls still resolve; exactly one socket is ever bound.
1274
- * - **Inbound (`message`).** Each decoded text frame is `JSON.parse`d (guarded) and narrowed
1275
- * 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`
1276
1066
  * event (the reply the {@link import('@orkestrel/mcp').MCPClientInterface} correlates by `id`); a
1277
1067
  * non-JSON / non-message frame surfaces on `error` and is dropped. The socket's `close`
1278
1068
  * / `error` bridge to this transport's events.
1279
1069
  * - **Outbound (`send`).** `send(message)` writes one masked text frame. A socket write is not
1280
- * confirmed, so this transport answers a closed channel from its OWN state: a `send` with no
1281
- * bound socket — before `start()`, after `close()`, or after the peer ended the socket —
1282
- * REJECTS with `WebSocket transport is not connected`. It neither drops the message (the
1283
- * browser face's posture) nor queues it for a connection this transport is not holding.
1070
+ * confirmed, so this transport answers a closed channel from its own state AND the socket's
1071
+ * `readyState`: a `send` with no bound socket — before `start()`, after `close()`, or after the
1072
+ * peer ended the socket — and a `send` on a bound socket that is not `OPEN` both REJECT with
1073
+ * `WebSocket transport is not connected`. It neither drops the message nor queues it for a
1074
+ * connection this transport is not holding — the browser face queues a pre-open send, and this
1075
+ * one, holding no connection to flush it onto, rejects that too.
1284
1076
  * - **`close()`** unsubscribes from the socket, closes it, and fires `close` (idempotent). An
1285
1077
  * upgrade still on the wire is DESTROYED, so a `close()` during the handshake ends the
1286
1078
  * transport at once instead of waiting for a peer that may never answer — the suspended
@@ -1288,7 +1080,7 @@ var WebSocketServerTransport = class {
1288
1080
  * - **URL scheme.** `options.url` accepts a `ws://` / `wss://` URL or an `http://` / `https://`
1289
1081
  * one; a `ws(s)` scheme is converted to `http(s)` for the underlying upgrade request (`wss`
1290
1082
  * → TLS through `node:https`). Either reaches the same endpoint.
1291
- * - **Observable.** Owns the `emitter` ({@link MCPClientTransportEventMap}); every emit
1083
+ * - **Observable.** Owns the `emitter` ({@link MCPMessageTransportEventMap}); every emit
1292
1084
  * the emitter isolates a listener throw (a buggy observer never corrupts the transport);
1293
1085
  * `error` is a DOMAIN event (a transport-level fault).
1294
1086
  *
@@ -1325,14 +1117,14 @@ var WebSocketClientTransport = class {
1325
1117
  if (this.#socket !== void 0) return;
1326
1118
  this.#closed = false;
1327
1119
  try {
1328
- 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"));
1329
1121
  } finally {
1330
1122
  this.#request = void 0;
1331
1123
  }
1332
1124
  }
1333
1125
  async send(message) {
1334
1126
  const socket = this.#socket;
1335
- if (socket === void 0) throw new Error("WebSocket transport is not connected");
1127
+ if (socket === void 0 || socket.readyState !== _orkestrel_websocket.WEBSOCKET_READY_OPEN) throw new Error("WebSocket transport is not connected");
1336
1128
  socket.send(JSON.stringify(message));
1337
1129
  }
1338
1130
  async close() {
@@ -1358,7 +1150,7 @@ var WebSocketClientTransport = class {
1358
1150
  Upgrade: "websocket",
1359
1151
  "Sec-WebSocket-Key": key,
1360
1152
  "Sec-WebSocket-Version": _orkestrel_websocket.WEBSOCKET_VERSION,
1361
- "Sec-WebSocket-Protocol": "mcp",
1153
+ "Sec-WebSocket-Protocol": _src_core.MCP_WEBSOCKET_SUBPROTOCOL,
1362
1154
  ...this.#headers
1363
1155
  }
1364
1156
  });
@@ -1410,19 +1202,7 @@ var WebSocketClientTransport = class {
1410
1202
  socket.emitter.off("error", this.#failure);
1411
1203
  }
1412
1204
  #receive(text) {
1413
- let parsed;
1414
- try {
1415
- parsed = JSON.parse(text);
1416
- } catch (error) {
1417
- this.#emitter.emit("error", error);
1418
- return;
1419
- }
1420
- const message = (0, _src_core.parseJSONRPCMessage)(parsed);
1421
- if (message === void 0) {
1422
- this.#emitter.emit("error", /* @__PURE__ */ new Error("non-JSON-RPC WebSocket frame"));
1423
- return;
1424
- }
1425
- this.#emitter.emit("message", message);
1205
+ (0, _src_core.deliverMessage)(this.#emitter, text, "non-JSON-RPC WebSocket frame");
1426
1206
  }
1427
1207
  #onClose() {
1428
1208
  if (this.#closed) return;
@@ -1431,7 +1211,7 @@ var WebSocketClientTransport = class {
1431
1211
  this.#socket = void 0;
1432
1212
  this.#emitter.emit("close");
1433
1213
  }
1434
- #httpURL() {
1214
+ #toHTTPURL() {
1435
1215
  const url = new URL(this.#url);
1436
1216
  if (url.protocol === "ws:") url.protocol = "http:";
1437
1217
  else if (url.protocol === "wss:") url.protocol = "https:";
@@ -1442,10 +1222,9 @@ var WebSocketClientTransport = class {
1442
1222
  //#endregion
1443
1223
  //#region src/server/transports/StdioClientTransport.ts
1444
1224
  /**
1445
- * The stdio CLIENT transport for the Model Context Protocol a
1446
- * {@link StdioClientTransportInterface} that drives a CHILD PROCESS MCP server over
1447
- * newline-delimited JSON-RPC on `stdin`/`stdout`, the stdio sibling of {@link
1448
- * 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
1449
1228
  * import('./WebSocketClientTransport.js').WebSocketClientTransport}.
1450
1229
  *
1451
1230
  * @remarks
@@ -1486,7 +1265,7 @@ var WebSocketClientTransport = class {
1486
1265
  * never moves again, so a detached descendant writing to the inherited stderr after the cutoff
1487
1266
  * cannot grow it. See {@link StdioClientTransportInterface.evidence} for the readings and the
1488
1267
  * byte bound.
1489
- * - **Observable.** Owns the `emitter` ({@link MCPClientTransportEventMap}); the
1268
+ * - **Observable.** Owns the `emitter` ({@link MCPMessageTransportEventMap}); the
1490
1269
  * emitter isolates a listener throw; `error` is a DOMAIN event (a transport-level
1491
1270
  * fault, including the child spawn cause the supervisor surfaces and the notice that this
1492
1271
  * lifetime's `evidence` was cut off at the `drain` bound), distinct from the emitter's own
@@ -1608,15 +1387,14 @@ var StdioClientTransport = class {
1608
1387
  //#endregion
1609
1388
  //#region src/server/transports/StdioServerTransport.ts
1610
1389
  /**
1611
- * The stdio SERVER transport for the Model Context Protocol — wraps an injectable
1612
- * readable/writable stream pair (`process.stdin`/`process.stdout` in production, a
1613
- * test double in tests) as a {@link MCPClientTransportInterface}, the newline-delimited
1614
- * JSON-RPC channel {@link import('../factories.js').createStdioServer} pumps
1615
- * `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
1616
1394
  * import('./WebSocketServerTransport.js').WebSocketServerTransport}.
1617
1395
  *
1618
1396
  * @remarks
1619
- * - **Reuses `MCPClientTransportInterface`.** The same generic carrier the HTTP
1397
+ * - **Reuses `MCPMessageTransportInterface`.** The same generic carrier the HTTP
1620
1398
  * and WebSocket server transports implement — `emitter` (`message` / `close` /
1621
1399
  * `error`), `start`, `send`, `close`. `session` is `undefined` (the stateless v1).
1622
1400
  * - **Inbound (`message`).** `start()` subscribes to `input`'s `data` event; each
@@ -1642,7 +1420,7 @@ var StdioClientTransport = class {
1642
1420
  * listener receives data. The injected streams are owned by the caller (typically
1643
1421
  * `process.stdin`/`process.stdout`), so the transport never destroys, ends, or blanket-clears
1644
1422
  * them.
1645
- * - **Observable.** Owns the `emitter` ({@link MCPClientTransportEventMap}); the
1423
+ * - **Observable.** Owns the `emitter` ({@link MCPMessageTransportEventMap}); the
1646
1424
  * emitter isolates a listener throw; `error` is a DOMAIN event (a transport-level
1647
1425
  * fault), distinct from the emitter's own listener-error channel.
1648
1426
  */
@@ -1748,6 +1526,87 @@ function createMCPContinuation(secret) {
1748
1526
  };
1749
1527
  }
1750
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 two 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
+ /**
1751
1610
  * Creates the MCP Streamable-HTTP transport routes — mounts a transport-agnostic
1752
1611
  * {@link MCPDispatcherInterface} (the `@orkestrel/mcp` dispatch boundary) on the fetch-standard router
1753
1612
  * spine, pumping each `POST` body through `mcp.dispatch`. Returns the {@link RouteInput}s to
@@ -1773,7 +1632,7 @@ function createMCPContinuation(secret) {
1773
1632
  * When `streaming` is enabled (the default) and the client `Accept`s `text/event-stream`,
1774
1633
  * the `200` reply is framed as a Streamable-HTTP SSE response (one `data:` event carrying
1775
1634
  * the JSON-RPC envelope, then the stream ends) through `@orkestrel/server`'s generic
1776
- * {@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.
1777
1636
  *
1778
1637
  * **Sessions are a SEPARATE, plug-and-play middleware.** `createMCPRoutes` mints / reads no
1779
1638
  * session id. To make the transport STATEFUL, mount {@link
@@ -1812,10 +1671,15 @@ function createMCPRoutes(mcp, options) {
1812
1671
  }
1813
1672
  /**
1814
1673
  * Creates the HTTP CLIENT transport for an {@link import('@orkestrel/mcp').MCPClientInterface}
1815
- * — a {@link MCPClientTransportInterface} that drives a REMOTE Streamable-HTTP MCP server
1674
+ * — a {@link MCPMessageTransportInterface} that drives a REMOTE Streamable-HTTP MCP server
1816
1675
  * over `fetch`. The egress mirror of {@link createMCPRoutes}.
1817
1676
  *
1818
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
1819
1683
  * Hand it to `createMCPClient({ transport })`: each JSON-RPC message the client sends is
1820
1684
  * `POST`ed to `options.url` with `content-type: application/json` and an `Accept` of
1821
1685
  * both `application/json` and `text/event-stream` (the server answers with EITHER — a
@@ -1831,7 +1695,7 @@ function createMCPRoutes(mcp, options) {
1831
1695
  * @param options - `url` (the remote endpoint; REQUIRED), optional `headers` merged onto
1832
1696
  * every request, optional `fetch` (default `globalThis.fetch`), and optional `timeout`
1833
1697
  * (ms, applied with `AbortSignal.timeout`); see {@link HTTPClientTransportOptions}
1834
- * @returns A working {@link MCPClientTransportInterface} over `fetch`
1698
+ * @returns A working {@link MCPMessageTransportInterface} over `fetch`
1835
1699
  *
1836
1700
  * @example
1837
1701
  * ```ts
@@ -1846,7 +1710,7 @@ function createMCPRoutes(mcp, options) {
1846
1710
  * ```
1847
1711
  */
1848
1712
  function createHTTPClientTransport(options) {
1849
- return new HTTPClientTransport(options);
1713
+ return new _src_core.HTTPClientTransport(options);
1850
1714
  }
1851
1715
  /**
1852
1716
  * Creates the MCP WebSocket transport INGRESS — an {@link UpgradeHandler} that exposes a
@@ -1868,7 +1732,7 @@ function createHTTPClientTransport(options) {
1868
1732
  * only when the client's offer contains it, and sends UNMASKED frames), wraps it in a
1869
1733
  * {@link WebSocketServerTransport}, and pipes it through the core {@link
1870
1734
  * import('@orkestrel/mcp').MCPTransportInterface} port through {@link
1871
- * import('./helpers.js').bridgeMessageTransport} + {@link import('@orkestrel/mcp').bindServer}:
1735
+ * createDuplexServerTransport} + {@link import('@orkestrel/mcp').bindServer}:
1872
1736
  * each inbound REQUEST runs through `mcp.dispatch`, and a defined response is written back
1873
1737
  * as a frame — a NOTIFICATION sends nothing, and a non-request message (a stray response) is
1874
1738
  * ignored. A `dispatch` / `send` fault surfaces on `mcp.emitter`'s `error` event rather than
@@ -1904,7 +1768,7 @@ function createHTTPClientTransport(options) {
1904
1768
  */
1905
1769
  function createWebSocketServer(mcp, options) {
1906
1770
  const path = options.path ?? "/mcp";
1907
- const subprotocol = options.subprotocol ?? "mcp";
1771
+ const subprotocol = options.subprotocol ?? _src_core.MCP_WEBSOCKET_SUBPROTOCOL;
1908
1772
  const live = /* @__PURE__ */ new Map();
1909
1773
  options.emitter.on("stop", () => {
1910
1774
  for (const [transport, unbind] of live) {
@@ -1928,7 +1792,7 @@ function createWebSocketServer(mcp, options) {
1928
1792
  head,
1929
1793
  ...protocol === void 0 ? {} : { protocol }
1930
1794
  }));
1931
- const unbind = (0, _src_core.bindServer)(mcp, bridgeMessageTransport(transport));
1795
+ const unbind = (0, _src_core.bindServer)(mcp, createDuplexServerTransport(transport));
1932
1796
  live.set(transport, unbind);
1933
1797
  transport.emitter.on("close", () => {
1934
1798
  live.delete(transport);
@@ -1940,7 +1804,7 @@ function createWebSocketServer(mcp, options) {
1940
1804
  }
1941
1805
  /**
1942
1806
  * Creates the WebSocket CLIENT transport for an {@link import('@orkestrel/mcp').MCPClientInterface}
1943
- * — a {@link MCPClientTransportInterface} that drives a REMOTE MCP server over a WebSocket. The
1807
+ * — a {@link MCPMessageTransportInterface} that drives a REMOTE MCP server over a WebSocket. The
1944
1808
  * egress mirror of {@link createWebSocketServer} and the WebSocket sibling of {@link
1945
1809
  * createHTTPClientTransport}.
1946
1810
  *
@@ -1956,7 +1820,7 @@ function createWebSocketServer(mcp, options) {
1956
1820
  *
1957
1821
  * @param options - `url` (the remote WebSocket endpoint; REQUIRED) and optional `headers`
1958
1822
  * merged onto the upgrade request; see {@link WebSocketClientTransportOptions}
1959
- * @returns A working {@link MCPClientTransportInterface} over a WebSocket
1823
+ * @returns A working {@link MCPMessageTransportInterface} over a WebSocket
1960
1824
  *
1961
1825
  * @example
1962
1826
  * ```ts
@@ -2022,7 +1886,7 @@ function createStdioClientTransport(options) {
2022
1886
  * Wraps `options.input` (default `process.stdin`) / `options.output` (default
2023
1887
  * `process.stdout`) in a {@link import('./transports/StdioServerTransport.js').StdioServerTransport}
2024
1888
  * and pipes it through the core {@link import('@orkestrel/mcp').MCPTransportInterface} port
2025
- * through {@link import('./helpers.js').bridgeMessageTransport} + {@link
1889
+ * through {@link createDuplexServerTransport} + {@link
2026
1890
  * import('@orkestrel/mcp').bindServer}: each inbound REQUEST runs through `mcp.dispatch`, and
2027
1891
  * a defined response is written back as a newline-terminated line — a NOTIFICATION
2028
1892
  * writes nothing, and a non-request message is ignored. A `dispatch` / `send` fault
@@ -2032,7 +1896,8 @@ function createStdioClientTransport(options) {
2032
1896
  * @param mcp - The transport-agnostic {@link MCPDispatcherInterface} to expose over stdio
2033
1897
  * @param options - Optional injectable `input` / `output` streams; see
2034
1898
  * {@link StdioServerOptions}
2035
- * @returns A `{ start(): void; stop(): void }` handle to arm / tear down the pump
1899
+ * @returns A {@link StdioServerInterface} handle to arm / tear down the pump; `stop()` ends
1900
+ * that handle's lifetime permanently
2036
1901
  *
2037
1902
  * @example
2038
1903
  * ```ts
@@ -2047,7 +1912,7 @@ function createStdioClientTransport(options) {
2047
1912
  */
2048
1913
  function createStdioServer(mcp, options) {
2049
1914
  const transport = new StdioServerTransport(options?.input ?? process.stdin, options?.output ?? process.stdout);
2050
- const unbind = (0, _src_core.bindServer)(mcp, bridgeMessageTransport(transport));
1915
+ const unbind = (0, _src_core.bindServer)(mcp, createDuplexServerTransport(transport));
2051
1916
  return {
2052
1917
  start() {
2053
1918
  transport.start();
@@ -2079,8 +1944,9 @@ function createStdioServer(mcp, options) {
2079
1944
  * can re-read it from a freshly-built forwarded `Request`). Resolves a session through {@link
2080
1945
  * readSessionHeader}: a VALID id touches the entry and sets `context.state.session`; an
2081
1946
  * ABSENT / unknown id whose (guarded) body parses to an `initialize` request ({@link
2082
- * isInitializeRequest}) MINTS a fresh {@link MCPSession} (`crypto.randomUUID()`, `capacity`)
2083
- * and sets `context.state.session`; neither → {@link rejectUnknownSession} (`404`). The
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
2084
1950
  * minted entry pins the negotiated legacy revision, which is supplied to a later headerless
2085
1951
  * live-session request. It then
2086
1952
  * FORWARDS a fresh `Request` carrying the buffered `text` (`next(forwarded)`) — never the
@@ -2091,7 +1957,7 @@ function createStdioServer(mcp, options) {
2091
1957
  * a `DELETE` arriving while the request was suspended is not undone.
2092
1958
  * - **`GET {path}`.** Resolves the session the same way (no mint — only `initialize` mints);
2093
1959
  * an invalid / unknown id is the same `404`. A valid session opens the resumable
2094
- * server→client stream through `@orkestrel/server`'s {@link import('@orkestrel/server').openStream}:
1960
+ * server→client stream through `@orkestrel/server`'s {@link import('@orkestrel/server').createStream}:
2095
1961
  * replays every event after the client's `Last-Event-ID` ({@link readLastEventId}) BEFORE
2096
1962
  * attaching the stream for live pushes, then attaches; cancellation of the streamed response
2097
1963
  * body composes with `request.signal` and detaches it. Long-lived — never `end()`ed here.
@@ -2106,10 +1972,11 @@ function createStdioServer(mcp, options) {
2106
1972
  * @typeParam TState - The consumer's `TState`, which MUST extend {@link MCPSessionState} so
2107
1973
  * the resolved session can be threaded through `context.state.session`
2108
1974
  * @param options - Optional `path` (default {@link DEFAULT_MCP_PATH}), `ttl` (idle-session
2109
- * sweep window, ms — omit for sessions that live until an explicit `DELETE`), `capacity`
2110
- * (the folded per-session replay-log bound), and `clock` (the deterministic epoch-ms clock;
2111
- * defaults to `Date.now`), plus the shared `origin` validation options; see
2112
- * {@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}
2113
1980
  * @returns A {@link MiddlewareHandler} that mints / validates sessions + serves the resumable
2114
1981
  * `GET` / `DELETE`
2115
1982
  *
@@ -2127,7 +1994,7 @@ function createStdioServer(mcp, options) {
2127
1994
  */
2128
1995
  function createMCPSession(options) {
2129
1996
  const path = options?.path ?? "/mcp";
2130
- const capacity = options?.capacity;
1997
+ const sessionOptions = options?.session ?? {};
2131
1998
  const ttl = options?.ttl;
2132
1999
  const clock = options?.clock ?? Date.now;
2133
2000
  const origin = options?.origin;
@@ -2140,10 +2007,10 @@ function createMCPSession(options) {
2140
2007
  if (context.method === "POST") {
2141
2008
  try {
2142
2009
  text = await request.text();
2143
- parsed = (0, _src_core.parseJSONRPCMessage)(JSON.parse(text));
2144
2010
  } catch {
2145
- parsed = void 0;
2011
+ text = void 0;
2146
2012
  }
2013
+ parsed = text === void 0 ? void 0 : (0, _src_core.parseJSONRPCMessage)((0, _orkestrel_contract.parseJSON)(text));
2147
2014
  if (text !== void 0 && parsed !== void 0 && (0, _src_core.isModernRequest)(parsed)) return next(new Request(context.url, {
2148
2015
  method: "POST",
2149
2016
  headers: request.headers,
@@ -2176,7 +2043,7 @@ function createMCPSession(options) {
2176
2043
  if (context.method === "GET") {
2177
2044
  if (entry === void 0) return rejectUnknownSession();
2178
2045
  const session = entry.session;
2179
- const stream = (0, _orkestrel_server.openStream)();
2046
+ const stream = (0, _orkestrel_server.createStream)();
2180
2047
  const disconnect = new HTTPDisconnect(request.signal, options?.keepalive);
2181
2048
  stream.response.headers.set(SSE_BUFFERING_HEADER, "no");
2182
2049
  stream.comment("open");
@@ -2195,7 +2062,10 @@ function createMCPSession(options) {
2195
2062
  if (entry === void 0) {
2196
2063
  if (parsed !== void 0 && (0, _src_core.isInitializeRequest)(parsed)) {
2197
2064
  created = {
2198
- session: new MCPSession(crypto.randomUUID(), capacity !== void 0 ? { capacity } : {}),
2065
+ session: new MCPSession(crypto.randomUUID(), {
2066
+ ...sessionOptions,
2067
+ clock: sessionOptions.clock ?? clock
2068
+ }),
2199
2069
  touched: clock(),
2200
2070
  version: inferLegacyVersion(parsed)
2201
2071
  };
@@ -2205,8 +2075,8 @@ function createMCPSession(options) {
2205
2075
  if (!Reflect.set(context.state, "session", entry.session)) throw new Error("MCP session state is not writable");
2206
2076
  const headers = new Headers(request.headers);
2207
2077
  if (parsed === void 0 || !(0, _src_core.isInitializeRequest)(parsed)) {
2208
- const issue = inferHeaderIssue(request, entry.version);
2209
- 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);
2210
2080
  else if (issue !== void 0) {
2211
2081
  const requestId = parsed !== void 0 && "method" in parsed ? parsed.id : void 0;
2212
2082
  return Response.json((0, _src_core.buildJSONRPCError)(requestId, _src_core.MCP_HEADER_MISMATCH, issue.message), { status: 400 });
@@ -2228,7 +2098,7 @@ function createMCPSession(options) {
2228
2098
  ...entry,
2229
2099
  touched: clock()
2230
2100
  });
2231
- response.headers.set(MCP_SESSION_HEADER, entry.session.id);
2101
+ response.headers.set(_src_core.MCP_SESSION_HEADER, entry.session.id);
2232
2102
  return response;
2233
2103
  };
2234
2104
  }
@@ -2238,14 +2108,8 @@ exports.DEFAULT_MCP_KEEPALIVE_INTERVAL = DEFAULT_MCP_KEEPALIVE_INTERVAL;
2238
2108
  exports.DEFAULT_MCP_PATH = DEFAULT_MCP_PATH;
2239
2109
  exports.DEFAULT_MCP_SESSION_CAPACITY = DEFAULT_MCP_SESSION_CAPACITY;
2240
2110
  exports.DEFAULT_MCP_SESSION_TTL = DEFAULT_MCP_SESSION_TTL;
2241
- exports.HTTPClientTransport = HTTPClientTransport;
2242
2111
  exports.HTTPDisconnect = HTTPDisconnect;
2243
2112
  exports.MCPSession = MCPSession;
2244
- exports.MCP_METHOD_HEADER = MCP_METHOD_HEADER;
2245
- exports.MCP_NAME_HEADER = MCP_NAME_HEADER;
2246
- exports.MCP_PROTOCOL_VERSION_HEADER = MCP_PROTOCOL_VERSION_HEADER;
2247
- exports.MCP_SESSION_HEADER = MCP_SESSION_HEADER;
2248
- exports.MCP_WEBSOCKET_SUBPROTOCOL = MCP_WEBSOCKET_SUBPROTOCOL;
2249
2113
  exports.SSE_BUFFERING_DISABLED = SSE_BUFFERING_DISABLED;
2250
2114
  exports.SSE_BUFFERING_HEADER = SSE_BUFFERING_HEADER;
2251
2115
  exports.SSE_KEEPALIVE_COMMENT = SSE_KEEPALIVE_COMMENT;
@@ -2255,25 +2119,24 @@ exports.WebSocketClientTransport = WebSocketClientTransport;
2255
2119
  exports.WebSocketServerTransport = WebSocketServerTransport;
2256
2120
  exports.acceptsEventStream = acceptsEventStream;
2257
2121
  exports.allowsOrigin = allowsOrigin;
2258
- exports.bridgeMessageTransport = bridgeMessageTransport;
2259
- exports.buildResponseError = buildResponseError;
2122
+ exports.createDuplexServerTransport = createDuplexServerTransport;
2260
2123
  exports.createHTTPClientTransport = createHTTPClientTransport;
2261
2124
  exports.createMCPContinuation = createMCPContinuation;
2262
2125
  exports.createMCPPostHandler = createMCPPostHandler;
2263
2126
  exports.createMCPRoutes = createMCPRoutes;
2264
2127
  exports.createMCPSession = createMCPSession;
2265
- exports.createReadableStream = createReadableStream;
2266
2128
  exports.createStdioClientTransport = createStdioClientTransport;
2267
2129
  exports.createStdioServer = createStdioServer;
2268
2130
  exports.createWebSocketClientTransport = createWebSocketClientTransport;
2269
2131
  exports.createWebSocketServer = createWebSocketServer;
2270
- exports.decodeEvent = decodeEvent;
2271
2132
  exports.dispatchLines = dispatchLines;
2272
2133
  exports.extractLines = extractLines;
2273
2134
  exports.inferHeaderIssue = inferHeaderIssue;
2135
+ exports.inferHeaderTarget = inferHeaderTarget;
2274
2136
  exports.inferLegacyVersion = inferLegacyVersion;
2137
+ exports.inferParameterRefusal = inferParameterRefusal;
2138
+ exports.inferSessionHeaderIssue = inferSessionHeaderIssue;
2275
2139
  exports.inferStatus = inferStatus;
2276
- exports.readEventStream = readEventStream;
2277
2140
  exports.readLastEventId = readLastEventId;
2278
2141
  exports.readSessionHeader = readSessionHeader;
2279
2142
  exports.rejectUnknownSession = rejectUnknownSession;