@orkestrel/mcp 0.0.11 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  import { createSSEParser } from "@orkestrel/sse";
2
- import { JSONRPC_INVALID_PARAMS, JSONRPC_INVALID_REQUEST, JSONRPC_METHOD_NOT_FOUND, JSONRPC_PARSE_ERROR, MCP_HEADER_MISMATCH, MCP_META_VERSION, MCP_MISSING_CAPABILITY, MCP_PROTOCOL_VERSION, MCP_UNSUPPORTED_VERSION, SUPPORTED_PROTOCOL_VERSIONS, bindServer, buildJSONRPCError, inferEra, inferVersion, isInitializeRequest, isJSONRPCRequest, isJSONRPCResponse, isMCPVersion, isModernRequest, parseJSONRPCMessage, parseRequestContext } from "../core/index.js";
3
- import { isRecord, isString } from "@orkestrel/contract";
4
- import { openStream } from "@orkestrel/server";
2
+ import { JSONRPC_INVALID_PARAMS, JSONRPC_INVALID_REQUEST, JSONRPC_METHOD_NOT_FOUND, JSONRPC_PARSE_ERROR, MCP_HEADER_MISMATCH, MCP_META_VERSION, MCP_MISSING_CAPABILITY, MCP_PROTOCOL_VERSION, MCP_UNSUPPORTED_VERSION, SUPPORTED_PROTOCOL_VERSIONS, bindServer, buildJSONRPCError, inferEra, inferRequestVersion, inferVersion, isInitializeRequest, isJSONRPCInvocation, isJSONRPCResponse, isMCPVersion, isModernRequest, parseJSONRPCMessage, parseRequestContext } from "../core/index.js";
3
+ import { isRecord, isString, sanitizeBudget } from "@orkestrel/contract";
4
+ import { openStream, signToken, verifyToken } from "@orkestrel/server";
5
5
  import { Emitter } from "@orkestrel/emitter";
6
6
  import { randomBytes } from "node:crypto";
7
7
  import { request } from "node:http";
@@ -98,6 +98,55 @@ function createReadableStream(pull, cancel) {
98
98
  });
99
99
  }
100
100
  /**
101
+ * Pump a controlled held-open exchange onto an open SSE stream — one `data:` event per
102
+ * notification in order, then the terminating response — and END the exchange however the
103
+ * pump leaves.
104
+ *
105
+ * @remarks
106
+ * The Streamable-HTTP twin of {@link import('@src/core').sendStream}, and it owns exactly what
107
+ * that owns. The `finally` releases the exchange on EVERY exit — the normal terminal, a
108
+ * producer that threw, a `write` that threw, and an abort alike — because nothing else will:
109
+ * a request whose client vanished cancels nothing by itself, so an exchange this pump walks
110
+ * away from keeps its producer, its request lifetime, and its live subscription slot forever.
111
+ * The exchange is released BEFORE the body ends, so the slot is already back when the response
112
+ * completes.
113
+ *
114
+ * Total (§14) — never throws and never rejects. A held-open SSE response has already sent its
115
+ * headers and part of its body, so there is no failure the transport could still convert into
116
+ * a different answer; the honest end of a broken stream is a closed one, and the fault itself
117
+ * is already legible on `server.emitter`'s `error` event, which is where a contained fault
118
+ * belongs.
119
+ *
120
+ * @param stream - The controlled held-open answer to write out and then end
121
+ * @param sse - The open SSE stream to write each serialized message onto
122
+ * @returns Resolves once the exchange has ended and the SSE body has been closed
123
+ *
124
+ * @example
125
+ * ```ts
126
+ * const answer = await mcp.dispatch(invocation, { signal: disconnect.signal })
127
+ * if (answer !== undefined && Symbol.asyncIterator in answer) {
128
+ * const sse = openStream()
129
+ * queueMicrotask(() => void sendEventStream(answer, sse))
130
+ * }
131
+ * ```
132
+ */
133
+ async function sendEventStream(stream, sse) {
134
+ try {
135
+ try {
136
+ let next = await stream.next();
137
+ while (next.done !== true) {
138
+ sse.write({ data: JSON.stringify(next.value) });
139
+ next = await stream.next();
140
+ }
141
+ sse.write({ data: JSON.stringify(next.value) });
142
+ } finally {
143
+ await stream[Symbol.asyncDispose]();
144
+ }
145
+ } catch {} finally {
146
+ sse.end();
147
+ }
148
+ }
149
+ /**
101
150
  * Whether the request's `Accept` header opts into a Server-Sent-Events response.
102
151
  *
103
152
  * @remarks
@@ -144,27 +193,6 @@ function allowsOrigin(request, options) {
144
193
  return options?.origins?.includes(parsed.origin) ?? false;
145
194
  }
146
195
  /**
147
- * Whether a modern HTTP request's required standard headers match its JSON-RPC body.
148
- *
149
- * @remarks
150
- * Requires `MCP-Protocol-Version` to equal the reserved `_meta` version and `Mcp-Method`
151
- * to equal `method`. `Mcp-Name` is required only for `tools/call`, where it must equal
152
- * `params.name`; discovery and listing requests need no name because none is derivable.
153
- * Legacy requests return `false` because this predicate models the modern contract only.
154
- *
155
- * @param request - The HTTP request carrying the headers
156
- * @param message - The parsed JSON-RPC request body
157
- * @returns `true` only when every method-applicable modern header matches
158
- */
159
- function matchesModernHeaders(request, message) {
160
- if (!isModernRequest(message)) return false;
161
- const version = (isRecord(message.params?.["_meta"]) ? message.params["_meta"] : void 0)?.[MCP_META_VERSION];
162
- if (!isString(version) || request.headers.get("mcp-protocol-version") !== version || request.headers.get("mcp-method") !== message.method) return false;
163
- if (message.method !== "tools/call") return true;
164
- const name = message.params?.["name"];
165
- return isString(name) && request.headers.get("mcp-name") === name;
166
- }
167
- /**
168
196
  * Read the request's `mcp-session-id` header — the session id a stateful transport
169
197
  * validates, or `undefined` when absent.
170
198
  *
@@ -205,9 +233,9 @@ function readLastEventId(request) {
205
233
  * JSON-RPC error body.
206
234
  *
207
235
  * @remarks
208
- * Returns `Response.json(buildJSONRPCError(null, JSONRPC_INVALID_REQUEST, 'Session not found'),
209
- * { status: 404 })`, mirroring `createMCPRoutes`'s `400` transport-failure shape (a
210
- * JSON-RPC error BODY with a `null` id) but at the session-not-found status. Shared by
236
+ * Returns `Response.json(buildJSONRPCError(undefined, JSONRPC_INVALID_REQUEST, 'Session not
237
+ * found'), { status: 404 })`, mirroring `createMCPRoutes`'s `400` transport-failure shape (a
238
+ * JSON-RPC error BODY with NO id) but at the session-not-found status. Shared by
211
239
  * every {@link import('./middlewares.js').createMCPSession} validation site — the
212
240
  * non-`initialize` `POST` path, the resumable `GET {path}` open, and the `DELETE {path}`
213
241
  * session-end (each a missing / unknown / TTL-evicted id) — so the single `404` envelope
@@ -216,7 +244,7 @@ function readLastEventId(request) {
216
244
  * @returns The `404` JSON-RPC error `Response`
217
245
  */
218
246
  function rejectUnknownSession() {
219
- return Response.json(buildJSONRPCError(null, JSONRPC_INVALID_REQUEST, "Session not found"), { status: 404 });
247
+ return Response.json(buildJSONRPCError(void 0, JSONRPC_INVALID_REQUEST, "Session not found"), { status: 404 });
220
248
  }
