@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,45 +1,23 @@
1
- import { createSSEParser } from "@orkestrel/sse";
2
- import { JSONRPC_INVALID_PARAMS, JSONRPC_INVALID_REQUEST, JSONRPC_METHOD_NOT_FOUND, JSONRPC_PARSE_ERROR, MCP_HANDSHAKE_VERSION, MCP_HEADER_MISMATCH, MCP_META_VERSION, MCP_MISSING_CAPABILITY, MCP_UNSUPPORTED_VERSION, SUPPORTED_LEGACY_PROTOCOL_VERSIONS, bindServer, buildJSONRPCError, inferRequestVersion, isInitializeRequest, isJSONRPCInvocation, isJSONRPCResponse, isMCPLegacyVersion, isMCPVersion, isModernRequest, parseJSONRPCMessage, parseRequestContext } from "../core/index.js";
3
- import { isRecord, isString, sanitizeBudget } from "@orkestrel/contract";
4
- import { openStream, signToken, verifyToken } from "@orkestrel/server";
1
+ import { HTTPClientTransport, JSONRPC_INVALID_PARAMS, JSONRPC_INVALID_REQUEST, JSONRPC_METHOD_NOT_FOUND, JSONRPC_PARSE_ERROR, MCP_HANDSHAKE_VERSION, MCP_HEADER_MISMATCH, MCP_LOOKUP_PAGES, MCP_META_VERSION, MCP_METHOD_HEADER, MCP_MISSING_CAPABILITY, MCP_NAME_HEADER, MCP_PARAM_PREFIX, MCP_PROTOCOL_VERSION_HEADER, MCP_SESSION_HEADER, MCP_UNSUPPORTED_VERSION, MCP_WEBSOCKET_SUBPROTOCOL, SUPPORTED_LEGACY_PROTOCOL_VERSIONS, bindServer, buildHeaderParameters, buildJSONRPCError, decodeEvent, decodeSentinel, deliverMessage, extractToolSchema, inferRequestEra, isInitializeRequest, isJSONRPCInvocation, isMCPLegacyVersion, isMCPModernVersion, isModernRequest, parseJSONRPCMessage, parseRequestContext, renderHeaderValue } from "../core/index.js";
2
+ import { isRecord, isString, parseJSON, sanitizeBudget } from "@orkestrel/contract";
3
+ import { createStream, signToken, verifyToken } from "@orkestrel/server";
5
4
  import { Emitter } from "@orkestrel/emitter";
5
+ import { WEBSOCKET_READY_OPEN, WEBSOCKET_VERSION, computeWebSocketAccept, createNodeWebSocket } from "@orkestrel/websocket";
6
6
  import { randomBytes } from "node:crypto";
7
7
  import { request } from "node:http";
8
8
  import { request as request$1 } from "node:https";
9
- import { WEBSOCKET_VERSION, computeWebSocketAccept, createNodeWebSocket } from "@orkestrel/websocket";
10
9
  import { Process } from "@orkestrel/process/server";
11
10
  import { PROCESS_GRACE } from "@orkestrel/process";
12
11
  import { Readable } from "node:stream";
13
12
  //#region src/server/constants.ts
14
- /**
15
- * The Streamable-HTTP transport header that carries the MCP session id. When a {@link
16
- * import('./middlewares.js').createMCPSession} middleware is mounted, it SETS this header on
17
- * the `initialize` response (the minted id) and READS it on every subsequent request
18
- * (validating the session); the stateless `createMCPRoutes` default neither sets nor reads it.
19
- */
20
- var MCP_SESSION_HEADER = "mcp-session-id";
21
- /**
22
- * The Streamable-HTTP transport header carrying the negotiated MCP protocol version
23
- * on every post-initialize client request.
24
- *
25
- * @remarks
26
- * Required by MCP 2025-06-18 after initialization. Both HTTP client transports
27
- * capture the initialize result's `protocolVersion` and send it on subsequent
28
- * requests; `createMCPRoutes` rejects a present unsupported value before dispatch.
29
- */
30
- var MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version";
31
- /** The modern Streamable-HTTP request header carrying the JSON-RPC method name. */
32
- var MCP_METHOD_HEADER = "mcp-method";
33
- /** The modern Streamable-HTTP request header carrying a named method's target. */
34
- var MCP_NAME_HEADER = "mcp-name";
35
- /** The reverse-proxy response header controlling buffering of an SSE response. */
13
+ /** Names the reverse-proxy response header controlling buffering of an SSE response. */
36
14
  var SSE_BUFFERING_HEADER = "x-accel-buffering";
37
- /** The `X-Accel-Buffering` value that disables reverse-proxy buffering. */
15
+ /** Names the `X-Accel-Buffering` value that disables reverse-proxy buffering. */
38
16
  var SSE_BUFFERING_DISABLED = "no";
39
- /** The default request path `createMCPRoutes` mounts the transport's `POST` route at. */
17
+ /** Names the default request path `createMCPRoutes` mounts the transport's `POST` route at. */
40
18
  var DEFAULT_MCP_PATH = "/mcp";
41
19
  /**
42
- * The default interval in milliseconds between SSE keepalive comments on held-open MCP
20
+ * Sets the default interval in milliseconds between SSE keepalive comments on held-open MCP
43
21
  * responses.
44
22
  *
45
23
  * @remarks
@@ -47,34 +25,22 @@ var DEFAULT_MCP_PATH = "/mcp";
47
25
  * client detection and staying comfortably inside common intermediary idle windows.
48
26
  */
49
27
  var DEFAULT_MCP_KEEPALIVE_INTERVAL = 15e3;
50
- /** The comment text written by the held-open MCP response keepalive. */
28
+ /** Names the comment text written by the held-open MCP response keepalive. */
51
29
  var SSE_KEEPALIVE_COMMENT = "keepalive";
52
30
  /**
53
- * The WebSocket subprotocol the MCP-over-WebSocket transports negotiate sent by the
54
- * client in `Sec-WebSocket-Protocol`, echoed by the server in its `101` handshake.
55
- *
56
- * @remarks
57
- * `createWebSocketServer` echoes it in the upgrade response and `createWebSocketClientTransport`
58
- * requests it, so an MCP WebSocket endpoint is distinguishable from any other WebSocket on the
59
- * same path. The default WebSocket upgrade path is {@link DEFAULT_MCP_PATH} (the same `'/mcp'`
60
- * the HTTP transport mounts at) — the upgrade is selected by the `Upgrade: websocket` header,
61
- * not a separate path.
62
- */
63
- var MCP_WEBSOCKET_SUBPROTOCOL = "mcp";
64
- /**
65
- * The default capacity of a session's FOLDED resumable event log (the per-{@link
31
+ * Sets the default capacity of a session's FOLDED resumable event log (the per-{@link
66
32
  * import('./MCPSession.js').MCPSession} replay log) — the maximum number of pushed
67
33
  * server→client messages retained for replay before the OLDEST is evicted.
68
34
  *
69
35
  * @remarks
70
36
  * Bounds the replay log's memory: only the most-recent {@link DEFAULT_MCP_SESSION_CAPACITY}
71
37
  * pushes are retained, so a client reconnecting with a `Last-Event-ID` older than that window
72
- * replays nothing (its cursor fell off the back). Override per `createMCPSession`'s `capacity`
73
- * for a deeper / shallower window.
38
+ * replays nothing (its cursor fell off the back). Override through the `session` group of
39
+ * `createMCPSession`'s options (`session.capacity`) for a deeper / shallower window.
74
40
  */
75
41
  var DEFAULT_MCP_SESSION_CAPACITY = 1024;
76
42
  /**
77
- * The default per-event idle lifetime (ms) of a session's folded resumable event log — an
43
+ * Sets the default per-event idle lifetime (ms) of a session's folded resumable event log — an
78
44
  * entry older than this is lazily evicted on the next access (no background timer), bounding
79
45
  * how far back a reconnecting client may replay.
80
46
  *
@@ -85,7 +51,7 @@ var DEFAULT_MCP_SESSION_CAPACITY = 1024;
85
51
  */
86
52
  var DEFAULT_MCP_SESSION_TTL = 3e5;
87
53
  /**
88
- * The default bound in milliseconds on one unconfirmed write to a stdio client transport's
54
+ * Sets the default bound in milliseconds on one unconfirmed write to a stdio client transport's
89
55
  * child `stdin` — the `delivery` a `createStdioClientTransport` caller who supplies none gets.
90
56
  *
91
57
  * @remarks
@@ -99,37 +65,6 @@ var DEFAULT_MCP_DELIVERY = 1e4;
99
65
  //#endregion
100
66
  //#region src/server/helpers.ts
101
67
  /**
102
- * Builds the error for a non-success HTTP response that carried no JSON-RPC message.
103
- *
104
- * @param response - The response whose status is reported
105
- * @param type - The response's content type, or an empty string when absent
106
- * @returns An error naming the HTTP status and unsupported response shape
107
- *
108
- * @example
109
- * ```ts
110
- * const error = buildResponseError(new Response('', { status: 500 }), '')
111
- * ```
112
- */
113
- function buildResponseError(response, type) {
114
- if (type.includes("application/json")) return /* @__PURE__ */ new Error(`HTTP ${response.status} response contained an application/json body that was not a JSON-RPC message`);
115
- if (type.includes("text/event-stream")) return /* @__PURE__ */ new Error(`HTTP ${response.status} response contained a text/event-stream body without a JSON-RPC message`);
116
- const shape = type === "" ? "a body without a content type" : `an unsupported '${type}' body`;
117
- return /* @__PURE__ */ new Error(`HTTP ${response.status} response contained ${shape}`);
118
- }
119
- /**
120
- * Creates a readable stream from its pull and cancellation behaviours.
121
- *
122
- * @param pull - The behaviour that supplies the stream's next chunk
123
- * @param cancel - The behaviour that releases the stream after consumer cancellation
124
- * @returns A readable stream backed by the supplied behaviours
125
- */
126
- function createReadableStream(pull, cancel) {
127
- return new ReadableStream({
128
- pull,
129
- cancel
130
- });
131
- }
132
- /**
133
68
  * Pumps a controlled held-open exchange onto an open SSE stream — one `data:` event per
134
69
  * notification in order, then the terminating response — and END the exchange however the
135
70
  * pump leaves.
@@ -157,7 +92,7 @@ function createReadableStream(pull, cancel) {
157
92
  * ```ts
158
93
  * const answer = await mcp.dispatch(invocation, { signal: disconnect.signal })
159
94
  * if (answer !== undefined && Symbol.asyncIterator in answer) {
160
- * const sse = openStream()
95
+ * const sse = createStream()
161
96
  * queueMicrotask(() => void sendEventStream(answer, sse))
162
97
  * }
163
98
  * ```
@@ -179,7 +114,7 @@ async function sendEventStream(stream, sse) {
179
114
  }
180
115
  }
181
116
  /**
182
- * Whether the request's `Accept` header opts into a Server-Sent-Events response.
117
+ * Checks whether the request's `Accept` header opts into a Server-Sent-Events response.
183
118
  *
184
119
  * @remarks
185
120
  * Reads the fetch-standard `Request.headers.get('accept')` and returns `true` when it
@@ -189,7 +124,7 @@ async function sendEventStream(stream, sse) {
189
124
  * — an absent / unmatched header returns `false`.
190
125
  *
191
126
  * @param request - The fetch-standard `Request`
192
- * @returns `true` when the client `Accept`s `text/event-stream`, else `false`
127
+ * @returns True if the client `Accept`s `text/event-stream`; false otherwise
193
128
  */