221
249
  /**
222
250
  * Decode a `fetch` Response's Server-Sent-Events body into the JSON-RPC messages it
@@ -323,7 +351,7 @@ function extractLines(buffer, chunk) {
323
351
  }
324
352
  /**
325
353
  * Decode and deliver each complete newline-framed line onto a {@link
326
- * ClientTransportEventMap} emitter — the shared per-chunk dispatch step both stdio
354
+ * MCPClientTransportEventMap} emitter — the shared per-chunk dispatch step both stdio
327
355
  * transports (client and server) run their {@link extractLines} output through.
328
356
  *
329
357
  * @remarks
@@ -348,7 +376,7 @@ function dispatchLines(emitter, lines) {
348
376
  }
349
377
  }
350
378
  /**
351
- * Bridge a message-channel {@link ClientTransportInterface} (the shape the stdio and
379
+ * Bridge a message-channel {@link MCPClientTransportInterface} (the shape the stdio and
352
380
  * WebSocket SERVER transports already implement) into the environment-agnostic
353
381
  * {@link import('@src/core').MCPTransportInterface} port — the adapter
354
382
  * {@link import('./factories.js').createStdioServer} and {@link
@@ -360,11 +388,22 @@ function dispatchLines(emitter, lines) {
360
388
  * `send` decodes the already-serialized reply string back to a {@link JSONRPCMessage}
361
389
  * and writes it via `transport.send` (the SAME `JSON.stringify` the underlying
362
390
  * transport already performs, so the wire bytes are unchanged). `listen` filters
363
- * `transport`'s `message` event to REQUESTS ONLY — a stray response is ignored,
364
- * exactly as the prior hand-rolled pumps did — and re-serializes each one back to a
365
- * string for `bindServer`. `closed` bridges `transport`'s `close` event. `close`
391
+ * `transport`'s `message` event to INVOCATIONS ONLY — requests and notifications, never a
392
+ * stray response, exactly as the prior hand-rolled pumps did — and re-serializes each one
393
+ * back to a string for `bindServer`. `closed` bridges `transport`'s `close` event. `close`
366
394
  * closes the underlying `transport`.
367
395
  *
396
+ * @remarks A message crossing this bridge is decoded and re-encoded TWICE, and that is
397
+ * ACCEPTED rather than accidental. Inbound: the carrier already parsed the frame into a
398
+ * {@link JSONRPCMessage}, and `listen` re-serializes it so `bindServer` can decode it again
399
+ * under the server's own `limit`. Outbound: `bindServer` serialized the reply, `send` parses
400
+ * it back, and the carrier stringifies it once more. The cost is two extra `JSON.parse` /
401
+ * `JSON.stringify` round trips per message, paid to keep ONE pump in the core binder instead
402
+ * of a hand-rolled one per carrier. It is BOUNDED rather than unbounded because the binder
403
+ * decodes within `server.limit.message`, so an oversized frame is refused before the second
404
+ * decode rather than after it. Removing the cost means giving `MCPTransportInterface` a
405
+ * message-shaped face beside its string one, which every transport would then carry.
406
+ *
368
407
  * @remarks Per {@link import('@src/core').MCPTransportInterface}, `listen`/`closed`
369
408
  * each hold THE SINGLE current handler (a second call REPLACES the first, never adds).
370
409
  * Since the underlying `transport.emitter` is ADD-based (`on` subscribes, never
@@ -392,7 +431,7 @@ function bridgeMessageTransport(transport) {
392
431
  let onMessage;
393
432
  let onClosed;
394
433
  transport.emitter.on("message", (message) => {
395
- if (!isJSONRPCRequest(message)) return;
434
+ if (!isJSONRPCInvocation(message)) return;
396
435
  onMessage?.(JSON.stringify(message));
397
436
  });
398
437
  transport.emitter.on("close", () => {
@@ -418,13 +457,94 @@ function bridgeMessageTransport(transport) {
418
457
  //#endregion
419
458
  //#region src/server/inferers.ts
420
459
  /**
460
+ * Infer the first required MCP HTTP header that is missing or mismatched.
461
+ *
462
+ * @remarks
463
+ * A modern request derives its protocol, method, and tools/call-only name expectations from
464
+ * the JSON-RPC body. A legacy request body requires a protocol header after initialization,
465
+ * while a supplied legacy session version additionally diagnoses a header that disagrees with
466
+ * the active session. Messages name the expected value but never echo the client-supplied one.
467
+ *
468
+ * @param request - The HTTP request carrying the headers
469
+ * @param reference - The parsed invocation body, or the active legacy session version
470
+ * @returns The first header issue, or `undefined` when the applicable headers agree
471
+ *
472
+ * @example
473
+ * ```ts
474
+ * const issue = inferHeaderIssue(request, rpcRequest)
475
+ * issue?.header // 'Mcp-Method' when that field is absent or mismatched
476
+ * ```
477
+ */
478
+ function inferHeaderIssue(request, reference) {
479
+ const protocol = request.headers.get(MCP_PROTOCOL_VERSION_HEADER);
480
+ if (isString(reference)) {
481
+ if (protocol === null) return {
482
+ header: "MCP-Protocol-Version",
483
+ reason: "missing",
484
+ message: `Required MCP-Protocol-Version header is missing; the active session uses '${reference}'.`
485
+ };
486
+ if (protocol !== reference) return {
487
+ header: "MCP-Protocol-Version",
488
+ reason: "mismatched",
489
+ message: `MCP-Protocol-Version header does not match the active session version '${reference}'.`
490
+ };
491
+ return;
492
+ }
493
+ if (!isModernRequest(reference)) {
494
+ if (isInitializeRequest(reference) || protocol !== null) return void 0;
495
+ return {
496
+ header: "MCP-Protocol-Version",
497
+ reason: "missing",
498
+ message: `Required MCP-Protocol-Version header is missing; this server offers '${MCP_PROTOCOL_VERSION}'.`
499
+ };
500
+ }
501
+ const message = reference;
502
+ const version = (isRecord(message.params?.["_meta"]) ? message.params["_meta"] : void 0)?.[MCP_META_VERSION];
503
+ if (!isString(version)) return void 0;
504
+ if (protocol === null) return {
505
+ header: "MCP-Protocol-Version",
506
+ reason: "missing",
507
+ message: `Required MCP-Protocol-Version header is missing; the request body version is '${version}'.`
508
+ };
509
+ if (protocol !== version) return {
510
+ header: "MCP-Protocol-Version",
511
+ reason: "mismatched",
512
+ message: `MCP-Protocol-Version header does not match the request body version '${version}'.`
513
+ };
514
+ const method = request.headers.get(MCP_METHOD_HEADER);
515
+ if (method === null) return {
516
+ header: "Mcp-Method",
517
+ reason: "missing",
518
+ message: `Required Mcp-Method header is missing; the request body method is '${message.method}'.`
519
+ };
520
+ if (method !== message.method) return {
521
+ header: "Mcp-Method",
522
+ reason: "mismatched",
523
+ message: `Mcp-Method header does not match the request body method '${message.method}'.`
524
+ };
525
+ if (message.method !== "tools/call") return void 0;
526
+ const name = message.params?.["name"];
527
+ if (!isString(name)) return void 0;
528
+ const header = request.headers.get(MCP_NAME_HEADER);
529
+ if (header === null) return {
530
+ header: "Mcp-Name",
531
+ reason: "missing",
532
+ message: `Required Mcp-Name header is missing; the request body tool name is '${name}'.`
533
+ };
534
+ if (header !== name) return {
535
+ header: "Mcp-Name",
536
+ reason: "mismatched",
537
+ message: `Mcp-Name header does not match the request body tool name '${name}'.`
538
+ };
539
+ }
540
+ /**
421
541
  * Infer the legacy revision an `initialize` request negotiates.
422
542
  *
423
543
  * @remarks
424
544
  * A supported legacy request is pinned exactly. A modern, malformed, absent, or unsupported
425
545
  * request selects the newest supported legacy revision, matching the core initialize result.
426
546
  *
427
- * @param request - The legacy initialize request
547
+ * @param request - The legacy initialize invocation
428
548
  * @returns The negotiated legacy protocol revision
429
549
  */