194
129
  function acceptsEventStream(request) {
195
130
  const accept = request.headers.get("accept");
@@ -197,7 +132,7 @@ function acceptsEventStream(request) {
197
132
  return accept.toLowerCase().includes("text/event-stream");
198
133
  }
199
134
  /**
200
- * Whether an HTTP request satisfies the endpoint's origin gate.
135
+ * Checks whether an HTTP request satisfies the endpoint's origin gate.
201
136
  *
202
137
  * @remarks
203
138
  * Validation is enabled by default. A request without `Origin` is allowed. A canonical origin
@@ -208,7 +143,7 @@ function acceptsEventStream(request) {
208
143
  *
209
144
  * @param request - The fetch-standard request to validate
210
145
  * @param options - Shared origin validation and delegation options
211
- * @returns `true` when the request may reach MCP dispatch
146
+ * @returns True if the request may reach MCP dispatch; false otherwise
212
147
  */
213
148
  function allowsOrigin(request, options) {
214
149
  if (options?.enabled === false) return true;
@@ -279,65 +214,6 @@ function rejectUnknownSession() {
279
214
  return Response.json(buildJSONRPCError(void 0, JSONRPC_INVALID_REQUEST, "Session not found"), { status: 404 });
280
215
  }
281
216
  /**
282
- * Decodes a `fetch` Response's Server-Sent-Events body into the JSON-RPC messages it
283
- * carried — the CLIENT-side inverse of the server's Streamable-HTTP SSE response.
284
- *
285
- * @remarks
286
- * Reads the whole `response.body` stream chunk-by-chunk through a `TextDecoder({
287
- * stream: true })` (handling a multi-byte char split across reads) and `@orkestrel/sse`'s
288
- * {@link SSEParserInterface} (handling a partial line / in-progress event split across
289
- * reads), then narrows each dispatched event's `data` to a {@link JSONRPCMessage} with
290
- * `parseJSONRPCMessage` (so a non-message / non-JSON `data:` event is DROPPED, never
291
- * thrown — total). It reuses the SAME `SSEParser` the server's `openStream` seam
292
- * serializes against, so the wire round-trips. A `null` body (no stream) yields no
293
- * messages; the {@link import('./transports/HTTPClientTransport.js').HTTPClientTransport}
294
- * reads a request/response SSE reply (the server sends one `data:` event then ends), so
295
- * this drains to completion.
296
- *
297
- * @param response - The SSE `fetch` Response to decode (its `body` is read to completion)
298
- * @returns Every {@link JSONRPCMessage} the stream carried, in order
299
- */
300
- async function readEventStream(response) {
301
- const body = response.body;
302
- if (body === null) return [];
303
- const reader = body.getReader();
304
- const decoder = new TextDecoder();
305
- const parser = createSSEParser();
306
- const messages = [];
307
- try {
308
- for (;;) {
309
- const { done, value } = await reader.read();
310
- if (done) break;
311
- for (const event of parser.parse(decoder.decode(value, { stream: true }))) {
312
- const message = decodeEvent(event.data);
313
- if (message !== void 0) messages.push(message);
314
- }
315
- }
316
- } finally {
317
- reader.releaseLock();
318
- }
319
- return messages;
320
- }
321
- /**
322
- * Decodes one SSE event's `data` string into a {@link JSONRPCMessage}, or `undefined`
323
- * when it is not one — the per-event step {@link readEventStream} folds over.
324
- *
325
- * @remarks
326
- * `JSON.parse`s the `data` (the server serializes the JSON-RPC envelope as the event's
327
- * `data`) inside a try/catch and narrows the parsed value with `parseJSONRPCMessage`.
328
- * Total: malformed JSON or a non-message value yields `undefined`, never throws.
329
- *
330
- * @param data - One SSE event's `data` payload
331
- * @returns The decoded {@link JSONRPCMessage}, or `undefined`
332
- */
333
- function decodeEvent(data) {
334
- try {
335
- return parseJSONRPCMessage(JSON.parse(data));
336
- } catch {
337
- return;
338
- }
339
- }
340
- /**
341
217
  * Reads the path (without the query string) of a raw `node:http` protocol-upgrade request —
342
218
  * the `createWebSocketServer` upgrade-path match.
343
219
  *
@@ -419,16 +295,17 @@ function writeLine(output, line) {
419
295
  }
420
296
  /**
421
297
  * Decodes and delivers each complete newline-framed line onto a {@link
422
- * MCPClientTransportEventMap} emitter — the shared per-chunk dispatch step both stdio
298
+ * MCPMessageTransportEventMap} emitter — the shared per-chunk dispatch step both stdio
423
299
  * transports run their framed lines through: the server transport frames with {@link
424
300
  * extractLines}, the client transport takes its lines from the process supervisor.
425
301
  *
426
302
  * @remarks
427
- * A blank line is skipped (a stray trailing newline). Every other line is decoded
428
- * with {@link decodeEvent} (`JSON.parse` + `parseJSONRPCMessage`, guarded); a
429
- * well-formed {@link JSONRPCMessage} emits `message`, a malformed / non-message line
430
- * emits `error` (total, never throws). Pure w.r.t. its own state the emit is
431
- * the caller-owned side effect.
303
+ * A blank line is skipped (a stray trailing newline). Every other line runs through the
304
+ * shared {@link import('@orkestrel/mcp').deliverMessage} fold, the one inbound decode every
305
+ * transport in this package shares: a well-formed {@link JSONRPCMessage} emits `message`,
306
+ * unparsable text emits the caught parse error, and a well-formed non-message line emits
307
+ * `error` naming a non-JSON-RPC stdio line (total, never throws). Pure w.r.t. its own state
308
+ * — the emit is the caller-owned side effect.
432
309
  *
433
310
  * @param emitter - The transport's {@link EmitterInterface} to emit `message` / `error` onto
434
311
  * @param lines - The complete lines to decode and deliver
@@ -436,106 +313,62 @@ function writeLine(output, line) {
436
313
  function dispatchLines(emitter, lines) {
437
314
  for (const line of lines) {
438
315
  if (line.length === 0) continue;
439
- const message = decodeEvent(line);
440
- if (message === void 0) {
441
- emitter.emit("error", /* @__PURE__ */ new Error("non-JSON-RPC stdio line"));
442
- continue;
443
- }
444
- emitter.emit("message", message);
316
+ deliverMessage(emitter, line, "non-JSON-RPC stdio line");
445
317
  }
446
318
  }
319
+ //#endregion
320
+ //#region src/server/inferers.ts
447
321
  /**
448
- * Bridges a message-channel {@link MCPClientTransportInterface} (the shape the stdio and
449
- * WebSocket SERVER transports already implement) into the environment-agnostic
450
- * {@link import('@orkestrel/mcp').MCPTransportInterface} port — the adapter
451
- * {@link import('./factories.js').createStdioServer} and {@link
452
- * import('./factories.js').createWebSocketServer} pipe through `bindServer`, so the
453
- * request/reply/error pump those factories used to hand-roll identically now
454
- * lives ONCE in the core binder.
322
+ * Infers the target one modern request's `Mcp-Name` header must carry.
455
323
  *
456
324
  * @remarks
457
- * `send` decodes the already-serialized reply string back to a {@link JSONRPCMessage}
458
- * and writes it through `transport.send` (the same `JSON.stringify` the underlying
459
- * transport already performs, so the wire bytes are unchanged). `listen` filters
460
- * `transport`'s `message` event to INVOCATIONS ONLY requests and notifications, never a
461
- * stray response, exactly as the prior hand-rolled pumps did and re-serializes each one
462
- * back to a string for `bindServer`. `closed` bridges `transport`'s `close` event. `close`
463
- * closes the underlying `transport`.
325
+ * The protocol scopes the header to the methods whose body carries a name-shaped field, and
326
+ * names the field per method: `tools/call` and `prompts/get` carry `params.name`, and
327
+ * `resources/read` carries `params.uri`. Every other method `server/discover`, `tools/list`,
328
+ * `resources/list`, `prompts/list` has nothing to derive a target from, so the header is not
329
+ * required there and a peer that sent one anyway is not held to it.
464
330
  *
465
- * @remarks A message crossing this bridge is decoded and re-encoded TWICE, and that is
466
- * ACCEPTED rather than accidental. Inbound: the carrier already parsed the frame into a
467
- * {@link JSONRPCMessage}, and `listen` re-serializes it so `bindServer` can decode it again
468
- * under the server's own `limit`. Outbound: `bindServer` serialized the reply, `send` parses
469
- * it back, and the carrier stringifies it once more. The cost is two extra `JSON.parse` /
470
- * `JSON.stringify` round trips per message, paid to keep ONE pump in the core binder instead
471
- * of a hand-rolled one per carrier. It is BOUNDED rather than unbounded because the binder
472
- * decodes within `server.limit.message`, so an oversized frame is refused before the second
473
- * decode rather than after it. Removing the cost means giving `MCPTransportInterface` a
474
- * message-shaped face beside its string one, which every transport would then carry.
331
+ * A method within the scope whose named member is absent or is not a string reads as no
332
+ * target. There is nothing for a header to match, and refusing the request over a body member
333
+ * the header rule does not own would report a parameter fault as a header fault. Total.
475
334
  *
476
- * @remarks Per {@link import('@orkestrel/mcp').MCPTransportInterface}, `listen`/`closed`
477
- * each hold THE SINGLE current handler (a second call REPLACES the first, never adds).
478
- * Because the underlying `transport.emitter` is ADD-based (`on` subscribes, never
479
- * replaces), this bridge installs ONE stable emitter listener per event on first use
480
- * and re-routes it to whichever handler is active (`undefined` while
481
- * none is), so rebinding never double-dispatches.
482
- *
483
- * @remarks A response whose `result` serializes away (for example, `undefined`) is dropped by
484
- * the message validators on the wire's decode side — an asymmetry the stdio/WS carrier
485
- * shares with the streamable-HTTP face, because both round-trip through `JSON.stringify`
486
- * / `JSON.parse` before re-validation.
487
- *
488
- * @param transport - The message-channel transport to bridge (stdio or WebSocket)
489
- * @returns An {@link import('@orkestrel/mcp').MCPTransportInterface} `bindServer` can drive
335
+ * @param request - The parsed modern invocation to read the target from
336
+ * @returns The target the header must carry, or `undefined` when the method carries none
490
337
  *
491
338
  * @example
492
339
  * ```ts
493
- * import { bindServer } from '@orkestrel/mcp'
494
- *
495
- * const transport = new StdioServerTransport(process.stdin, process.stdout)
496
- * bindServer(mcp, bridgeMessageTransport(transport))
340
+ * inferHeaderTarget({ jsonrpc: '2.0', id: 1, method: 'resources/read', params: { uri: 'file:///a' } })
341
+ * // → 'file:///a'
497
342
  * ```
498
343
  */