430
550
  function inferLegacyVersion(request) {
@@ -456,35 +576,78 @@ function inferStatus(response, era) {
456
576
  //#endregion
457
577
  //#region src/server/transports/HTTPDisconnect.ts
458
578
  /**
459
- * The HTTP response-disconnect bridge for an MCP SSE stream.
579
+ * Compose one incoming HTTP request lifetime with one MCP-owned SSE response lifetime.
460
580
  *
461
581
  * @remarks
462
- * Composes the incoming request signal with a controller owned by the MCP HTTP face. The
463
- * returned {@link signal} therefore observes both an incomplete request body and cancellation
464
- * of the streamed response body. {@link bridge} preserves the supplied SSE response while
465
- * forwarding its body through a cancellation-aware stream. While that response is held open,
466
- * the bridge writes SSE comment frames at the configured keepalive interval so an idle dead
467
- * client becomes observable to the HTTP writer. It never decides how an abort changes handler
468
- * or session state.
582
+ * The composed {@link signal} observes request abort and EVERY way this response can end
583
+ * without one: consumer cancellation of the bridged body, a forwarding failure mid-pump, and a
584
+ * keepalive tick that finds the SSE stream already closed. That last pair is the whole point of
585
+ * the composition a client that vanishes mid-stream aborts nothing by itself, so unless this
586
+ * object raises the signal on its own failure paths, the handler, the controlled stream, and
587
+ * the producer behind them all keep running for a response that can no longer be written.
588
+ * Graceful upstream completion is the one terminal that does NOT abort: the body simply closes,
589
+ * because the exchange finished rather than ended.
590
+ *
591
+ * {@link bridge} preserves the source response status and headers, forwards its body bytes, and
592
+ * owns keepalive comments plus listener/timer cleanup until upstream completion, request abort,
593
+ * or consumer cancellation. This is a single-response lifecycle object, not a reusable bridge.
594
+ * It supplies no handler or session policy.
595
+ *
596
+ * The keepalive interval is a BUDGET, sanitized like every other numeric knob in this package:
597
+ * anything that is not a positive integer — `0`, a negative, a fractional value, `NaN`,
598
+ * `Infinity` — falls back to {@link DEFAULT_MCP_KEEPALIVE_INTERVAL}, and a larger value clamps
599
+ * to Node's `2_147_483_647` ms timer maximum. None may reach the platform's timer floor, where
600
+ * an idle-liveness tick becomes the polling this package forbids everywhere else.
601
+ *
602
+ * @example
603
+ * ```ts
604
+ * import { HTTPDisconnect } from '@orkestrel/mcp/server'
605
+ * import { openStream } from '@orkestrel/server'
606
+ *
607
+ * const disconnect = new HTTPDisconnect(request.signal, { interval: 15_000 })
608
+ * const stream = openStream()
609
+ * const response = disconnect.bridge(stream)
610
+ * ```
469
611
  */
470
612
  var HTTPDisconnect = class {
471
- #abort = new AbortController();
613
+ #response = new AbortController();
472
614
  #lifecycle = new AbortController();
473
615
  #interval;
474
616
  #signal;
475
617
  #timer;
618
+ #pulling = false;
619
+ /**
620
+ * Create the lifecycle composition for one request and its future SSE response.
621
+ *
622
+ * @param signal - The incoming request signal
623
+ * @param options - Optional keepalive `interval` in milliseconds; an invalid value falls back
624
+ * to {@link DEFAULT_MCP_KEEPALIVE_INTERVAL}, and one above Node's timer maximum clamps to it
625
+ */
476
626
  constructor(signal, options) {
477
- this.#interval = options?.interval ?? 15e3;
478
- this.#signal = AbortSignal.any([signal, this.#abort.signal]);
627
+ const interval = sanitizeBudget(options?.interval, DEFAULT_MCP_KEEPALIVE_INTERVAL);
628
+ this.#interval = interval > 0 ? Math.min(interval, 2147483647) : DEFAULT_MCP_KEEPALIVE_INTERVAL;
629
+ this.#signal = AbortSignal.any([signal, this.#response.signal]);
479
630
  }
631
+ /**
632
+ * The signal aborted by the incoming request, or by any end of this response that is not
633
+ * its graceful completion.
634
+ *
635
+ * @returns The composed lifecycle signal
636
+ */
480
637
  get signal() {
481
638
  return this.#signal;
482
639
  }
483
640
  /**
484
- * Bridge cancellation of an SSE response body into this disconnect signal.
641
+ * Bridge one open SSE response through cancellation-aware byte forwarding and keepalives.
642
+ *
643
+ * Consumer cancellation, a read failure while forwarding, and a keepalive tick that finds the
644
+ * SSE stream already closed each abort {@link signal}; consumer cancellation also cancels the
645
+ * upstream reader. Upstream completion closes the returned body without inventing an abort.
646
+ * Every terminal path clears the keepalive timer and detaches the bridge-owned abort listener.
485
647
  *
486
648
  * @param stream - The open SSE stream whose response will be consumed by the HTTP writer
487
- * @returns A response with the same status and headers whose body forwards the SSE bytes
649
+ * @returns A one-use response preserving status, status text, headers, and SSE body bytes
650
+ * @throws When the supplied SSE response has no body
488
651
  */
489
652
  bridge(stream) {
490
653
  const response = stream.response;
@@ -492,28 +655,32 @@ var HTTPDisconnect = class {
492
655
  if (body === null) throw new Error("MCP SSE response has no body");
493
656
  const reader = body.getReader();
494
657
  this.#timer = setInterval(() => {
495
- if (stream.closed) this.#stop();
496
- else stream.comment(SSE_KEEPALIVE_COMMENT);
658
+ if (stream.closed) {
659
+ if (!this.#pulling) this.#abort();
660
+ } else stream.comment(SSE_KEEPALIVE_COMMENT);
497
661
  }, this.#interval);
498
- this.#signal.addEventListener("abort", () => this.#stop(), {
662
+ this.#signal.addEventListener("abort", () => this.#release(), {
499
663
  once: true,
500
664
  signal: this.#lifecycle.signal
501
665
  });
502
- if (this.#signal.aborted || stream.closed) this.#stop();
666
+ if (this.#signal.aborted) this.#release();
667
+ else if (stream.closed) this.#abort();
503
668
  return new Response(createReadableStream(async (controller) => {
669
+ this.#pulling = true;
504
670
  try {
505
671
  const chunk = await reader.read();
506
672
  if (chunk.done) {
507
- this.#stop();
673
+ this.#release();
508
674
  controller.close();
509
675
  } else controller.enqueue(chunk.value);
510
676
  } catch (error) {
511
- this.#stop();
677
+ this.#abort();
512
678
  controller.error(error);
679
+ } finally {
680
+ this.#pulling = false;
513
681
  }
514
682
  }, async (reason) => {
515
- this.#abort.abort();
516
- this.#stop();
683
+ this.#abort();
517
684
  await reader.cancel(reason);
518
685
  }), {
519
686
  status: response.status,
@@ -521,13 +688,17 @@ var HTTPDisconnect = class {
521
688
  headers: response.headers
522
689
  });
523
690
  }
524
- #stop() {
691
+ #release() {
525
692
  if (this.#timer !== void 0) {
526
693
  clearInterval(this.#timer);
527
694
  this.#timer = void 0;
528
695
  }
529
696
  this.#lifecycle.abort();
530
697
  }
698
+ #abort() {
699
+ this.#release();
700
+ this.#response.abort();
701
+ }
531
702
  };
532
703
  //#endregion
533
704
  //#region src/server/handlers.ts
@@ -547,18 +718,18 @@ var HTTPDisconnect = class {
547
718
  * defined value is added to `MCPDispatchOptions`, while `undefined` is omitted.
548
719
  *
549
720
  * @typeParam TState - The consumer's opaque per-request route state type
550
- * @param mcp - The transport-agnostic MCP server to dispatch through
721
+ * @param mcp - The transport-agnostic MCP dispatcher to dispatch through
551
722
  * @param options - Optional streaming, origin-validation, SSE keepalive, and caller-extraction options
552
723
  * @returns A request handler for the stateless MCP POST route
553
724
  *
554
725
  * @example
555
726
  * ```ts
556
- * import { createMCPServer } from '@orkestrel/mcp'
727
+ * import { createMCPLegacy, createMCPServer } from '@orkestrel/mcp'
557
728
  * import { createMCPPostHandler } from '@orkestrel/mcp/server'
558
729
  * import { createToolManager } from '@orkestrel/tool'
559
730
  *
560
731
  * const mcp = createMCPServer({ identity: { name: 'docs', version: '1.0.0' }, tools: createToolManager() })
561
- * const handler = createMCPPostHandler(mcp, { streaming: true })
732
+ * const handler = createMCPPostHandler(createMCPLegacy(mcp), { streaming: true })
562
733
  * await handler(new Request('http://localhost/mcp', {
563
734
  * method: 'POST',
564
735
  * body: '{"jsonrpc":"2.0","method":"ping","id":1}',
@@ -574,24 +745,25 @@ function createMCPPostHandler(mcp, options) {
574
745
  try {
575
746
  text = await request.text();
576
747
  } catch {
577
- return Response.json(buildJSONRPCError(null, JSONRPC_PARSE_ERROR, "Parse error"), { status: 400 });
748
+ return Response.json(buildJSONRPCError(void 0, JSONRPC_PARSE_ERROR, "Parse error"), { status: 400 });
578
749
  }
579
750
  let parsed;
580
751
  try {
581
752
  parsed = JSON.parse(text);
582
753
  } catch {
583
- return Response.json(buildJSONRPCError(null, JSONRPC_PARSE_ERROR, "Parse error"), { status: 400 });
754
+ return Response.json(buildJSONRPCError(void 0, JSONRPC_PARSE_ERROR, "Parse error"), { status: 400 });
584
755
  }
585
- const rpcRequest = parseJSONRPCMessage(parsed);
586
- if (rpcRequest === void 0 || !("method" in rpcRequest)) return Response.json(buildJSONRPCError(null, JSONRPC_INVALID_REQUEST, "Invalid Request"), { status: 400 });
587
- const era = isModernRequest(rpcRequest) ? "modern" : "legacy";
588
- const id = rpcRequest.id ?? null;
756
+ const invocation = parseJSONRPCMessage(parsed);
757
+ if (invocation === void 0 || !("method" in invocation)) return Response.json(buildJSONRPCError(void 0, JSONRPC_INVALID_REQUEST, "Invalid Request"), { status: 400 });
758
+ const era = isModernRequest(invocation) ? "modern" : "legacy";
759
+ const id = invocation.id;
589
760
  const protocol = request.headers.get(MCP_PROTOCOL_VERSION_HEADER);
590
761
  if (era === "modern") {
591
- if (parseRequestContext(rpcRequest) === void 0) return Response.json(buildJSONRPCError(id, JSONRPC_INVALID_PARAMS, "Invalid params: malformed modern request metadata"), { status: 400 });
592
- if (!matchesModernHeaders(request, rpcRequest)) return Response.json(buildJSONRPCError(id, MCP_HEADER_MISMATCH, "MCP request headers do not match the request body"), { status: 400 });
593
- } else {
594
- if (protocol === null && !isInitializeRequest(rpcRequest)) return Response.json(buildJSONRPCError(id, MCP_HEADER_MISMATCH, "MCP request headers do not match the request body"), { status: 400 });
762
+ if (parseRequestContext(invocation) === void 0) return Response.json(buildJSONRPCError(id, JSONRPC_INVALID_PARAMS, "Invalid params: malformed modern request metadata"), { status: 400 });
763
+ }
764
+ const issue = inferHeaderIssue(request, invocation);
765
+ if (issue !== void 0) return Response.json(buildJSONRPCError(id, MCP_HEADER_MISMATCH, issue.message), { status: 400 });
766
+ if (era === "legacy") {
595
767
  if (protocol !== null && !isMCPVersion(protocol)) return Response.json(buildJSONRPCError(id, MCP_UNSUPPORTED_VERSION, `Unsupported MCP protocol version '${protocol}'`, {
596
768
  supported: SUPPORTED_PROTOCOL_VERSIONS,
597
769
  requested: protocol
@@ -599,25 +771,14 @@ function createMCPPostHandler(mcp, options) {
599
771
  }
600
772
  const disconnect = new HTTPDisconnect(request.signal, options?.keepalive);
601
773
  const caller = options?.caller?.(request, context);
602
- const response = await mcp.dispatch(rpcRequest, {
774
+ const response = await mcp.dispatch(invocation, {
603
775
  signal: disconnect.signal,
604
776
  ...caller === void 0 ? {} : { caller }
605
777
  });
606
778
  if (response !== void 0 && Symbol.asyncIterator in response) {
607
779
  const stream = openStream();
608
780
  stream.response.headers.set(SSE_BUFFERING_HEADER, "no");
609
- queueMicrotask(async () => {
610
- try {
611
- let next = await response.next();
612
- while (!next.done) {
613
- stream.write({ data: JSON.stringify(next.value) });
614
- next = await response.next();
615
- }
616
- stream.write({ data: JSON.stringify(next.value) });
617
- } catch {} finally {
618
- stream.end();
619
- }
620
- });
781
+ queueMicrotask(() => void sendEventStream(response, stream));
621
782
  return disconnect.bridge(stream);
622
783
  }
623
784
  const status = inferStatus(response, era);
@@ -636,7 +797,7 @@ function createMCPPostHandler(mcp, options) {
636
797
  //#region src/server/transports/HTTPClientTransport.ts
637
798
  /**
638
799
  * The HTTP CLIENT transport for the Model Context Protocol — a
639
- * {@link ClientTransportInterface} that drives a REMOTE Streamable-HTTP MCP server over
800
+ * {@link MCPClientTransportInterface} that drives a REMOTE Streamable-HTTP MCP server over
640
801
  * `fetch`, the egress mirror of the server's `createMCPRoutes`.
641
802
  *
642
803
  * @remarks
@@ -669,7 +830,7 @@ function createMCPPostHandler(mcp, options) {
669
830
  * - **Total at the boundary (§14).** Every reply is narrowed (`parseJSONRPCMessage`,
670
831
  * the SSE decoder) — a non-message reply is dropped, never asserted; a `fetch` /
671
832
  * decode failure surfaces on the `error` event rather than escaping `send`.
672
- * - **Observable (§13).** Owns the `emitter` ({@link ClientTransportEventMap}); fires
833
+ * - **Observable (§13).** Owns the `emitter` ({@link MCPClientTransportEventMap}); fires
673
834
  * `message` per decoded reply, `error` on a fault, and `close` on `close()`.
674
835
  *
675
836
  * @example
@@ -700,6 +861,9 @@ var HTTPClientTransport = class {
700
861
  get session() {
701
862
  return this.#session;
702
863
  }
864
+ get duplex() {
865
+ return false;
866
+ }
703
867
  async start() {}
704
868
  async send(message) {
705
869
  let response;
@@ -729,11 +893,11 @@ var HTTPClientTransport = class {
729
893
  this.#emitter.emit("close");
730
894
  }
731
895
  #buildHeaders(message) {
732
- if (isJSONRPCRequest(message) && isModernRequest(message)) {
733
- const version = (isRecord(message.params?.["_meta"]) ? message.params["_meta"] : void 0)?.[MCP_META_VERSION];
896
+ if (isModernRequest(message)) {
897
+ const version = inferRequestVersion(message);
734
898
  const name = message.params?.["name"];
735
899
  return {
736
- ...isString(version) ? { [MCP_PROTOCOL_VERSION_HEADER]: version } : {},
900
+ ...version === void 0 ? {} : { [MCP_PROTOCOL_VERSION_HEADER]: version },
737
901
  [MCP_METHOD_HEADER]: message.method,
738
902
  ...message.method === "tools/call" && isString(name) ? { [MCP_NAME_HEADER]: name } : {}
739
903
  };
@@ -884,12 +1048,12 @@ var MCPSession = class {
884
1048
  /**
885
1049
  * The per-connection JSON-RPC-over-WebSocket SERVER bridge — wraps a
886
1050
  * {@link NodeWebSocketInterface} (the RFC 6455 wire wrapper) as a
887
- * {@link ClientTransportInterface}, the bidirectional JSON-RPC message channel
1051
+ * {@link MCPClientTransportInterface}, the bidirectional JSON-RPC message channel
888
1052
  * `createWebSocketServer` pumps `mcp.dispatch` over and the egress mirror's
889
1053
  * {@link import('./WebSocketClientTransport.js').WebSocketClientTransport} reuses.
890
1054
  *
891
1055
  * @remarks
892
- * - **Reuses `ClientTransportInterface` (§21).** It IS the same generic carrier the HTTP
1056
+ * - **Reuses `MCPClientTransportInterface` (§21).** It IS the same generic carrier the HTTP
893
1057
  * client transport implements — `emitter` (`message` / `close` / `error`), `start`,
894
1058
  * `send`, `close` — so the WebSocket server and client both speak ONE transport contract,
895
1059
  * no near-duplicate sibling interface. `session` is `undefined` (the stateless v1; a
@@ -907,7 +1071,7 @@ var MCPSession = class {
907
1071
  * - **`close()`** closes the underlying socket (the RFC 6455 close handshake) and fires the
908
1072
  * transport's `close` event (idempotent — a second `close`, or a socket-driven close, emits
909
1073
  * once).
910
- * - **Observable (§13).** Owns the `emitter` ({@link ClientTransportEventMap}); the emitter
1074
+ * - **Observable (§13).** Owns the `emitter` ({@link MCPClientTransportEventMap}); the emitter
911
1075
  * isolates a listener throw (a buggy observer never corrupts the bridge). `error` is a
912
1076
  * DOMAIN event (a transport-level fault), distinct from the emitter's listener-error channel.
913
1077
  */
@@ -924,6 +1088,9 @@ var WebSocketServerTransport = class {
924
1088
  return this.#emitter;
925
1089
  }
926
1090
  get session() {}
1091
+ get duplex() {
1092
+ return true;
1093
+ }
927
1094
  async start() {
928
1095
  if (this.#started || this.#closed) return;
929
1096
  this.#started = true;
@@ -965,7 +1132,7 @@ var WebSocketServerTransport = class {
965
1132
  //#region src/server/transports/WebSocketClientTransport.ts
966
1133
  /**
967
1134
  * The WebSocket CLIENT transport for the Model Context Protocol — a
968
- * {@link ClientTransportInterface} that drives a REMOTE MCP server over a WebSocket, the
1135
+ * {@link MCPClientTransportInterface} that drives a REMOTE MCP server over a WebSocket, the
969
1136
  * egress mirror of {@link import('./factories.js').createWebSocketServer} and the WebSocket
970
1137
  * sibling of {@link import('./HTTPClientTransport.js').HTTPClientTransport}.
971
1138
  *
@@ -978,6 +1145,12 @@ var WebSocketServerTransport = class {
978
1145
  * — a mismatch (or a non-`101` response, or a request error) REJECTS `start()` and the socket
979
1146
  * is destroyed. On success it wraps the raw upgraded socket in `createNodeWebSocket({ socket,
980
1147
  * head })` (CLIENT mode — no key → frames are MASKED per §5.3) and bridges its `message`.
1148
+ * - **The arriving socket is RE-ASKED for, never assumed.** `start()` suspends across that
1149
+ * connect and upgrade, so it re-checks the transport's state before installing anything: a
1150
+ * concurrent `start()` that already installed a socket, or a {@link close} that ended the
1151
+ * transport while the handshake was on the wire, both WIN — the socket that arrives late is
1152
+ * DESTROYED and never bound, so no orphan is left re-emitting frames at nobody. Both
1153
+ * `start()` calls still resolve; exactly one socket is ever bound.
981
1154
  * - **Inbound (`message`).** Each decoded text frame is `JSON.parse`d (guarded) and narrowed
982
1155
  * with `parseJSONRPCMessage` — a {@link JSONRPCMessage} re-emits on this transport's `message`
983
1156
  * event (the reply the {@link import('@src/core').MCPClientInterface} correlates by `id`); a
@@ -988,7 +1161,7 @@ var WebSocketServerTransport = class {
988
1161
  * - **URL scheme.** `options.url` accepts a `ws://` / `wss://` URL or an `http://` / `https://`
989
1162
  * one; a `ws(s)` scheme is converted to `http(s)` for the underlying upgrade request (`wss`
990
1163
  * → TLS via `node:https`). Either reaches the same endpoint.
991
- * - **Observable (§13).** Owns the `emitter` ({@link ClientTransportEventMap}); every emit
1164
+ * - **Observable (§13).** Owns the `emitter` ({@link MCPClientTransportEventMap}); every emit
992
1165
  * the emitter isolates a listener throw (a buggy observer never corrupts the transport);
993
1166
  * `error` is a DOMAIN event (a transport-level fault).
994
1167
  *
@@ -1014,6 +1187,9 @@ var WebSocketClientTransport = class {
1014
1187
  return this.#emitter;
1015
1188
  }
1016
1189
  get session() {}
1190
+ get duplex() {
1191
+ return true;
1192
+ }
1017
1193
  async start() {
1018
1194
  if (this.#socket !== void 0) return;
1019
1195
  this.#closed = false;
@@ -1042,6 +1218,11 @@ var WebSocketClientTransport = class {
1042
1218
  reject(/* @__PURE__ */ new Error("WebSocket handshake failed: Sec-WebSocket-Accept mismatch"));
1043
1219
  return;
1044
1220
  }
1221
+ if (this.#closed || this.#socket !== void 0) {
1222
+ socket.destroy();
1223
+ resolve();
1224
+ return;
1225
+ }
1045
1226
  const ws = createNodeWebSocket({
1046
1227
  socket,
1047
1228
  head
@@ -1073,7 +1254,7 @@ var WebSocketClientTransport = class {
1073
1254
  }
1074
1255
  #bind(ws) {
1075
1256
  ws.emitter.on("message", (text) => this.#receive(text));
1076
- ws.emitter.on("close", () => this.#onClose());
1257
+ ws.emitter.on("close", () => this.#onClose(ws));
1077
1258
  ws.emitter.on("error", (error) => this.#emitter.emit("error", error));
1078
1259
  }
1079
1260
  #receive(text) {
@@ -1091,8 +1272,8 @@ var WebSocketClientTransport = class {
1091
1272
  }
1092
1273
  this.#emitter.emit("message", message);
1093
1274
  }
1094
- #onClose() {
1095
- if (this.#closed) return;
1275
+ #onClose(socket) {
1276
+ if (this.#closed || this.#socket !== socket) return;
1096
1277
  this.#closed = true;
1097
1278
  this.#socket = void 0;
1098
1279
  this.#emitter.emit("close");
@@ -1109,7 +1290,7 @@ var WebSocketClientTransport = class {
1109
1290
  //#region src/server/transports/StdioClientTransport.ts
1110
1291
  /**
1111
1292
  * The stdio CLIENT transport for the Model Context Protocol — a
1112
- * {@link ClientTransportInterface} that drives a CHILD PROCESS MCP server over
1293
+ * {@link MCPClientTransportInterface} that drives a CHILD PROCESS MCP server over
1113
1294
  * newline-delimited JSON-RPC on `stdin`/`stdout`, the stdio sibling of {@link
1114
1295
  * import('./HTTPClientTransport.js').HTTPClientTransport} and {@link
1115
1296
  * import('./WebSocketClientTransport.js').WebSocketClientTransport}.
@@ -1128,7 +1309,7 @@ var WebSocketClientTransport = class {
1128
1309
  * - **Outbound (`send`).** `send(message)` writes one newline-terminated
1129
1310
  * `JSON.stringify`d line to the child's `stdin`.
1130
1311
  * - **`close()`** kills the child process and fires `close` (idempotent).
1131
- * - **Observable (§13).** Owns the `emitter` ({@link ClientTransportEventMap}); the
1312
+ * - **Observable (§13).** Owns the `emitter` ({@link MCPClientTransportEventMap}); the
1132
1313
  * emitter isolates a listener throw; `error` is a DOMAIN event (a transport-level
1133
1314
  * fault), distinct from the emitter's own listener-error channel.
1134
1315
  *
@@ -1157,6 +1338,9 @@ var StdioClientTransport = class {
1157
1338
  return this.#emitter;
1158
1339
  }
1159
1340
  get session() {}
1341
+ get duplex() {
1342
+ return true;
1343
+ }
1160
1344
  async start() {
1161
1345
  if (this.#child !== void 0) return;
1162
1346
  this.#closed = false;
@@ -1171,7 +1355,7 @@ var StdioClientTransport = class {
1171
1355
  });
1172
1356
  this.#child = child;
1173
1357
  child.stdout.on("data", (chunk) => this.#receive(chunk.toString()));
1174
- child.on("close", () => this.#onClose());
1358
+ child.on("close", () => this.#onClose(child));
1175
1359
  child.on("error", (error) => this.#emitter.emit("error", error));
1176
1360
  }
1177
1361
  async send(message) {
@@ -1192,8 +1376,8 @@ var StdioClientTransport = class {
1192
1376
  this.#buffer = remainder;
1193
1377
  dispatchLines(this.#emitter, lines);
1194
1378
  }
1195
- #onClose() {
1196
- if (this.#closed) return;
1379
+ #onClose(child) {
1380
+ if (this.#closed || this.#child !== child) return;
1197
1381
  this.#closed = true;
1198
1382
  this.#child = void 0;
1199
1383
  this.#emitter.emit("close");
@@ -1204,13 +1388,13 @@ var StdioClientTransport = class {
1204
1388
  /**
1205
1389
  * The stdio SERVER transport for the Model Context Protocol — wraps an injectable
1206
1390
  * readable/writable stream pair (`process.stdin`/`process.stdout` in production, a
1207
- * test double in tests) as a {@link ClientTransportInterface}, the newline-delimited
1391
+ * test double in tests) as a {@link MCPClientTransportInterface}, the newline-delimited
1208
1392
  * JSON-RPC channel {@link import('../factories.js').createStdioServer} pumps
1209
1393
  * `mcp.dispatch` over, the stdio mirror of {@link
1210
1394
  * import('./WebSocketServerTransport.js').WebSocketServerTransport}.
1211
1395
  *
1212
1396
  * @remarks
1213
- * - **Reuses `ClientTransportInterface` (§21).** The same generic carrier the HTTP
1397
+ * - **Reuses `MCPClientTransportInterface` (§21).** The same generic carrier the HTTP
1214
1398
  * and WebSocket server transports implement — `emitter` (`message` / `close` /
1215
1399
  * `error`), `start`, `send`, `close`. `session` is `undefined` (the stateless v1).
1216
1400
  * - **Inbound (`message`).** `start()` subscribes to `input`'s `data` event; each
@@ -1225,7 +1409,7 @@ var StdioClientTransport = class {
1225
1409
  * - **`close()`** fires this transport's `close` (idempotent) — the injected streams
1226
1410
  * are owned by the caller (typically `process.stdin`/`process.stdout`, which must
1227
1411
  * never be closed out from under the process) and are not torn down here.
1228
- * - **Observable (§13).** Owns the `emitter` ({@link ClientTransportEventMap}); the
1412
+ * - **Observable (§13).** Owns the `emitter` ({@link MCPClientTransportEventMap}); the
1229
1413
  * emitter isolates a listener throw; `error` is a DOMAIN event (a transport-level
1230
1414
  * fault), distinct from the emitter's own listener-error channel.
1231
1415
  */
@@ -1245,6 +1429,9 @@ var StdioServerTransport = class {
1245
1429
  return this.#emitter;
1246
1430
  }
1247
1431
  get session() {}
1432
+ get duplex() {
1433
+ return true;
1434
+ }
1248
1435
  async start() {
1249
1436
  if (this.#started || this.#closed) return;
1250
1437
  this.#started = true;
@@ -1274,8 +1461,24 @@ var StdioServerTransport = class {
1274
1461
  //#endregion
1275
1462
  //#region src/server/factories.ts
1276
1463
  /**
1464
+ * Adapt the installed server token primitives to the host-neutral MCP continuation port.
1465
+ *
1466
+ * @param secret - Current signing secret or `[current, ...older]` rotation list
1467
+ * @returns A continuation port that seals and opens opaque canonical state strings
1468
+ */
1469
+ function createMCPContinuation(secret) {
1470
+ return {
1471
+ seal(value) {
1472
+ return signToken(value, { secret });
1473
+ },
1474
+ open(value) {
1475
+ return verifyToken(value, secret);
1476
+ }
1477
+ };
1478
+ }
1479
+ /**
1277
1480
  * Create the MCP Streamable-HTTP transport routes — mounts a transport-agnostic
1278
- * {@link MCPServerInterface} (the `@src/core` dispatch core) on the fetch-standard router
1481
+ * {@link MCPDispatcherInterface} (the `@src/core` dispatch boundary) on the fetch-standard router
1279
1482
  * spine, pumping each `POST` body through `mcp.dispatch`. Returns the {@link RouteInput}s to
1280
1483
  * hand to `router.add(...)`.
1281
1484
  *
@@ -1286,14 +1489,14 @@ var StdioServerTransport = class {
1286
1489
  * DISPATCH-level outcomes:
1287
1490
  *
1288
1491
  * - A **transport** failure — a malformed JSON body, or a parsed value that is not a
1289
- * JSON-RPC REQUEST — is an HTTP `400` carrying a JSON-RPC error BODY (`-32700` Parse
1290
- * error / `-32600` Invalid Request, id `null`).
1492
+ * JSON-RPC INVOCATION — is an HTTP `400` carrying a JSON-RPC error BODY (`-32700` Parse
1493
+ * error / `-32600` Invalid Request), with the `id` it could not read OMITTED.
1291
1494
  * - Modern protocol/method/name headers are validated against the body; a mismatch is
1292
1495
  * HTTP `400` + `-32020`. Headerless initialize is accepted, a live legacy session supplies
1293
1496
  * its pinned revision, and every other headerless request is rejected.
1294
1497
  * - Legacy dispatch errors stay IN-BAND at HTTP `200`; modern errors map to `400` for
1295
1498
  * `-32020` / `-32021` / `-32022` / `-32602`, `404` for `-32601`, and `200` otherwise.
1296
- * - A **notification** (a request with no `id`, which `dispatch` resolves to
1499
+ * - A **notification** (an invocation with no `id`, which `dispatch` resolves to
1297
1500
  * `undefined`) is a `202 Accepted` with no body.
1298
1501
  *
1299
1502
  * When `streaming` is enabled (the default) and the client `Accept`s `text/event-stream`,
@@ -1312,7 +1515,7 @@ var StdioServerTransport = class {
1312
1515
  * allowlist or explicitly delegates validation to an upstream layer.
1313
1516
  *
1314
1517
  * @typeParam TState - The consumer's opaque per-request state type
1315
- * @param mcp - The transport-agnostic {@link MCPServerInterface} to expose over HTTP
1518
+ * @param mcp - The transport-agnostic {@link MCPDispatcherInterface} to expose over HTTP
1316
1519
  * @param options - Optional `path` (default {@link DEFAULT_MCP_PATH}) and `streaming`
1317
1520
  * (default `true`), plus shared origin, keepalive, and synchronous caller-extraction options; see
1318
1521
  * {@link HTTPTransportOptions}
@@ -1320,11 +1523,11 @@ var StdioServerTransport = class {
1320
1523
  *
1321
1524
  * @example
1322
1525
  * ```ts
1323
- * import { createMCPServer, createToolManager } from '@src/core'
1526
+ * import { createMCPLegacy, createMCPServer, createToolManager } from '@src/core'
1324
1527
  * import { createMCPRoutes } from '@src/server'
1325
1528
  *
1326
1529
  * const mcp = createMCPServer({ identity: { name: 'docs', version: '1.0.0' }, tools: createToolManager() })
1327
- * const routes = createMCPRoutes(mcp) // POST /mcp dispatches JSON-RPC (JSON or SSE per Accept)
1530
+ * const routes = createMCPRoutes(createMCPLegacy(mcp)) // both eras; pass `mcp` for modern only
1328
1531
  * ```
1329
1532
  */
1330
1533
  function createMCPRoutes(mcp, options) {
@@ -1337,7 +1540,7 @@ function createMCPRoutes(mcp, options) {
1337
1540
  }
1338
1541
  /**
1339
1542
  * Create the HTTP CLIENT transport for an {@link import('@src/core').MCPClientInterface}
1340
- * — a {@link ClientTransportInterface} that drives a REMOTE Streamable-HTTP MCP server
1543
+ * — a {@link MCPClientTransportInterface} that drives a REMOTE Streamable-HTTP MCP server
1341
1544
  * over `fetch`. The egress mirror of {@link createMCPRoutes}.
1342
1545
  *
1343
1546
  * @remarks
@@ -1356,7 +1559,7 @@ function createMCPRoutes(mcp, options) {
1356
1559
  * @param options - `url` (the remote endpoint; REQUIRED), optional `headers` merged onto
1357
1560
  * every request, optional `fetch` (default `globalThis.fetch`), and optional `timeout`
1358
1561
  * (ms, applied via `AbortSignal.timeout`); see {@link HTTPClientTransportOptions}
1359
- * @returns A working {@link ClientTransportInterface} over `fetch`
1562
+ * @returns A working {@link MCPClientTransportInterface} over `fetch`
1360
1563
  *
1361
1564
  * @example
1362
1565
  * ```ts
@@ -1375,7 +1578,7 @@ function createHTTPClientTransport(options) {
1375
1578
  }
1376
1579
  /**
1377
1580
  * Create the MCP WebSocket transport INGRESS — an {@link UpgradeHandler} that exposes a
1378
- * transport-agnostic {@link MCPServerInterface} over a WebSocket, the WebSocket mirror of
1581
+ * transport-agnostic {@link MCPDispatcherInterface} over a WebSocket, the WebSocket mirror of
1379
1582
  * {@link createMCPRoutes}. Register it on the spine's upgrade seam.
1380
1583
  *
1381
1584
  * @remarks
@@ -1403,7 +1606,7 @@ function createHTTPClientTransport(options) {
1403
1606
  * handler BEFORE this one — that handler can claim (decline + destroy) an unauthenticated
1404
1607
  * upgrade so it never reaches this pump.
1405
1608
  *
1406
- * @param mcp - The transport-agnostic {@link MCPServerInterface} to expose over WebSocket
1609
+ * @param mcp - The transport-agnostic {@link MCPDispatcherInterface} to expose over WebSocket
1407
1610
  * @param options - Optional `path` (default {@link DEFAULT_MCP_PATH}) and `subprotocol`
1408
1611
  * (default {@link MCP_WEBSOCKET_SUBPROTOCOL}); see {@link WebSocketServerOptions}
1409
1612
  * @returns An {@link UpgradeHandler} to register with the spine's `upgrade` seam
@@ -1441,7 +1644,7 @@ function createWebSocketServer(mcp, options) {
1441
1644
  }
1442
1645
  /**
1443
1646
  * Create the WebSocket CLIENT transport for an {@link import('@src/core').MCPClientInterface}
1444
- * — a {@link ClientTransportInterface} that drives a REMOTE MCP server over a WebSocket. The
1647
+ * — a {@link MCPClientTransportInterface} that drives a REMOTE MCP server over a WebSocket. The
1445
1648
  * egress mirror of {@link createWebSocketServer} and the WebSocket sibling of {@link
1446
1649
  * createHTTPClientTransport}.
1447
1650
  *
@@ -1457,7 +1660,7 @@ function createWebSocketServer(mcp, options) {
1457
1660
  *
1458
1661
  * @param options - `url` (the remote WebSocket endpoint; REQUIRED) and optional `headers`
1459
1662
  * merged onto the upgrade request; see {@link WebSocketClientTransportOptions}
1460
- * @returns A working {@link ClientTransportInterface} over a WebSocket
1663
+ * @returns A working {@link MCPClientTransportInterface} over a WebSocket
1461
1664
  *
1462
1665
  * @example
1463
1666
  * ```ts
@@ -1476,7 +1679,7 @@ function createWebSocketClientTransport(options) {
1476
1679
  }
1477
1680
  /**
1478
1681
  * Create the stdio CLIENT transport for an {@link import('@src/core').MCPClientInterface}
1479
- * — a {@link ClientTransportInterface} that spawns and drives a CHILD PROCESS MCP server
1682
+ * — a {@link MCPClientTransportInterface} that spawns and drives a CHILD PROCESS MCP server
1480
1683
  * over newline-delimited JSON-RPC on `stdin`/`stdout`, the stdio sibling of {@link
1481
1684
  * createHTTPClientTransport} and {@link createWebSocketClientTransport}.
1482
1685
  *
@@ -1491,7 +1694,7 @@ function createWebSocketClientTransport(options) {
1491
1694
  *
1492
1695
  * @param options - `command` (the executable to spawn; REQUIRED), optional `args`,
1493
1696
  * and optional `env`; see {@link StdioClientTransportOptions}
1494
- * @returns A working {@link ClientTransportInterface} over a child process's stdio
1697
+ * @returns A working {@link MCPClientTransportInterface} over a child process's stdio
1495
1698
  *
1496
1699
  * @example
1497
1700
  * ```ts
@@ -1510,7 +1713,7 @@ function createStdioClientTransport(options) {
1510
1713
  }
1511
1714
  /**
1512
1715
  * Create the MCP stdio transport INGRESS — pumps a transport-agnostic {@link
1513
- * MCPServerInterface} over newline-delimited JSON-RPC on `stdin`/`stdout` (or an
1716
+ * MCPDispatcherInterface} over newline-delimited JSON-RPC on `stdin`/`stdout` (or an
1514
1717
  * injected stream pair), the stdio mirror of {@link createWebSocketServer}.
1515
1718
  *
1516
1719
  * @remarks
@@ -1524,7 +1727,7 @@ function createStdioClientTransport(options) {
1524
1727
  * surfaces on `mcp.emitter`'s `error` event rather than escaping the (async) message
1525
1728
  * pump.
1526
1729
  *
1527
- * @param mcp - The transport-agnostic {@link MCPServerInterface} to expose over stdio
1730
+ * @param mcp - The transport-agnostic {@link MCPDispatcherInterface} to expose over stdio
1528
1731
  * @param options - Optional injectable `input` / `output` streams; see
1529
1732
  * {@link StdioServerOptions}
1530
1733
  * @returns A `{ start(): void; stop(): void }` handle to arm / tear down the pump
@@ -1577,7 +1780,10 @@ function createStdioServer(mcp, options) {
1577
1780
  * live-session request. It then
1578
1781
  * FORWARDS a fresh `Request` carrying the buffered `text` (`next(forwarded)`) — never the
1579
1782
  * already-consumed original — so the route re-reads the same body, and stamps the response
1580
- * with {@link MCP_SESSION_HEADER}.
1783
+ * with {@link MCP_SESSION_HEADER}. The entry's `touched` instant is read AFTER that
1784
+ * downstream response, because it means the LAST ACCESS: a request slower than `ttl` would
1785
+ * otherwise store a session that is already expired, and the write-back RE-ASKS the store, so
1786
+ * a `DELETE` arriving while the request was suspended is not undone.
1581
1787
  * - **`GET {path}`.** Resolves the session the same way (no mint — only `initialize` mints);
1582
1788
  * an invalid / unknown id is the same `404`. A valid session opens the resumable
1583
1789
  * server→client stream via `@orkestrel/server`'s {@link import('@orkestrel/server').openStream}:
@@ -1679,22 +1885,24 @@ function createMCPSession(options) {
1679
1885
  }
1680
1886
  if (context.method !== "POST" || text === void 0) return next();
1681
1887
  let created;
1682
- if (entry === void 0) if (parsed !== void 0 && isInitializeRequest(parsed)) {
1683
- created = {
1684
- session: new MCPSession(crypto.randomUUID(), capacity !== void 0 ? { capacity } : {}),
1685
- touched: clock(),
1686
- version: inferLegacyVersion(parsed)
1687
- };
1688
- entry = created;
1689
- } else return rejectUnknownSession();
1888
+ if (entry === void 0) {
1889
+ if (parsed !== void 0 && isInitializeRequest(parsed)) {
1890
+ created = {
1891
+ session: new MCPSession(crypto.randomUUID(), capacity !== void 0 ? { capacity } : {}),
1892
+ touched: clock(),
1893
+ version: inferLegacyVersion(parsed)
1894
+ };
1895
+ entry = created;
1896
+ } else return rejectUnknownSession();
1897
+ }
1690
1898
  if (!Reflect.set(context.state, "session", entry.session)) throw new Error("MCP session state is not writable");
1691
1899
  const headers = new Headers(request.headers);
1692
1900
  if (parsed === void 0 || !isInitializeRequest(parsed)) {
1693
- const protocol = headers.get(MCP_PROTOCOL_VERSION_HEADER);
1694
- if (protocol === null) headers.set(MCP_PROTOCOL_VERSION_HEADER, entry.version);
1695
- else if (protocol !== entry.version) {
1696
- const requestId = parsed !== void 0 && "method" in parsed ? parsed.id ?? null : null;
1697
- return Response.json(buildJSONRPCError(requestId, MCP_HEADER_MISMATCH, "MCP protocol version does not match the active session"), { status: 400 });
1901
+ const issue = inferHeaderIssue(request, entry.version);
1902
+ if (issue?.reason === "missing") headers.set(MCP_PROTOCOL_VERSION_HEADER, entry.version);
1903
+ else if (issue !== void 0) {
1904
+ const requestId = parsed !== void 0 && "method" in parsed ? parsed.id : void 0;
1905
+ return Response.json(buildJSONRPCError(requestId, MCP_HEADER_MISMATCH, issue.message), { status: 400 });
1698
1906
  }
1699
1907
  }
1700
1908
  const response = await next(new Request(context.url, {
@@ -1705,13 +1913,19 @@ function createMCPSession(options) {
1705
1913
  }));
1706
1914
  if (created !== void 0) {
1707
1915
  if (!response.ok) return response;
1708
- store.set(created.session.id, created);
1709
- }
1916
+ store.set(created.session.id, {
1917
+ ...created,
1918
+ touched: clock()
1919
+ });
1920
+ } else if (store.get(entry.session.id) === entry) store.set(entry.session.id, {
1921
+ ...entry,
1922
+ touched: clock()
1923
+ });
1710
1924
  response.headers.set(MCP_SESSION_HEADER, entry.session.id);
1711
1925
  return response;
1712
1926
  };
1713
1927
  }
1714
1928
  //#endregion
1715
- export { DEFAULT_MCP_KEEPALIVE_INTERVAL, DEFAULT_MCP_PATH, DEFAULT_MCP_SESSION_CAPACITY, DEFAULT_MCP_SESSION_TTL, HTTPClientTransport, 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, createHTTPClientTransport, createMCPPostHandler, createMCPRoutes, createMCPSession, createReadableStream, createStdioClientTransport, createStdioServer, createWebSocketClientTransport, createWebSocketServer, decodeEvent, dispatchLines, extractLines, inferLegacyVersion, inferStatus, matchesModernHeaders, readEventStream, readLastEventId, readSessionHeader, rejectUnknownSession, upgradeRequestPath };
1929
+ export { 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, createHTTPClientTransport, createMCPContinuation, createMCPPostHandler, createMCPRoutes, createMCPSession, createReadableStream, createStdioClientTransport, createStdioServer, createWebSocketClientTransport, createWebSocketServer, decodeEvent, dispatchLines, extractLines, inferHeaderIssue, inferLegacyVersion, inferStatus, readEventStream, readLastEventId, readSessionHeader, rejectUnknownSession, sendEventStream, upgradeRequestPath };
1716
1930
 
1717
1931
  //# sourceMappingURL=index.js.map