499
- function bridgeMessageTransport(transport) {
500
- let onMessage;
501
- let onClosed;
502
- transport.emitter.on("message", (message) => {
503
- if (!isJSONRPCInvocation(message)) return;
504
- onMessage?.(JSON.stringify(message));
505
- });
506
- transport.emitter.on("close", () => {
507
- onClosed?.();
508
- });
509
- return {
510
- async send(message) {
511
- const decoded = decodeEvent(message);
512
- if (decoded === void 0) return;
513
- await transport.send(decoded);
514
- },
515
- listen(handler) {
516
- onMessage = handler;
517
- },
518
- closed(handler) {
519
- onClosed = handler;
520
- },
521
- async close() {
522
- await transport.close();
523
- }
524
- };
344
+ function inferHeaderTarget(request) {
345
+ if (request.method === "tools/call" || request.method === "prompts/get") {
346
+ const name = request.params?.["name"];
347
+ return isString(name) ? name : void 0;
348
+ }
349
+ if (request.method === "resources/read") {
350
+ const uri = request.params?.["uri"];
351
+ return isString(uri) ? uri : void 0;
352
+ }
525
353
  }
526
- //#endregion
527
- //#region src/server/inferers.ts
528
354
  /**
529
- * Infers the first required MCP HTTP header that is missing or mismatched.
355
+ * Infers the first required MCP HTTP header a request's own body contradicts.
530
356
  *
531
357
  * @remarks
532
- * A modern request derives its protocol, method, and tools/call-only name expectations from
533
- * the JSON-RPC body. A legacy request body requires a protocol header after initialization,
534
- * while a supplied legacy session version additionally diagnoses a header that disagrees with
535
- * the active session. Messages name the expected value but never echo the client-supplied one.
358
+ * A modern request derives its protocol, method, and name expectations from the JSON-RPC body,
359
+ * the name expectation scoped to the methods {@link inferHeaderTarget} reads a target for. A
360
+ * name header carrying the Base64 sentinel is decoded through
361
+ * {@link import('@orkestrel/mcp').decodeSentinel} before the comparison, so a peer that had
362
+ * to encode its value still matches; a sentinel whose payload is invalid decodes to nothing
363
+ * and therefore mismatches, which is how an invalid header value is refused. A legacy request
364
+ * body requires a protocol header after initialization. Messages name the expected value but
365
+ * never echo the client-supplied one.
366
+ *
367
+ * The expectation a LIVE SESSION supplies is a different rule over a different input, so it
368
+ * is {@link inferSessionHeaderIssue} rather than a second arm of this one.
536
369
  *
537
370
  * @param request - The HTTP request carrying the headers
538
- * @param reference - The parsed invocation body, or the active legacy session version
371
+ * @param invocation - The parsed invocation body the expectations are derived from
539
372
  * @returns The first header issue, or `undefined` when the applicable headers agree
540
373
  *
541
374
  * @example
@@ -544,30 +377,17 @@ function bridgeMessageTransport(transport) {
544
377
  * issue?.header // 'Mcp-Method' when that field is absent or mismatched
545
378
  * ```
546
379
  */
547
- function inferHeaderIssue(request, reference) {
380
+ function inferHeaderIssue(request, invocation) {
548
381
  const protocol = request.headers.get(MCP_PROTOCOL_VERSION_HEADER);
549
- if (isString(reference)) {
550
- if (protocol === null) return {
551
- header: "MCP-Protocol-Version",
552
- reason: "missing",
553
- message: `Required MCP-Protocol-Version header is missing; the active session uses '${reference}'.`
554
- };
555
- if (protocol !== reference) return {
556
- header: "MCP-Protocol-Version",
557
- reason: "mismatched",
558
- message: `MCP-Protocol-Version header does not match the active session version '${reference}'.`
559
- };
560
- return;
561
- }
562
- if (!isModernRequest(reference)) {
563
- if (isInitializeRequest(reference) || protocol !== null) return void 0;
382
+ if (!isModernRequest(invocation)) {
383
+ if (isInitializeRequest(invocation) || protocol !== null) return void 0;
564
384
  return {
565
385
  header: "MCP-Protocol-Version",
566
386
  reason: "missing",
567
387
  message: `Required MCP-Protocol-Version header is missing; this server offers '${MCP_HANDSHAKE_VERSION}'.`
568
388
  };
569
389
  }
570
- const message = reference;
390
+ const message = invocation;
571
391
  const version = (isRecord(message.params?.["_meta"]) ? message.params["_meta"] : void 0)?.[MCP_META_VERSION];
572
392
  if (!isString(version)) return void 0;
573
393
  if (protocol === null) return {
@@ -591,22 +411,107 @@ function inferHeaderIssue(request, reference) {
591
411
  reason: "mismatched",
592
412
  message: `Mcp-Method header does not match the request body method '${message.method}'.`
593
413
  };
594
- if (message.method !== "tools/call") return void 0;
595
- const name = message.params?.["name"];
596
- if (!isString(name)) return void 0;
414
+ const target = inferHeaderTarget(message);
415
+ if (target === void 0) return void 0;
597
416
  const header = request.headers.get(MCP_NAME_HEADER);
598
417
  if (header === null) return {
599
418
  header: "Mcp-Name",
600
419
  reason: "missing",
601
- message: `Required Mcp-Name header is missing; the request body tool name is '${name}'.`
420
+ message: `Required Mcp-Name header is missing; the request body target is '${target}'.`
602
421
  };
603
- if (header !== name) return {
422
+ if (decodeSentinel(header) !== target) return {
604
423
  header: "Mcp-Name",
605
424
  reason: "mismatched",
606
- message: `Mcp-Name header does not match the request body tool name '${name}'.`
425
+ message: `Mcp-Name header does not match the request body target '${target}'.`
607
426
  };
608
427
  }
609
428
  /**
429
+ * Infers the protocol header issue an active legacy session's pinned revision diagnoses.
430
+ *
431
+ * @remarks
432
+ * The session layer's rule, distinct from the body-derived one {@link inferHeaderIssue} owns:
433
+ * a live legacy session pinned its revision at `initialize`, so every later request on that
434
+ * session must name the same one. An absent header reads as `missing`, which the session
435
+ * middleware answers by SUPPLYING the pinned revision rather than refusing; a present header
436
+ * naming another revision reads as `mismatched` and is refused. The message names the session's
437
+ * revision and never echoes the client-supplied value.
438
+ *
439
+ * @param request - The HTTP request carrying the headers
440
+ * @param version - The legacy revision the active session pinned at `initialize`
441
+ * @returns The protocol header issue, or `undefined` when the header agrees
442
+ *
443
+ * @example
444
+ * ```ts
445
+ * const issue = inferSessionHeaderIssue(request, '2025-06-18')
446
+ * issue?.reason // 'missing' when the request carries no protocol header
447
+ * ```
448
+ */
449
+ function inferSessionHeaderIssue(request, version) {
450
+ const protocol = request.headers.get(MCP_PROTOCOL_VERSION_HEADER);
451
+ if (protocol === null) return {
452
+ header: "MCP-Protocol-Version",
453
+ reason: "missing",
454
+ message: `Required MCP-Protocol-Version header is missing; the active session uses '${version}'.`
455
+ };
456
+ if (protocol !== version) return {
457
+ header: "MCP-Protocol-Version",
458
+ reason: "mismatched",
459
+ message: `MCP-Protocol-Version header does not match the active session version '${version}'.`
460
+ };
461
+ }
462
+ /**
463
+ * Infers the refusal one `tools/call` earns for a `Mcp-Param-*` header the body contradicts.
464
+ *
465
+ * @remarks
466
+ * The custom-header half of the standard-header seam {@link inferHeaderIssue} owns, and it
467
+ * takes the SERVED definition's projections rather than a header issue: SEP-2243 scopes the
468
+ * rule to the `Mcp-Param-*` names the server's OWN tool definitions annotate, so a name no
469
+ * parameter claims is another party's header and travels through untouched.
470
+ *
471
+ * For each recognized parameter the body's value at the parameter's own property path fixes
472
+ * the expectation. A value the call omits or supplies as `null` requires no header, and a
473
+ * header sent anyway is refused because it asserts something the body never said. A value the
474
+ * call does supply requires its header: an absent one, a Base64 sentinel whose payload is
475
+ * invalid, and a decoded value that disagrees are each refused. An `integer` parameter
476
+ * compares numerically, so a peer that padded its decimal still matches. A supplied value
477
+ * whose runtime shape contradicts the declared type is left alone — the tool's own argument
478
+ * validation owns that disagreement, and refusing it here would report an argument fault as a
479
+ * header fault.
480
+ *
481
+ * Messages name the field and the body path the expectation came from, and never echo the
482
+ * value the peer supplied.
483
+ *
484
+ * @param request - The HTTP request carrying the headers
485
+ * @param parameters - The projections the served tool definition declares
486
+ * @param values - The call's `arguments` record
487
+ * @returns The refusal message for the first disagreeing parameter, or `undefined`
488
+ *
489
+ * @example
490
+ * ```ts
491
+ * inferParameterRefusal(request, [{ name: 'Region', path: ['region'], primitive: 'string' }], {})
492
+ * // → undefined when the request carries no `Mcp-Param-Region` either
493
+ * ```
494
+ */
495
+ function inferParameterRefusal(request, parameters, values) {
496
+ for (const parameter of parameters) {
497
+ let carried = values;
498
+ for (const key of parameter.path) carried = isRecord(carried) ? carried[key] : void 0;
499
+ const field = `${MCP_PARAM_PREFIX}${parameter.name}`;
500
+ const path = parameter.path.join(".");
501
+ const header = request.headers.get(field);
502
+ if (carried === void 0 || carried === null) {
503
+ if (header === null) continue;
504
+ return `${field} header carries a value the request body omits at '${path}'.`;
505
+ }
506
+ const expected = renderHeaderValue(carried, parameter.primitive);
507
+ if (expected === void 0) continue;
508
+ if (header === null) return `Required ${field} header is missing; the request body carries '${path}'.`;
509
+ const decoded = decodeSentinel(header);
510
+ if (decoded === void 0) return `${field} header value is not a valid Base64 sentinel.`;
511
+ if (!(parameter.primitive === "integer" ? decoded.trim() !== "" && Number(decoded) === Number(expected) : decoded === expected)) return `${field} header does not match the request body value at '${path}'.`;
512
+ }
513
+ }
514
+ /**
610
515
  * Infers the legacy revision an `initialize` request negotiates.
611
516
  *
612
517
  * @remarks
@@ -647,7 +552,7 @@ function inferStatus(response, era) {
647
552
  return 200;
648
553
  }
649
554
  //#endregion
650
- //#region src/server/transports/HTTPDisconnect.ts
555
+ //#region src/server/HTTPDisconnect.ts
651
556
  /**
652
557
  * Composes one incoming HTTP request lifetime with one MCP-owned SSE response lifetime.
653
558
  *
@@ -676,10 +581,10 @@ function inferStatus(response, era) {
676
581
  * @example
677
582
  * ```ts
678
583
  * import { HTTPDisconnect } from '@orkestrel/mcp/server'
679
- * import { openStream } from '@orkestrel/server'
584
+ * import { createStream } from '@orkestrel/server'
680
585
  *
681
586
  * const disconnect = new HTTPDisconnect(request.signal, { interval: 15_000 })
682
- * const stream = openStream()
587
+ * const stream = createStream()
683
588
  * const response = disconnect.bridge(stream)
684
589
  * ```
685
590
  */
@@ -688,6 +593,9 @@ var HTTPDisconnect = class {
688
593
  #lifecycle = new AbortController();
689
594
  #interval;
690
595
  #signal;
596
+ #pull = (controller) => this.#pump(controller);
597
+ #cancel = (reason) => this.#discard(reason);
598
+ #reader;
691
599
  #timer;
692
600
  #bridged = false;
693
601
  #pulling = false;
@@ -704,8 +612,8 @@ var HTTPDisconnect = class {
704
612
  this.#signal = AbortSignal.any([signal, this.#response.signal]);
705
613
  }
706
614
  /**
707
- * The signal aborted by the incoming request, or by any end of this response that is not
708
- * its graceful completion.
615
+ * Returns the signal aborted by the incoming request, or by any end of this response that
616
+ * is not its graceful completion.
709
617
  *
710
618
  * @returns The composed lifecycle signal
711
619
  */
@@ -731,7 +639,7 @@ var HTTPDisconnect = class {
731
639
  const response = stream.response;
732
640
  const body = response.body;
733
641
  if (body === null) throw new Error("MCP SSE response has no body");
734
- const reader = body.getReader();
642
+ this.#reader = body.getReader();
735
643
  this.#timer = setInterval(() => {
736
644
  if (stream.closed) {
737
645
  if (!this.#pulling) this.#abort();
@@ -743,29 +651,39 @@ var HTTPDisconnect = class {
743
651
  });
744
652
  if (this.#signal.aborted) this.#release();
745
653
  else if (stream.closed) this.#abort();
746
- return new Response(createReadableStream(async (controller) => {
747
- this.#pulling = true;
748
- try {
749
- const chunk = await reader.read();
750
- if (chunk.done) {
751
- this.#release();
752
- controller.close();
753
- } else controller.enqueue(chunk.value);
754
- } catch (error) {
755
- this.#abort();
756
- controller.error(error);
757
- } finally {
758
- this.#pulling = false;
759
- }
760
- }, async (reason) => {
761
- this.#abort();
762
- await reader.cancel(reason);
654
+ return new Response(new ReadableStream({
655
+ pull: this.#pull,
656
+ cancel: this.#cancel
763
657
  }), {
764
658
  status: response.status,
765
659
  statusText: response.statusText,
766
660
  headers: response.headers
767
661
  });
768
662
  }
663
+ async #pump(controller) {
664
+ const reader = this.#reader;
665
+ if (reader === void 0) {
666
+ controller.close();
667
+ return;
668
+ }
669
+ this.#pulling = true;
670
+ try {
671
+ const chunk = await reader.read();
672
+ if (chunk.done) {
673
+ this.#release();
674
+ controller.close();
675
+ } else controller.enqueue(chunk.value);
676
+ } catch (error) {
677
+ this.#abort();
678
+ controller.error(error);
679
+ } finally {
680
+ this.#pulling = false;
681
+ }
682
+ }
683
+ async #discard(reason) {
684
+ this.#abort();
685
+ await this.#reader?.cancel(reason);
686
+ }
769
687
  #release() {
770
688
  if (this.#timer !== void 0) {
771
689
  clearInterval(this.#timer);
@@ -784,12 +702,17 @@ var HTTPDisconnect = class {
784
702
  * Creates the Streamable-HTTP POST handler used by `createMCPRoutes`.
785
703
  *
786
704
  * @remarks
787
- * Modern requests require matching protocol/method headers and a matching name header only
788
- * for `tools/call`; mismatch returns HTTP `400` + `-32020`. Headerless `initialize` is
789
- * accepted, while every other headerless request needs a live legacy session to supply its
790
- * pinned version. A legacy-shaped request carrying a protocol header is admitted only for a
791
- * legacy revision; any other value, the modern revision included, returns HTTP `400` + `-32022`
792
- * whose `supported` names the legacy revisions this door accepts. A present origin must occur in `origin.origins` unless validation is
705
+ * Modern requests require matching protocol/method headers and a matching name header on each
706
+ * method carrying a named target — `tools/call` and `prompts/get` against `params.name`,
707
+ * `resources/read` against `params.uri` with a Base64-sentinel value decoded before the
708
+ * comparison; a missing, mismatched, or invalidly encoded value returns HTTP `400` + `-32020`.
709
+ * A protocol header naming a MODERN revision holds the request to that revision whatever shape
710
+ * its body arrived in, so a body with no parsable modern `_meta` returns HTTP `400` + `-32602`.
711
+ * Headerless `initialize` is accepted, while every other headerless request needs a live legacy
712
+ * session to supply its pinned version. A legacy-shaped request carrying a protocol header is
713
+ * otherwise admitted only for a legacy revision; a revision this server does not implement
714
+ * returns HTTP `400` + `-32022` whose `supported` names the legacy revisions this door accepts.
715
+ * A present origin must occur in `origin.origins` unless validation is
793
716
  * explicitly delegated upstream. Modern dispatch errors use their protocol status map; legacy
794
717
  * errors remain in-band at HTTP `200`. A streamed response composes the fetch-standard request
795
718
  * signal with response-body cancellation and supplies the result to every dispatched modern
@@ -827,22 +750,49 @@ function createMCPPostHandler(mcp, options) {
827
750
  } catch {
828
751
  return Response.json(buildJSONRPCError(void 0, JSONRPC_PARSE_ERROR, "Parse error"), { status: 400 });
829
752
  }
830
- let parsed;
831
- try {
832
- parsed = JSON.parse(text);
833
- } catch {
834
- return Response.json(buildJSONRPCError(void 0, JSONRPC_PARSE_ERROR, "Parse error"), { status: 400 });
835
- }
753
+ const parsed = parseJSON(text);
754
+ if (parsed === void 0) return Response.json(buildJSONRPCError(void 0, JSONRPC_PARSE_ERROR, "Parse error"), { status: 400 });
836
755
  const invocation = parseJSONRPCMessage(parsed);
837
756
  if (invocation === void 0 || !("method" in invocation)) return Response.json(buildJSONRPCError(void 0, JSONRPC_INVALID_REQUEST, "Invalid Request"), { status: 400 });
838
- const era = isModernRequest(invocation) ? "modern" : "legacy";
757
+ const era = inferRequestEra(invocation);
839
758
  const id = invocation.id;
840
759
  const protocol = request.headers.get(MCP_PROTOCOL_VERSION_HEADER);
841
- if (era === "modern") {
760
+ if (era === "modern" || isMCPModernVersion(protocol)) {
842
761
  if (parseRequestContext(invocation) === void 0) return Response.json(buildJSONRPCError(id, JSONRPC_INVALID_PARAMS, "Invalid params: malformed modern request metadata"), { status: 400 });
843
762
  }
844
763
  const issue = inferHeaderIssue(request, invocation);
845
764
  if (issue !== void 0) return Response.json(buildJSONRPCError(id, MCP_HEADER_MISMATCH, issue.message), { status: 400 });
765
+ const called = invocation.params?.["name"];
766
+ if (era === "modern" && invocation.method === "tools/call" && isString(called)) {
767
+ let parameters = [];
768
+ let cursor = void 0;
769
+ for (let page = 0; page < MCP_LOOKUP_PAGES; page += 1) {
770
+ const answer = await mcp.dispatch({
771
+ jsonrpc: "2.0",
772
+ id: 0,
773
+ method: "tools/list",
774
+ params: {
775
+ _meta: invocation.params?.["_meta"],
776
+ ...cursor === void 0 ? {} : { cursor }
777
+ }
778
+ });
779
+ if (Symbol.asyncIterator in answer) {
780
+ answer.stop();
781
+ break;
782
+ }
783
+ const schema = extractToolSchema(answer, called);
784
+ if (schema !== void 0) {
785
+ parameters = buildHeaderParameters(schema) ?? [];
786
+ break;
787
+ }
788
+ const listing = answer.result;
789
+ const next = isRecord(listing) ? listing["nextCursor"] : void 0;
790
+ if (!isString(next)) break;
791
+ cursor = next;
792
+ }
793
+ const refusal = inferParameterRefusal(request, parameters, invocation.params?.["arguments"]);
794
+ if (refusal !== void 0) return Response.json(buildJSONRPCError(id, MCP_HEADER_MISMATCH, refusal), { status: 400 });
795
+ }
846
796
  if (era === "legacy") {
847
797
  if (protocol !== null && !isMCPLegacyVersion(protocol)) return Response.json(buildJSONRPCError(id, MCP_UNSUPPORTED_VERSION, `Unsupported MCP protocol version '${protocol}'`, {
848
798
  supported: SUPPORTED_LEGACY_PROTOCOL_VERSIONS,
@@ -856,7 +806,7 @@ function createMCPPostHandler(mcp, options) {
856
806
  ...caller === void 0 ? {} : { caller }
857
807
  });
858
808
  if (response !== void 0 && Symbol.asyncIterator in response) {
859
- const stream = openStream();
809
+ const stream = createStream();
860
810
  stream.response.headers.set(SSE_BUFFERING_HEADER, "no");
861
811
  queueMicrotask(() => void sendEventStream(response, stream));
862
812
  return disconnect.bridge(stream);
@@ -864,7 +814,7 @@ function createMCPPostHandler(mcp, options) {
864
814
  const status = inferStatus(response, era);
865
815
  if (response === void 0) return new Response(null, { status });
866
816
  if (status === 200 && streaming && acceptsEventStream(request)) {
867
- const stream = openStream();
817
+ const stream = createStream();
868
818
  stream.response.headers.set(SSE_BUFFERING_HEADER, "no");
869
819
  stream.write({ data: JSON.stringify(response) });
870
820
  stream.end();
@@ -874,172 +824,14 @@ function createMCPPostHandler(mcp, options) {
874
824
  };
875
825
  }
876
826
  //#endregion
877
- //#region src/server/transports/HTTPClientTransport.ts
878
- /**
879
- * The HTTP CLIENT transport for the Model Context Protocol — a
880
- * {@link MCPClientTransportInterface} that drives a REMOTE Streamable-HTTP MCP server over
881
- * `fetch`, the egress mirror of the server's `createMCPRoutes`.
882
- *
883
- * @remarks
884
- * - **Request/response over `fetch`.** `send(message)` POSTs the JSON-serialized
885
- * message to `options.url` with `content-type: application/json` and an
886
- * `Accept` of BOTH `application/json` and `text/event-stream` (so the server may
887
- * answer with either framing) — plus any `options.headers` (for example, an `Authorization`
888
- * bearer). It then decodes the reply and emits each decoded {@link JSONRPCMessage} on
889
- * the `message` event the {@link import('@orkestrel/mcp').MCPClientInterface} subscribes
890
- * to.
891
- * - **Both reply framings.** A `200` with an `application/json` body is parsed with
892
- * `parseJSONRPCMessage`; a `200` with a `text/event-stream` body is decoded with the
893
- * `@orkestrel/sse` {@link import('@orkestrel/sse').SSEParserInterface} ({@link
894
- * readEventStream}) — the inverse of the server's `openStream` seam, so the wire
895
- * round-trips. A `202`
896
- * Accepted (a notification) carries no body and emits nothing.
897
- * - **Session and protocol headers.** `start()` is a no-op (a
898
- * request/response transport opens no long-lived connection). The
899
- * `mcp-session-id` response header, when a STATEFUL server sends one (on
900
- * `initialize`), is captured into `session` and then ECHOED as the
901
- * `mcp-session-id` request header on every SUBSEQUENT request — so an
902
- * `MCPClient` passes a stateful server's session validation. The
903
- * initialize result's `protocolVersion` is likewise captured, but only
904
- * when it is a SUPPORTED value, and echoed as `mcp-protocol-version` alone on
905
- * subsequent legacy requests. Modern requests instead derive protocol and method
906
- * headers from the message, plus the name header only for `tools/call`.
907
- * Before initialize returns, neither captured legacy header is sent.
908
- * `close()` clears the captured protocol so a reconnect's `initialize`
909
- * POST is headerless; the captured `session` persists across `close()`.
910
- * - **`close()` releases what is in flight.** Every `fetch` this transport still has open is
911
- * ABORTED, which cancels the response body a `send` is reading — an SSE reply the server
912
- * never ends would otherwise outlive the transport, with nothing left able to reach it. The
913
- * aborted read surfaces on `error` and the `send` reporting it resolves. `close()` is
914
- * idempotent (one `close` event per connected lifetime), and `start()` opens the next one.
915
- * - **Total at the boundary.** Every reply is narrowed (`parseJSONRPCMessage`,
916
- * the SSE decoder). A non-message success reply is dropped, never asserted. A non-success
917
- * reply that carries no valid JSON-RPC message rejects `send` with its HTTP status and body
918
- * shape. A valid JSON-RPC error body is emitted at any HTTP status. A `fetch` / decode failure
919
- * on a success response surfaces on the `error` event rather than escaping `send`.
920
- * - **Observable.** Owns the `emitter` ({@link MCPClientTransportEventMap}); fires
921
- * `message` per decoded reply, `error` on a fault, and `close` on `close()`.
922
- *
923
- * @example
924
- * ```ts
925
- * const transport = new HTTPClientTransport({ url: 'http://localhost:3000/mcp' })
926
- * const client = new MCPClient({ transport })
927
- * await client.connect()
928
- * ```
929
- */
930
- var HTTPClientTransport = class {
931
- #emitter;
932
- #url;
933
- #headers;
934
- #fetch;
935
- #timeout;
936
- #pending = /* @__PURE__ */ new Set();
937
- #session = void 0;
938
- #protocol = void 0;
939
- #closed = false;
940
- constructor(options) {
941
- this.#emitter = new Emitter();
942
- this.#url = options.url;
943
- this.#headers = options.headers ?? {};
944
- this.#fetch = options.fetch ?? globalThis.fetch;
945
- this.#timeout = options.timeout;
946
- }
947
- get emitter() {
948
- return this.#emitter;
949
- }
950
- get session() {
951
- return this.#session;
952
- }
953
- get duplex() {
954
- return false;
955
- }
956
- async start() {
957
- this.#closed = false;
958
- }
959
- async send(message) {
960
- const request = new AbortController();
961
- this.#pending.add(request);
962
- try {
963
- await this.#exchange(message, request.signal);
964
- } finally {
965
- this.#pending.delete(request);
966
- }
967
- }
968
- async #exchange(message, signal) {
969
- let response;
970
- try {
971
- response = await this.#fetch(this.#url, {
972
- method: "POST",
973
- headers: {
974
- "content-type": "application/json",
975
- accept: "application/json, text/event-stream",
976
- ...this.#session === void 0 ? {} : { [MCP_SESSION_HEADER]: this.#session },
977
- ...this.#buildHeaders(message),
978
- ...this.#headers
979
- },
980
- body: JSON.stringify(message),
981
- signal: this.#timeout === void 0 ? signal : AbortSignal.any([signal, AbortSignal.timeout(this.#timeout)])
982
- });
983
- } catch (error) {
984
- this.#emitter.emit("error", error);
985
- return;
986
- }
987
- const session = response.headers.get(MCP_SESSION_HEADER);
988
- if (session !== null) this.#session = session;
989
- await this.#deliver(response);
990
- }
991
- async close() {
992
- if (this.#closed) return;
993
- this.#closed = true;
994
- for (const request of this.#pending) request.abort();
995
- this.#pending.clear();
996
- this.#protocol = void 0;
997
- this.#emitter.emit("close");
998
- }
999
- #buildHeaders(message) {
1000
- if (isModernRequest(message)) {
1001
- const version = inferRequestVersion(message);
1002
- const name = message.params?.["name"];
1003
- return {
1004
- ...version === void 0 ? {} : { [MCP_PROTOCOL_VERSION_HEADER]: version },
1005
- [MCP_METHOD_HEADER]: message.method,
1006
- ...message.method === "tools/call" && isString(name) ? { [MCP_NAME_HEADER]: name } : {}
1007
- };
1008
- }
1009
- return this.#protocol === void 0 ? {} : { [MCP_PROTOCOL_VERSION_HEADER]: this.#protocol };
1010
- }
1011
- async #deliver(response) {
1012
- if (response.status === 202) return;
1013
- const type = response.headers.get("content-type") ?? "";
1014
- let messages = [];
1015
- let failure;
1016
- try {
1017
- if (type.includes("text/event-stream")) messages = await readEventStream(response);
1018
- else if (type.includes("application/json")) {
1019
- const message = parseJSONRPCMessage(await response.json());
1020
- if (message !== void 0) messages = [message];
1021
- }
1022
- } catch (error) {
1023
- failure = { error };
1024
- }
1025
- for (const message of messages) this.#capture(message);
1026
- if (!response.ok && messages.length === 0) throw buildResponseError(response, type);
1027
- if (failure !== void 0) this.#emitter.emit("error", failure.error);
1028
- }
1029
- #capture(message) {
1030
- if (isJSONRPCResponse(message) && isRecord(message.result) && isMCPVersion(message.result["protocolVersion"])) this.#protocol = message.result["protocolVersion"];
1031
- this.#emitter.emit("message", message);
1032
- }
1033
- };
1034
- //#endregion
1035
827
  //#region src/server/MCPSession.ts
1036
828
  /**
1037
- * One MCP transport session — the per-session entity a {@link
829
+ * Represents one MCP transport session — the per-session entity a {@link
1038
830
  * import('./middlewares.js').createMCPSession} middleware owns, keyed by its `id`, carrying the
1039
831
  * resumable server→client push channel with its bounded replay log FOLDED IN.
1040
832
  *
1041
833
  * @remarks
1042
- * The single session entity (the old `SessionState` + `EventStore` merged): it holds the
834
+ * One entity carries the whole session: it holds the
1043
835
  * session `id`, its OWN bounded, replayable log of pushed server→client messages (the
1044
836
  * resumable GET-SSE channel — a private `#events` `Map` + a monotone `#counter`, with
1045
837
  * `capacity` / `ttl` eviction, not a separate store), and the set of open
@@ -1051,7 +843,7 @@ var HTTPClientTransport = class {
1051
843
  * SSE event (`stream.write({ id, data })`). A push with NO attached stream is still logged,
1052
844
  * so a client that connects (or reconnects with a `Last-Event-ID`) LATER replays it from the
1053
845
  * log. A `write` to a closed stream is a safe no-op (the {@link
1054
- * `@orkestrel/server`'s `openStream` contract), so a just-disconnected stream that
846
+ * `@orkestrel/server`'s `createStream` contract), so a just-disconnected stream that
1055
847
  * has not yet been `detach`ed never throws. A replayed event and the live one carry the
1056
848
  * IDENTICAL id (the log assigns it once).
1057
849
  *
@@ -1076,9 +868,10 @@ var HTTPClientTransport = class {
1076
868
  * The middleware opens the stream (the spine seam) and registers it here; this class only
1077
869
  * serializes a message onto the already-open streams.
1078
870
  *
1079
- * - **Injected clock.** `push` / `replay` accept an optional `now` (epoch ms), defaulting to
1080
- * `Date.now()` — so a test drives TTL eviction with an elapsed clock rather than a real
1081
- * timer.
871
+ * - **Injected clock.** {@link import('./types.js').MCPSessionOptions.clock} supplies the
872
+ * epoch-ms clock the lazy TTL sweep reads, defaulting to `Date.now` — so a test drives TTL
873
+ * eviction with an elapsed clock rather than a real timer, and the middleware that mints a
874
+ * session hands its own clock down instead of leaving the log on wall-clock time.
1082
875
  *
1083
876
  * @example
1084
877
  * ```ts
@@ -1094,11 +887,13 @@ var MCPSession = class {
1094
887
  #streams = /* @__PURE__ */ new Set();
1095
888
  #capacity;
1096
889
  #ttl;
890
+ #clock;
1097
891
  #counter = 0;
1098
892
  constructor(id, options) {
1099
893
  this.#id = id;
1100
894
  this.#capacity = options?.capacity ?? 1024;
1101
895
  this.#ttl = options?.ttl ?? 3e5;
896
+ this.#clock = options?.clock ?? Date.now;
1102
897
  }
1103
898
  get id() {
1104
899
  return this.#id;
@@ -1109,8 +904,8 @@ var MCPSession = class {
1109
904
  detach(stream) {
1110
905
  this.#streams.delete(stream);
1111
906
  }
1112
- push(message, now = Date.now()) {
1113
- const id = this.#append(message, now);
907
+ push(message) {
908
+ const id = this.#append(message);
1114
909
  const data = JSON.stringify(message);
1115
910
  for (const stream of this.#streams) stream.write({
1116
911
  id,
@@ -1118,15 +913,16 @@ var MCPSession = class {
1118
913
  });
1119
914
  return id;
1120
915
  }
1121
- replay(afterId, now = Date.now()) {
1122
- this.#evict(now);
916
+ replay(afterId) {
917
+ this.#evict(this.#clock());
1123
918
  const out = [];
1124
919
  let found = false;
1125
920
  for (const entry of this.#events.values()) if (found) out.push(entry);
1126
921
  else if (entry.id === afterId) found = true;
1127
922
  return found ? out : [];
1128
923
  }
1129
- #append(message, now) {
924
+ #append(message) {
925
+ const now = this.#clock();
1130
926
  this.#evict(now);
1131
927
  this.#counter += 1;
1132
928
  const id = this.#counter.toString(36);
@@ -1152,34 +948,39 @@ var MCPSession = class {
1152
948
  //#endregion
1153
949
  //#region src/server/transports/WebSocketServerTransport.ts
1154
950
  /**
1155
- * The per-connection JSON-RPC-over-WebSocket SERVER bridge wraps a
1156
- * {@link NodeWebSocketInterface} (the RFC 6455 wire wrapper) as a
1157
- * {@link MCPClientTransportInterface}, the bidirectional JSON-RPC message channel
951
+ * Wraps a {@link NodeWebSocketInterface} (the RFC 6455 wire wrapper) as a
952
+ * {@link MCPMessageTransportInterface} the per-connection JSON-RPC-over-WebSocket SERVER
953
+ * bridge, the bidirectional JSON-RPC message channel
1158
954
  * `createWebSocketServer` pumps `mcp.dispatch` over and the egress mirror's
1159
955
  * {@link import('./WebSocketClientTransport.js').WebSocketClientTransport} reuses.
1160
956
  *
1161
957
  * @remarks
1162
- * - **Reuses `MCPClientTransportInterface`.** It IS the same generic carrier the HTTP
958
+ * - **Reuses `MCPMessageTransportInterface`.** It IS the same generic carrier the HTTP
1163
959
  * client transport implements — `emitter` (`message` / `close` / `error`), `start`,
1164
960
  * `send`, `close` — so the WebSocket server and client both speak ONE transport contract,
1165
961
  * no near-duplicate sibling interface. `session` is `undefined` (the stateless v1; a
1166
962
  * session id is the deferred sessions tier). The name keeps the role explicit even though
1167
963
  * the shape is shared.
1168
964
  * - **Inbound (`message`).** `start()` subscribes to the socket's `message` event; each text
1169
- * frame is `JSON.parse`d inside a try/catch and narrowed with `parseJSONRPCMessage` — a
965
+ * frame runs through the shared `deliverMessage` fold (parse, then narrow) — a
1170
966
  * well-formed {@link JSONRPCMessage} is re-emitted on this transport's `message` event (the
1171
967
  * parsed envelope the {@link import('@orkestrel/mcp').MCPServerInterface} pump dispatches), while
1172
968
  * a non-JSON or non-message frame is surfaced on `error` and DROPPED, never thrown. It
1173
969
  * also bridges the socket's `close` → this transport's `close`, and the socket's `error`.
1174
970
  * - **Outbound (`send`).** `send(message)` writes one text frame
1175
- * (`nodeWs.send(JSON.stringify(message))`); the underlying wrapper no-ops a write on a
1176
- * non-open socket, so a closed connection drops silently rather than throwing.
971
+ * (`nodeWs.send(JSON.stringify(message))`). The underlying wrapper no-ops a write on a
972
+ * non-open socket and confirms nothing, so this bridge answers a closed channel from its own
973
+ * state and the socket's `readyState`: a `send` after `close()`, after the peer's close, or on
974
+ * a socket that is not `OPEN` REJECTS with `WebSocket transport is not connected` rather than
975
+ * resolving on a frame nobody wrote. `bindServer` catches that rejection and routes it to the
976
+ * dispatcher's `error` event, and it aborts every in-flight request the moment this transport's
977
+ * `close` fires — so a peer that disconnects mid-request is answered by no write at all.
1177
978
  * - **`close()`** removes the subscriptions `start()` installed on the socket, closes the
1178
979
  * underlying socket (the RFC 6455 close handshake), and fires the transport's `close` event
1179
980
  * (idempotent — a second `close`, or a socket-driven close, emits once). A frame that arrives
1180
981
  * between that release and the peer's close echo reaches nothing: the socket-driven close path
1181
982
  * releases the same way, so a closed transport is never subscribed to a live socket.
1182
- * - **Observable.** Owns the `emitter` ({@link MCPClientTransportEventMap}); the emitter
983
+ * - **Observable.** Owns the `emitter` ({@link MCPMessageTransportEventMap}); the emitter
1183
984
  * isolates a listener throw (a buggy observer never corrupts the bridge). `error` is a
1184
985
  * DOMAIN event (a transport-level fault), distinct from the emitter's listener-error channel.
1185
986
  */
@@ -1210,6 +1011,7 @@ var WebSocketServerTransport = class {
1210
1011
  this.#socket.emitter.on("error", this.#failure);
1211
1012
  }
1212
1013
  async send(message) {
1014
+ if (this.#closed || this.#socket.readyState !== WEBSOCKET_READY_OPEN) throw new Error("WebSocket transport is not connected");
1213
1015
  this.#socket.send(JSON.stringify(message));
1214
1016
  }
1215
1017
  async close() {
@@ -1220,19 +1022,7 @@ var WebSocketServerTransport = class {
1220
1022
  this.#emitter.emit("close");
1221
1023
  }
1222
1024
  #receive(text) {
1223
- let parsed;
1224
- try {
1225
- parsed = JSON.parse(text);
1226
- } catch (error) {
1227
- this.#emitter.emit("error", error);
1228
- return;
1229
- }
1230
- const message = parseJSONRPCMessage(parsed);
1231
- if (message === void 0) {
1232
- this.#emitter.emit("error", /* @__PURE__ */ new Error("non-JSON-RPC WebSocket frame"));
1233
- return;
1234
- }
1235
- this.#emitter.emit("message", message);
1025
+ deliverMessage(this.#emitter, text, "non-JSON-RPC WebSocket frame");
1236
1026
  }
1237
1027
  #onClose() {
1238
1028
  if (this.#closed) return;
@@ -1249,10 +1039,10 @@ var WebSocketServerTransport = class {
1249
1039
  //#endregion
1250
1040
  //#region src/server/transports/WebSocketClientTransport.ts
1251
1041
  /**
1252
- * The WebSocket CLIENT transport for the Model Context Protocol — a
1253
- * {@link MCPClientTransportInterface} that drives a REMOTE MCP server over a WebSocket, the
1042
+ * Drives a REMOTE MCP server over a WebSocket — a CLIENT
1043
+ * {@link MCPMessageTransportInterface} for the Model Context Protocol, the
1254
1044
  * egress mirror of {@link import('./factories.js').createWebSocketServer} and the WebSocket
1255
- * sibling of {@link import('./HTTPClientTransport.js').HTTPClientTransport}.
1045
+ * sibling of {@link import('@orkestrel/mcp').HTTPClientTransport}.
1256
1046
  *
1257
1047
  * @remarks
1258
1048
  * - **Persistent bidirectional channel (unlike the HTTP transport).** `start()` performs the
@@ -1270,16 +1060,18 @@ var WebSocketServerTransport = class {
1270
1060
  * transport while the handshake was on the wire, both WIN — the socket that arrives late is
1271
1061
  * DESTROYED and never bound, so no orphan is left re-emitting frames at nobody. Both
1272
1062
  * `start()` calls still resolve; exactly one socket is ever bound.
1273
- * - **Inbound (`message`).** Each decoded text frame is `JSON.parse`d (guarded) and narrowed
1274
- * with `parseJSONRPCMessage` — a {@link JSONRPCMessage} re-emits on this transport's `message`
1063
+ * - **Inbound (`message`).** Each decoded text frame runs through the shared `deliverMessage`
1064
+ * fold (parse, then narrow) — a {@link JSONRPCMessage} re-emits on this transport's `message`
1275
1065
  * event (the reply the {@link import('@orkestrel/mcp').MCPClientInterface} correlates by `id`); a
1276
1066
  * non-JSON / non-message frame surfaces on `error` and is dropped. The socket's `close`
1277
1067
  * / `error` bridge to this transport's events.
1278
1068
  * - **Outbound (`send`).** `send(message)` writes one masked text frame. A socket write is not
1279
- * confirmed, so this transport answers a closed channel from its OWN state: a `send` with no
1280
- * bound socket — before `start()`, after `close()`, or after the peer ended the socket —
1281
- * REJECTS with `WebSocket transport is not connected`. It neither drops the message (the
1282
- * browser face's posture) nor queues it for a connection this transport is not holding.
1069
+ * confirmed, so this transport answers a closed channel from its own state AND the socket's
1070
+ * `readyState`: a `send` with no bound socket — before `start()`, after `close()`, or after the
1071
+ * peer ended the socket — and a `send` on a bound socket that is not `OPEN` both REJECT with
1072
+ * `WebSocket transport is not connected`. It neither drops the message nor queues it for a
1073
+ * connection this transport is not holding — the browser face queues a pre-open send, and this
1074
+ * one, holding no connection to flush it onto, rejects that too.
1283
1075
  * - **`close()`** unsubscribes from the socket, closes it, and fires `close` (idempotent). An
1284
1076
  * upgrade still on the wire is DESTROYED, so a `close()` during the handshake ends the
1285
1077
  * transport at once instead of waiting for a peer that may never answer — the suspended
@@ -1287,7 +1079,7 @@ var WebSocketServerTransport = class {
1287
1079
  * - **URL scheme.** `options.url` accepts a `ws://` / `wss://` URL or an `http://` / `https://`
1288
1080
  * one; a `ws(s)` scheme is converted to `http(s)` for the underlying upgrade request (`wss`
1289
1081
  * → TLS through `node:https`). Either reaches the same endpoint.
1290
- * - **Observable.** Owns the `emitter` ({@link MCPClientTransportEventMap}); every emit
1082
+ * - **Observable.** Owns the `emitter` ({@link MCPMessageTransportEventMap}); every emit
1291
1083
  * the emitter isolates a listener throw (a buggy observer never corrupts the transport);
1292
1084
  * `error` is a DOMAIN event (a transport-level fault).
1293
1085
  *
@@ -1324,14 +1116,14 @@ var WebSocketClientTransport = class {
1324
1116
  if (this.#socket !== void 0) return;
1325
1117
  this.#closed = false;
1326
1118
  try {
1327
- await this.#connect(this.#httpURL(), randomBytes(16).toString("base64"));
1119
+ await this.#connect(this.#toHTTPURL(), randomBytes(16).toString("base64"));
1328
1120
  } finally {
1329
1121
  this.#request = void 0;
1330
1122
  }
1331
1123
  }
1332
1124
  async send(message) {
1333
1125
  const socket = this.#socket;
1334
- if (socket === void 0) throw new Error("WebSocket transport is not connected");
1126
+ if (socket === void 0 || socket.readyState !== WEBSOCKET_READY_OPEN) throw new Error("WebSocket transport is not connected");
1335
1127
  socket.send(JSON.stringify(message));
1336
1128
  }
1337
1129
  async close() {
@@ -1357,7 +1149,7 @@ var WebSocketClientTransport = class {
1357
1149
  Upgrade: "websocket",
1358
1150
  "Sec-WebSocket-Key": key,
1359
1151
  "Sec-WebSocket-Version": WEBSOCKET_VERSION,
1360
- "Sec-WebSocket-Protocol": "mcp",
1152
+ "Sec-WebSocket-Protocol": MCP_WEBSOCKET_SUBPROTOCOL,
1361
1153
  ...this.#headers
1362
1154
  }
1363
1155
  });
@@ -1409,19 +1201,7 @@ var WebSocketClientTransport = class {
1409
1201
  socket.emitter.off("error", this.#failure);
1410
1202
  }
1411
1203
  #receive(text) {
1412
- let parsed;
1413
- try {
1414
- parsed = JSON.parse(text);
1415
- } catch (error) {
1416
- this.#emitter.emit("error", error);
1417
- return;
1418
- }
1419
- const message = parseJSONRPCMessage(parsed);
1420
- if (message === void 0) {
1421
- this.#emitter.emit("error", /* @__PURE__ */ new Error("non-JSON-RPC WebSocket frame"));
1422
- return;
1423
- }
1424
- this.#emitter.emit("message", message);
1204
+ deliverMessage(this.#emitter, text, "non-JSON-RPC WebSocket frame");
1425
1205
  }
1426
1206
  #onClose() {
1427
1207
  if (this.#closed) return;
@@ -1430,7 +1210,7 @@ var WebSocketClientTransport = class {
1430
1210
  this.#socket = void 0;
1431
1211
  this.#emitter.emit("close");
1432
1212
  }
1433
- #httpURL() {
1213
+ #toHTTPURL() {
1434
1214
  const url = new URL(this.#url);
1435
1215
  if (url.protocol === "ws:") url.protocol = "http:";
1436
1216
  else if (url.protocol === "wss:") url.protocol = "https:";
@@ -1441,10 +1221,9 @@ var WebSocketClientTransport = class {
1441
1221
  //#endregion
1442
1222
  //#region src/server/transports/StdioClientTransport.ts
1443
1223
  /**
1444
- * The stdio CLIENT transport for the Model Context Protocol a
1445
- * {@link StdioClientTransportInterface} that drives a CHILD PROCESS MCP server over
1446
- * newline-delimited JSON-RPC on `stdin`/`stdout`, the stdio sibling of {@link
1447
- * import('./HTTPClientTransport.js').HTTPClientTransport} and {@link
1224
+ * Drives a CHILD PROCESS MCP server over newline-delimited JSON-RPC on `stdin`/`stdout`
1225
+ * a {@link StdioClientTransportInterface}, the stdio sibling of {@link
1226
+ * import('@orkestrel/mcp').HTTPClientTransport} and {@link
1448
1227
  * import('./WebSocketClientTransport.js').WebSocketClientTransport}.
1449
1228
  *
1450
1229
  * @remarks
@@ -1485,7 +1264,7 @@ var WebSocketClientTransport = class {
1485
1264
  * never moves again, so a detached descendant writing to the inherited stderr after the cutoff
1486
1265
  * cannot grow it. See {@link StdioClientTransportInterface.evidence} for the readings and the
1487
1266
  * byte bound.
1488
- * - **Observable.** Owns the `emitter` ({@link MCPClientTransportEventMap}); the
1267
+ * - **Observable.** Owns the `emitter` ({@link MCPMessageTransportEventMap}); the
1489
1268
  * emitter isolates a listener throw; `error` is a DOMAIN event (a transport-level
1490
1269
  * fault, including the child spawn cause the supervisor surfaces and the notice that this
1491
1270
  * lifetime's `evidence` was cut off at the `drain` bound), distinct from the emitter's own
@@ -1607,15 +1386,14 @@ var StdioClientTransport = class {
1607
1386
  //#endregion
1608
1387
  //#region src/server/transports/StdioServerTransport.ts
1609
1388
  /**
1610
- * The stdio SERVER transport for the Model Context Protocol — wraps an injectable
1611
- * readable/writable stream pair (`process.stdin`/`process.stdout` in production, a
1612
- * test double in tests) as a {@link MCPClientTransportInterface}, the newline-delimited
1613
- * JSON-RPC channel {@link import('../factories.js').createStdioServer} pumps
1614
- * `mcp.dispatch` over, the stdio mirror of {@link
1389
+ * Wraps an injectable readable/writable stream pair (`process.stdin`/`process.stdout` in
1390
+ * production, a test double in tests) as a {@link MCPMessageTransportInterface} — the
1391
+ * newline-delimited JSON-RPC channel {@link import('../factories.js').createStdioServer}
1392
+ * pumps `mcp.dispatch` over, the stdio mirror of {@link
1615
1393
  * import('./WebSocketServerTransport.js').WebSocketServerTransport}.
1616
1394
  *
1617
1395
  * @remarks
1618
- * - **Reuses `MCPClientTransportInterface`.** The same generic carrier the HTTP
1396
+ * - **Reuses `MCPMessageTransportInterface`.** The same generic carrier the HTTP
1619
1397
  * and WebSocket server transports implement — `emitter` (`message` / `close` /
1620
1398
  * `error`), `start`, `send`, `close`. `session` is `undefined` (the stateless v1).
1621
1399
  * - **Inbound (`message`).** `start()` subscribes to `input`'s `data` event; each
@@ -1641,7 +1419,7 @@ var StdioClientTransport = class {
1641
1419
  * listener receives data. The injected streams are owned by the caller (typically
1642
1420
  * `process.stdin`/`process.stdout`), so the transport never destroys, ends, or blanket-clears
1643
1421
  * them.
1644
- * - **Observable.** Owns the `emitter` ({@link MCPClientTransportEventMap}); the
1422
+ * - **Observable.** Owns the `emitter` ({@link MCPMessageTransportEventMap}); the
1645
1423
  * emitter isolates a listener throw; `error` is a DOMAIN event (a transport-level
1646
1424
  * fault), distinct from the emitter's own listener-error channel.
1647
1425
  */
@@ -1747,6 +1525,87 @@ function createMCPContinuation(secret) {
1747
1525
  };
1748
1526
  }
1749
1527
  /**
1528
+ * Creates the server-side mirror of
1529
+ * {@link import('@orkestrel/mcp').createDuplexClientTransport}: the adapter that bridges a
1530
+ * message-channel {@link MCPMessageTransportInterface}
1531
+ * (the shape the stdio and WebSocket SERVER transports already implement) onto the
1532
+ * environment-agnostic {@link import('@orkestrel/mcp').MCPTransportInterface} port — what
1533
+ * {@link createStdioServer} and {@link createWebSocketServer} pipe through `bindServer`, so
1534
+ * the request/reply/error pump those factories used to hand-roll identically now lives ONCE
1535
+ * in the core binder. {@link import('@orkestrel/mcp').createDuplexClientTransport} adapts the
1536
+ * same two contracts the other way.
1537
+ *
1538
+ * @remarks
1539
+ * `send` decodes the already-serialized reply string back to a {@link JSONRPCMessage}
1540
+ * and writes it through `transport.send` (the same `JSON.stringify` the underlying
1541
+ * transport already performs, so the wire bytes are unchanged). `listen` filters
1542
+ * `transport`'s `message` event to INVOCATIONS ONLY — requests and notifications, never a
1543
+ * stray response, exactly as the prior hand-rolled pumps did — and re-serializes each one
1544
+ * back to a string for `bindServer`. `closed` bridges `transport`'s `close` event. `close`
1545
+ * closes the underlying `transport`.
1546
+ *
1547
+ * @remarks A message crossing this bridge is decoded and re-encoded TWICE, and that is
1548
+ * ACCEPTED rather than accidental. Inbound: the carrier already parsed the frame into a
1549
+ * {@link JSONRPCMessage}, and `listen` re-serializes it so `bindServer` can decode it again
1550
+ * under the server's own `limit`. Outbound: `bindServer` serialized the reply, `send` parses
1551
+ * it back, and the carrier stringifies it once more. The cost is two extra `JSON.parse` /
1552
+ * `JSON.stringify` round trips per message, paid to keep ONE pump in the core binder instead
1553
+ * of a hand-rolled one per carrier. It is BOUNDED rather than unbounded because the binder
1554
+ * decodes within `server.limit.message`, so an oversized frame is refused before the second
1555
+ * decode rather than after it. Removing the cost means giving `MCPTransportInterface` a
1556
+ * message-shaped face beside its string one, which every transport would then carry.
1557
+ *
1558
+ * @remarks Per {@link import('@orkestrel/mcp').MCPTransportInterface}, `listen`/`closed`
1559
+ * each hold THE SINGLE current handler (a second call REPLACES the first, never adds).
1560
+ * Because the underlying `transport.emitter` is ADD-based (`on` subscribes, never
1561
+ * replaces), this bridge installs ONE stable emitter listener per event on first use
1562
+ * and re-routes it to whichever handler is active (`undefined` while
1563
+ * none is), so rebinding never double-dispatches.
1564
+ *
1565
+ * @remarks A response whose `result` serializes away (for example, `undefined`) is dropped by
1566
+ * the message validators on the wire's decode side — an asymmetry the stdio/WS carrier
1567
+ * shares with the streamable-HTTP face, because both round-trip through `JSON.stringify`
1568
+ * / `JSON.parse` before re-validation.
1569
+ *
1570
+ * @param transport - The message-channel transport to bridge (stdio or WebSocket)
1571
+ * @returns An {@link import('@orkestrel/mcp').MCPTransportInterface} `bindServer` can drive
1572
+ *
1573
+ * @example
1574
+ * ```ts
1575
+ * import { bindServer } from '@orkestrel/mcp'
1576
+ *
1577
+ * const transport = new StdioServerTransport(process.stdin, process.stdout)
1578
+ * bindServer(mcp, createDuplexServerTransport(transport))
1579
+ * ```
1580
+ */
1581
+ function createDuplexServerTransport(transport) {
1582
+ let onMessage;
1583
+ let onClosed;
1584
+ transport.emitter.on("message", (message) => {
1585
+ if (!isJSONRPCInvocation(message)) return;
1586
+ onMessage?.(JSON.stringify(message));
1587
+ });
1588
+ transport.emitter.on("close", () => {
1589
+ onClosed?.();
1590
+ });
1591
+ return {
1592
+ async send(message) {
1593
+ const decoded = decodeEvent(message);
1594
+ if (decoded === void 0) return;
1595
+ await transport.send(decoded);
1596
+ },
1597
+ listen(handler) {
1598
+ onMessage = handler;
1599
+ },
1600
+ closed(handler) {
1601
+ onClosed = handler;
1602
+ },
1603
+ async close() {
1604
+ await transport.close();
1605
+ }
1606
+ };
1607
+ }
1608
+ /**
1750
1609
  * Creates the MCP Streamable-HTTP transport routes — mounts a transport-agnostic
1751
1610
  * {@link MCPDispatcherInterface} (the `@orkestrel/mcp` dispatch boundary) on the fetch-standard router
1752
1611
  * spine, pumping each `POST` body through `mcp.dispatch`. Returns the {@link RouteInput}s to
@@ -1772,7 +1631,7 @@ function createMCPContinuation(secret) {
1772
1631
  * When `streaming` is enabled (the default) and the client `Accept`s `text/event-stream`,
1773
1632
  * the `200` reply is framed as a Streamable-HTTP SSE response (one `data:` event carrying
1774
1633
  * the JSON-RPC envelope, then the stream ends) through `@orkestrel/server`'s generic
1775
- * {@link import('@orkestrel/server').openStream} seam; otherwise it is a plain JSON body.
1634
+ * {@link import('@orkestrel/server').createStream} seam; otherwise it is a plain JSON body.
1776
1635
  *
1777
1636
  * **Sessions are a SEPARATE, plug-and-play middleware.** `createMCPRoutes` mints / reads no
1778
1637
  * session id. To make the transport STATEFUL, mount {@link
@@ -1811,10 +1670,15 @@ function createMCPRoutes(mcp, options) {
1811
1670
  }
1812
1671
  /**
1813
1672
  * Creates the HTTP CLIENT transport for an {@link import('@orkestrel/mcp').MCPClientInterface}
1814
- * — a {@link MCPClientTransportInterface} that drives a REMOTE Streamable-HTTP MCP server
1673
+ * — a {@link MCPMessageTransportInterface} that drives a REMOTE Streamable-HTTP MCP server
1815
1674
  * over `fetch`. The egress mirror of {@link createMCPRoutes}.
1816
1675
  *
1817
1676
  * @remarks
1677
+ * It returns the core {@link import('@orkestrel/mcp').HTTPClientTransport}, the same class the
1678
+ * browser face's `createHTTPClientTransport` returns, because the class touches `fetch`,
1679
+ * `Response`, `AbortController`, `AbortSignal`, and `WeakMap` alone.
1680
+ *
1681
+ * @remarks
1818
1682
  * Hand it to `createMCPClient({ transport })`: each JSON-RPC message the client sends is
1819
1683
  * `POST`ed to `options.url` with `content-type: application/json` and an `Accept` of
1820
1684
  * both `application/json` and `text/event-stream` (the server answers with EITHER — a
@@ -1830,7 +1694,7 @@ function createMCPRoutes(mcp, options) {
1830
1694
  * @param options - `url` (the remote endpoint; REQUIRED), optional `headers` merged onto
1831
1695
  * every request, optional `fetch` (default `globalThis.fetch`), and optional `timeout`
1832
1696
  * (ms, applied with `AbortSignal.timeout`); see {@link HTTPClientTransportOptions}
1833
- * @returns A working {@link MCPClientTransportInterface} over `fetch`
1697
+ * @returns A working {@link MCPMessageTransportInterface} over `fetch`
1834
1698
  *
1835
1699
  * @example
1836
1700
  * ```ts
@@ -1867,7 +1731,7 @@ function createHTTPClientTransport(options) {
1867
1731
  * only when the client's offer contains it, and sends UNMASKED frames), wraps it in a
1868
1732
  * {@link WebSocketServerTransport}, and pipes it through the core {@link
1869
1733
  * import('@orkestrel/mcp').MCPTransportInterface} port through {@link
1870
- * import('./helpers.js').bridgeMessageTransport} + {@link import('@orkestrel/mcp').bindServer}:
1734
+ * createDuplexServerTransport} + {@link import('@orkestrel/mcp').bindServer}:
1871
1735
  * each inbound REQUEST runs through `mcp.dispatch`, and a defined response is written back
1872
1736
  * as a frame — a NOTIFICATION sends nothing, and a non-request message (a stray response) is
1873
1737
  * ignored. A `dispatch` / `send` fault surfaces on `mcp.emitter`'s `error` event rather than
@@ -1903,7 +1767,7 @@ function createHTTPClientTransport(options) {
1903
1767
  */
1904
1768
  function createWebSocketServer(mcp, options) {
1905
1769
  const path = options.path ?? "/mcp";
1906
- const subprotocol = options.subprotocol ?? "mcp";
1770
+ const subprotocol = options.subprotocol ?? MCP_WEBSOCKET_SUBPROTOCOL;
1907
1771
  const live = /* @__PURE__ */ new Map();
1908
1772
  options.emitter.on("stop", () => {
1909
1773
  for (const [transport, unbind] of live) {
@@ -1927,7 +1791,7 @@ function createWebSocketServer(mcp, options) {
1927
1791
  head,
1928
1792
  ...protocol === void 0 ? {} : { protocol }
1929
1793
  }));
1930
- const unbind = bindServer(mcp, bridgeMessageTransport(transport));
1794
+ const unbind = bindServer(mcp, createDuplexServerTransport(transport));
1931
1795
  live.set(transport, unbind);
1932
1796
  transport.emitter.on("close", () => {
1933
1797
  live.delete(transport);
@@ -1939,7 +1803,7 @@ function createWebSocketServer(mcp, options) {
1939
1803
  }
1940
1804
  /**
1941
1805
  * Creates the WebSocket CLIENT transport for an {@link import('@orkestrel/mcp').MCPClientInterface}
1942
- * — a {@link MCPClientTransportInterface} that drives a REMOTE MCP server over a WebSocket. The
1806
+ * — a {@link MCPMessageTransportInterface} that drives a REMOTE MCP server over a WebSocket. The
1943
1807
  * egress mirror of {@link createWebSocketServer} and the WebSocket sibling of {@link
1944
1808
  * createHTTPClientTransport}.
1945
1809
  *
@@ -1955,7 +1819,7 @@ function createWebSocketServer(mcp, options) {
1955
1819
  *
1956
1820
  * @param options - `url` (the remote WebSocket endpoint; REQUIRED) and optional `headers`
1957
1821
  * merged onto the upgrade request; see {@link WebSocketClientTransportOptions}
1958
- * @returns A working {@link MCPClientTransportInterface} over a WebSocket
1822
+ * @returns A working {@link MCPMessageTransportInterface} over a WebSocket
1959
1823
  *
1960
1824
  * @example
1961
1825
  * ```ts
@@ -2021,7 +1885,7 @@ function createStdioClientTransport(options) {
2021
1885
  * Wraps `options.input` (default `process.stdin`) / `options.output` (default
2022
1886
  * `process.stdout`) in a {@link import('./transports/StdioServerTransport.js').StdioServerTransport}
2023
1887
  * and pipes it through the core {@link import('@orkestrel/mcp').MCPTransportInterface} port
2024
- * through {@link import('./helpers.js').bridgeMessageTransport} + {@link
1888
+ * through {@link createDuplexServerTransport} + {@link
2025
1889
  * import('@orkestrel/mcp').bindServer}: each inbound REQUEST runs through `mcp.dispatch`, and
2026
1890
  * a defined response is written back as a newline-terminated line — a NOTIFICATION
2027
1891
  * writes nothing, and a non-request message is ignored. A `dispatch` / `send` fault
@@ -2031,7 +1895,8 @@ function createStdioClientTransport(options) {
2031
1895
  * @param mcp - The transport-agnostic {@link MCPDispatcherInterface} to expose over stdio
2032
1896
  * @param options - Optional injectable `input` / `output` streams; see
2033
1897
  * {@link StdioServerOptions}
2034
- * @returns A `{ start(): void; stop(): void }` handle to arm / tear down the pump
1898
+ * @returns A {@link StdioServerInterface} handle to arm / tear down the pump; `stop()` ends
1899
+ * that handle's lifetime permanently
2035
1900
  *
2036
1901
  * @example
2037
1902
  * ```ts
@@ -2046,7 +1911,7 @@ function createStdioClientTransport(options) {
2046
1911
  */
2047
1912
  function createStdioServer(mcp, options) {
2048
1913
  const transport = new StdioServerTransport(options?.input ?? process.stdin, options?.output ?? process.stdout);
2049
- const unbind = bindServer(mcp, bridgeMessageTransport(transport));
1914
+ const unbind = bindServer(mcp, createDuplexServerTransport(transport));
2050
1915
  return {
2051
1916
  start() {
2052
1917
  transport.start();
@@ -2078,8 +1943,9 @@ function createStdioServer(mcp, options) {
2078
1943
  * can re-read it from a freshly-built forwarded `Request`). Resolves a session through {@link
2079
1944
  * readSessionHeader}: a VALID id touches the entry and sets `context.state.session`; an
2080
1945
  * ABSENT / unknown id whose (guarded) body parses to an `initialize` request ({@link
2081
- * isInitializeRequest}) MINTS a fresh {@link MCPSession} (`crypto.randomUUID()`, `capacity`)
2082
- * and sets `context.state.session`; neither → {@link rejectUnknownSession} (`404`). The
1946
+ * isInitializeRequest}) MINTS a fresh {@link MCPSession} (`crypto.randomUUID()`, the `session`
1947
+ * options group) and sets `context.state.session`; neither → {@link rejectUnknownSession}
1948
+ * (`404`). The
2083
1949
  * minted entry pins the negotiated legacy revision, which is supplied to a later headerless
2084
1950
  * live-session request. It then
2085
1951
  * FORWARDS a fresh `Request` carrying the buffered `text` (`next(forwarded)`) — never the
@@ -2090,7 +1956,7 @@ function createStdioServer(mcp, options) {
2090
1956
  * a `DELETE` arriving while the request was suspended is not undone.
2091
1957
  * - **`GET {path}`.** Resolves the session the same way (no mint — only `initialize` mints);
2092
1958
  * an invalid / unknown id is the same `404`. A valid session opens the resumable
2093
- * server→client stream through `@orkestrel/server`'s {@link import('@orkestrel/server').openStream}:
1959
+ * server→client stream through `@orkestrel/server`'s {@link import('@orkestrel/server').createStream}:
2094
1960
  * replays every event after the client's `Last-Event-ID` ({@link readLastEventId}) BEFORE
2095
1961
  * attaching the stream for live pushes, then attaches; cancellation of the streamed response
2096
1962
  * body composes with `request.signal` and detaches it. Long-lived — never `end()`ed here.
@@ -2105,10 +1971,11 @@ function createStdioServer(mcp, options) {
2105
1971
  * @typeParam TState - The consumer's `TState`, which MUST extend {@link MCPSessionState} so
2106
1972
  * the resolved session can be threaded through `context.state.session`
2107
1973
  * @param options - Optional `path` (default {@link DEFAULT_MCP_PATH}), `ttl` (idle-session
2108
- * sweep window, ms — omit for sessions that live until an explicit `DELETE`), `capacity`
2109
- * (the folded per-session replay-log bound), and `clock` (the deterministic epoch-ms clock;
2110
- * defaults to `Date.now`), plus the shared `origin` validation options; see
2111
- * {@link MCPSessionOptions}
1974
+ * sweep window, ms — omit for sessions that live until an explicit `DELETE`), `session`
1975
+ * (the knobs each minted {@link MCPSession} takes — `capacity`, the log's own `ttl`, and its
1976
+ * `clock`), and `clock` (the deterministic epoch-ms clock this middleware keeps its own
1977
+ * bookkeeping on and hands down to a session that names none; defaults to `Date.now`), plus
1978
+ * the shared `origin` validation options; see {@link MCPSessionMiddlewareOptions}
2112
1979
  * @returns A {@link MiddlewareHandler} that mints / validates sessions + serves the resumable
2113
1980
  * `GET` / `DELETE`
2114
1981
  *
@@ -2126,7 +1993,7 @@ function createStdioServer(mcp, options) {
2126
1993
  */
2127
1994
  function createMCPSession(options) {
2128
1995
  const path = options?.path ?? "/mcp";
2129
- const capacity = options?.capacity;
1996
+ const sessionOptions = options?.session ?? {};
2130
1997
  const ttl = options?.ttl;
2131
1998
  const clock = options?.clock ?? Date.now;
2132
1999
  const origin = options?.origin;
@@ -2139,10 +2006,10 @@ function createMCPSession(options) {
2139
2006
  if (context.method === "POST") {
2140
2007
  try {
2141
2008
  text = await request.text();
2142
- parsed = parseJSONRPCMessage(JSON.parse(text));
2143
2009
  } catch {
2144
- parsed = void 0;
2010
+ text = void 0;
2145
2011
  }
2012
+ parsed = text === void 0 ? void 0 : parseJSONRPCMessage(parseJSON(text));
2146
2013
  if (text !== void 0 && parsed !== void 0 && isModernRequest(parsed)) return next(new Request(context.url, {
2147
2014
  method: "POST",
2148
2015
  headers: request.headers,
@@ -2175,7 +2042,7 @@ function createMCPSession(options) {
2175
2042
  if (context.method === "GET") {
2176
2043
  if (entry === void 0) return rejectUnknownSession();
2177
2044
  const session = entry.session;
2178
- const stream = openStream();
2045
+ const stream = createStream();
2179
2046
  const disconnect = new HTTPDisconnect(request.signal, options?.keepalive);
2180
2047
  stream.response.headers.set(SSE_BUFFERING_HEADER, "no");
2181
2048
  stream.comment("open");
@@ -2194,7 +2061,10 @@ function createMCPSession(options) {
2194
2061
  if (entry === void 0) {
2195
2062
  if (parsed !== void 0 && isInitializeRequest(parsed)) {
2196
2063
  created = {
2197
- session: new MCPSession(crypto.randomUUID(), capacity !== void 0 ? { capacity } : {}),
2064
+ session: new MCPSession(crypto.randomUUID(), {
2065
+ ...sessionOptions,
2066
+ clock: sessionOptions.clock ?? clock
2067
+ }),
2198
2068
  touched: clock(),
2199
2069
  version: inferLegacyVersion(parsed)
2200
2070
  };
@@ -2204,7 +2074,7 @@ function createMCPSession(options) {
2204
2074
  if (!Reflect.set(context.state, "session", entry.session)) throw new Error("MCP session state is not writable");
2205
2075
  const headers = new Headers(request.headers);
2206
2076
  if (parsed === void 0 || !isInitializeRequest(parsed)) {
2207
- const issue = inferHeaderIssue(request, entry.version);
2077
+ const issue = inferSessionHeaderIssue(request, entry.version);
2208
2078
  if (issue?.reason === "missing") headers.set(MCP_PROTOCOL_VERSION_HEADER, entry.version);
2209
2079
  else if (issue !== void 0) {
2210
2080
  const requestId = parsed !== void 0 && "method" in parsed ? parsed.id : void 0;
@@ -2232,6 +2102,6 @@ function createMCPSession(options) {
2232
2102
  };
2233
2103
  }
2234
2104
  //#endregion
2235
- export { DEFAULT_MCP_DELIVERY, DEFAULT_MCP_KEEPALIVE_INTERVAL, DEFAULT_MCP_PATH, DEFAULT_MCP_SESSION_CAPACITY, DEFAULT_MCP_SESSION_TTL, HTTPClientTransport, HTTPDisconnect, MCPSession, MCP_METHOD_HEADER, MCP_NAME_HEADER, MCP_PROTOCOL_VERSION_HEADER, MCP_SESSION_HEADER, MCP_WEBSOCKET_SUBPROTOCOL, SSE_BUFFERING_DISABLED, SSE_BUFFERING_HEADER, SSE_KEEPALIVE_COMMENT, StdioClientTransport, StdioServerTransport, WebSocketClientTransport, WebSocketServerTransport, acceptsEventStream, allowsOrigin, bridgeMessageTransport, buildResponseError, createHTTPClientTransport, createMCPContinuation, createMCPPostHandler, createMCPRoutes, createMCPSession, createReadableStream, createStdioClientTransport, createStdioServer, createWebSocketClientTransport, createWebSocketServer, decodeEvent, dispatchLines, extractLines, inferHeaderIssue, inferLegacyVersion, inferStatus, readEventStream, readLastEventId, readSessionHeader, rejectUnknownSession, sendEventStream, upgradeRequestPath, writeLine };
2105
+ export { DEFAULT_MCP_DELIVERY, DEFAULT_MCP_KEEPALIVE_INTERVAL, DEFAULT_MCP_PATH, DEFAULT_MCP_SESSION_CAPACITY, DEFAULT_MCP_SESSION_TTL, HTTPDisconnect, MCPSession, SSE_BUFFERING_DISABLED, SSE_BUFFERING_HEADER, SSE_KEEPALIVE_COMMENT, StdioClientTransport, StdioServerTransport, WebSocketClientTransport, WebSocketServerTransport, acceptsEventStream, allowsOrigin, createDuplexServerTransport, createHTTPClientTransport, createMCPContinuation, createMCPPostHandler, createMCPRoutes, createMCPSession, createStdioClientTransport, createStdioServer, createWebSocketClientTransport, createWebSocketServer, dispatchLines, extractLines, inferHeaderIssue, inferHeaderTarget, inferLegacyVersion, inferParameterRefusal, inferSessionHeaderIssue, inferStatus, readLastEventId, readSessionHeader, rejectUnknownSession, sendEventStream, upgradeRequestPath, writeLine };
2236
2106
 
2237
2107
  //# sourceMappingURL=index.js.map