@orkestrel/mcp 0.0.8 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { createSSEParser } from "@orkestrel/sse";
2
- import { JSONRPC_INVALID_REQUEST, JSONRPC_PARSE_ERROR, SUPPORTED_PROTOCOL_VERSIONS, bindServer, isInitializeRequest, isJSONRPCRequest, isJSONRPCResponse, jsonRPCError, parseJSONRPCMessage } from "../core/index.js";
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
3
  import { isRecord, isString } from "@orkestrel/contract";
4
4
  import { openStream } from "@orkestrel/server";
5
5
  import { Emitter } from "@orkestrel/emitter";
@@ -26,9 +26,28 @@ var MCP_SESSION_HEADER = "mcp-session-id";
26
26
  * requests; `createMCPRoutes` rejects a present unsupported value before dispatch.
27
27
  */
28
28
  var MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version";
29
+ /** The modern Streamable-HTTP request header carrying the JSON-RPC method name. */
30
+ var MCP_METHOD_HEADER = "mcp-method";
31
+ /** The modern Streamable-HTTP request header carrying a named method's target. */
32
+ var MCP_NAME_HEADER = "mcp-name";
33
+ /** The reverse-proxy response header controlling buffering of an SSE response. */
34
+ var SSE_BUFFERING_HEADER = "x-accel-buffering";
35
+ /** The `X-Accel-Buffering` value that disables reverse-proxy buffering. */
36
+ var SSE_BUFFERING_DISABLED = "no";
29
37
  /** The default request path `createMCPRoutes` mounts the transport's `POST` route at. */
30
38
  var DEFAULT_MCP_PATH = "/mcp";
31
39
  /**
40
+ * The default interval in milliseconds between SSE keepalive comments on held-open MCP
41
+ * responses.
42
+ *
43
+ * @remarks
44
+ * Fifteen seconds is infrequent enough to avoid chatty idle connections while bounding dead
45
+ * client detection and staying comfortably inside common intermediary idle windows.
46
+ */
47
+ var DEFAULT_MCP_KEEPALIVE_INTERVAL = 15e3;
48
+ /** The comment text written by the held-open MCP response keepalive. */
49
+ var SSE_KEEPALIVE_COMMENT = "keepalive";
50
+ /**
32
51
  * The WebSocket subprotocol the MCP-over-WebSocket transports negotiate — sent by the
33
52
  * client in `Sec-WebSocket-Protocol`, echoed by the server in its `101` handshake.
34
53
  *
@@ -66,6 +85,19 @@ var DEFAULT_MCP_SESSION_TTL = 3e5;
66
85
  //#endregion
67
86
  //#region src/server/helpers.ts
68
87
  /**
88
+ * Create a readable stream from its pull and cancellation behaviours.
89
+ *
90
+ * @param pull - The behaviour that supplies the stream's next chunk
91
+ * @param cancel - The behaviour that releases the stream after consumer cancellation
92
+ * @returns A readable stream backed by the supplied behaviours
93
+ */
94
+ function createReadableStream(pull, cancel) {
95
+ return new ReadableStream({
96
+ pull,
97
+ cancel
98
+ });
99
+ }
100
+ /**
69
101
  * Whether the request's `Accept` header opts into a Server-Sent-Events response.
70
102
  *
71
103
  * @remarks
@@ -84,6 +116,55 @@ function acceptsEventStream(request) {
84
116
  return accept.toLowerCase().includes("text/event-stream");
85
117
  }
86
118
  /**
119
+ * Whether an HTTP request satisfies the endpoint's origin gate.
120
+ *
121
+ * @remarks
122
+ * Validation is enabled by default. A request without `Origin` is allowed. A canonical origin
123
+ * whose host is the `localhost` or `[::1]` literal, or belongs to the `127.0.0.0/8` literal
124
+ * range, is allowed without configuration; every other present origin must occur exactly in
125
+ * the caller-supplied list. Invalid and opaque (`null`) origins are denied. `enabled: false`
126
+ * delegates validation to an upstream layer and allows the request through this gate.
127
+ *
128
+ * @param request - The fetch-standard request to validate
129
+ * @param options - Shared origin validation and delegation options
130
+ * @returns `true` when the request may reach MCP dispatch
131
+ */
132
+ function allowsOrigin(request, options) {
133
+ if (options?.enabled === false) return true;
134
+ const origin = request.headers.get("origin");
135
+ if (origin === null) return true;
136
+ let parsed;
137
+ try {
138
+ parsed = new URL(origin);
139
+ } catch {
140
+ return false;
141
+ }
142
+ if (parsed.origin !== origin) return false;
143
+ if (parsed.hostname === "localhost" || parsed.hostname === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(parsed.hostname)) return true;
144
+ return options?.origins?.includes(parsed.origin) ?? false;
145
+ }
146
+ /**
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
+ /**
87
168
  * Read the request's `mcp-session-id` header — the session id a stateful transport
88
169
  * validates, or `undefined` when absent.
89
170
  *
@@ -124,7 +205,7 @@ function readLastEventId(request) {
124
205
  * JSON-RPC error body.
125
206
  *
126
207
  * @remarks
127
- * Returns `Response.json(jsonRPCError(null, JSONRPC_INVALID_REQUEST, 'Session not found'),
208
+ * Returns `Response.json(buildJSONRPCError(null, JSONRPC_INVALID_REQUEST, 'Session not found'),
128
209
  * { status: 404 })`, mirroring `createMCPRoutes`'s `400` transport-failure shape (a
129
210
  * JSON-RPC error BODY with a `null` id) but at the session-not-found status. Shared by
130
211
  * every {@link import('./middlewares.js').createMCPSession} validation site — the
@@ -135,7 +216,7 @@ function readLastEventId(request) {
135
216
  * @returns The `404` JSON-RPC error `Response`
136
217
  */
137
218
  function rejectUnknownSession() {
138
- return Response.json(jsonRPCError(null, JSONRPC_INVALID_REQUEST, "Session not found"), { status: 404 });
219
+ return Response.json(buildJSONRPCError(null, JSONRPC_INVALID_REQUEST, "Session not found"), { status: 404 });
139
220
  }
140
221
  /**
141
222
  * Decode a `fetch` Response's Server-Sent-Events body into the JSON-RPC messages it
@@ -335,17 +416,139 @@ function bridgeMessageTransport(transport) {
335
416
  };
336
417
  }
337
418
  //#endregion
419
+ //#region src/server/inferers.ts
420
+ /**
421
+ * Infer the legacy revision an `initialize` request negotiates.
422
+ *
423
+ * @remarks
424
+ * A supported legacy request is pinned exactly. A modern, malformed, absent, or unsupported
425
+ * request selects the newest supported legacy revision, matching the core initialize result.
426
+ *
427
+ * @param request - The legacy initialize request
428
+ * @returns The negotiated legacy protocol revision
429
+ */
430
+ function inferLegacyVersion(request) {
431
+ const requested = request.params?.["protocolVersion"];
432
+ const version = inferVersion(isString(requested) ? [requested] : []);
433
+ if (version !== void 0 && inferEra(version) === "legacy") return version;
434
+ return MCP_PROTOCOL_VERSION;
435
+ }
436
+ /**
437
+ * Infer the HTTP status for one MCP dispatch outcome without changing its JSON-RPC body.
438
+ *
439
+ * @remarks
440
+ * Notifications are accepted with `202`. Legacy response envelopes retain uniform `200`
441
+ * status semantics, including in-band errors. Modern header/capability/version/parameter
442
+ * failures map to `400`, method-not-found maps to `404`, and every other modern result maps
443
+ * to `200`.
444
+ *
445
+ * @param response - The dispatch response, or `undefined` for a notification
446
+ * @param era - The structurally selected request era
447
+ * @returns The HTTP response status
448
+ */
449
+ function inferStatus(response, era) {
450
+ if (response === void 0) return 202;
451
+ if (era === "legacy" || response.error === void 0) return 200;
452
+ if (response.error.code === JSONRPC_METHOD_NOT_FOUND) return 404;
453
+ if (response.error.code === MCP_HEADER_MISMATCH || response.error.code === MCP_MISSING_CAPABILITY || response.error.code === MCP_UNSUPPORTED_VERSION || response.error.code === JSONRPC_INVALID_PARAMS) return 400;
454
+ return 200;
455
+ }
456
+ //#endregion
457
+ //#region src/server/transports/HTTPDisconnect.ts
458
+ /**
459
+ * The HTTP response-disconnect bridge for an MCP SSE stream.
460
+ *
461
+ * @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.
469
+ */
470
+ var HTTPDisconnect = class {
471
+ #abort = new AbortController();
472
+ #lifecycle = new AbortController();
473
+ #interval;
474
+ #signal;
475
+ #timer;
476
+ constructor(signal, options) {
477
+ this.#interval = options?.interval ?? 15e3;
478
+ this.#signal = AbortSignal.any([signal, this.#abort.signal]);
479
+ }
480
+ get signal() {
481
+ return this.#signal;
482
+ }
483
+ /**
484
+ * Bridge cancellation of an SSE response body into this disconnect signal.
485
+ *
486
+ * @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
488
+ */
489
+ bridge(stream) {
490
+ const response = stream.response;
491
+ const body = response.body;
492
+ if (body === null) throw new Error("MCP SSE response has no body");
493
+ const reader = body.getReader();
494
+ this.#timer = setInterval(() => {
495
+ if (stream.closed) this.#stop();
496
+ else stream.comment(SSE_KEEPALIVE_COMMENT);
497
+ }, this.#interval);
498
+ this.#signal.addEventListener("abort", () => this.#stop(), {
499
+ once: true,
500
+ signal: this.#lifecycle.signal
501
+ });
502
+ if (this.#signal.aborted || stream.closed) this.#stop();
503
+ return new Response(createReadableStream(async (controller) => {
504
+ try {
505
+ const chunk = await reader.read();
506
+ if (chunk.done) {
507
+ this.#stop();
508
+ controller.close();
509
+ } else controller.enqueue(chunk.value);
510
+ } catch (error) {
511
+ this.#stop();
512
+ controller.error(error);
513
+ }
514
+ }, async (reason) => {
515
+ this.#abort.abort();
516
+ this.#stop();
517
+ await reader.cancel(reason);
518
+ }), {
519
+ status: response.status,
520
+ statusText: response.statusText,
521
+ headers: response.headers
522
+ });
523
+ }
524
+ #stop() {
525
+ if (this.#timer !== void 0) {
526
+ clearInterval(this.#timer);
527
+ this.#timer = void 0;
528
+ }
529
+ this.#lifecycle.abort();
530
+ }
531
+ };
532
+ //#endregion
338
533
  //#region src/server/handlers.ts
339
534
  /**
340
535
  * Create the Streamable-HTTP POST handler used by `createMCPRoutes`.
341
536
  *
342
537
  * @remarks
343
- * A present `mcp-protocol-version` header must name a supported revision; an
344
- * unsupported value returns an HTTP `400` JSON-RPC invalid-request error without
345
- * dispatching. An absent header is accepted for the initialize/bootstrap request.
346
- *
538
+ * Modern requests require matching protocol/method headers and a matching name header only
539
+ * for `tools/call`; mismatch returns HTTP `400` + `-32020`. Headerless `initialize` is
540
+ * accepted, while every other headerless request needs a live legacy session to supply its
541
+ * pinned version. A present origin must occur in `origin.origins` unless validation is
542
+ * explicitly delegated upstream. Modern dispatch errors use their protocol status map; legacy
543
+ * errors remain in-band at HTTP `200`. A streamed response composes the fetch-standard request
544
+ * signal with response-body cancellation and supplies the result to every dispatched modern
545
+ * handler through `MCPDispatchOptions.signal`. After every transport validation and immediately
546
+ * before dispatch, the optional synchronous `caller` extractor reads front-middleware state; a
547
+ * defined value is added to `MCPDispatchOptions`, while `undefined` is omitted.
548
+ *
549
+ * @typeParam TState - The consumer's opaque per-request route state type
347
550
  * @param mcp - The transport-agnostic MCP server to dispatch through
348
- * @param streaming - Whether an event-stream response may be negotiated
551
+ * @param options - Optional streaming, origin-validation, SSE keepalive, and caller-extraction options
349
552
  * @returns A request handler for the stateless MCP POST route
350
553
  *
351
554
  * @example
@@ -354,41 +557,79 @@ function bridgeMessageTransport(transport) {
354
557
  * import { createMCPPostHandler } from '@orkestrel/mcp/server'
355
558
  * import { createToolManager } from '@orkestrel/tool'
356
559
  *
357
- * const mcp = createMCPServer({ name: 'docs', version: '1.0.0', tools: createToolManager() })
358
- * const handler = createMCPPostHandler(mcp, true)
560
+ * const mcp = createMCPServer({ identity: { name: 'docs', version: '1.0.0' }, tools: createToolManager() })
561
+ * const handler = createMCPPostHandler(mcp, { streaming: true })
359
562
  * await handler(new Request('http://localhost/mcp', {
360
563
  * method: 'POST',
361
564
  * body: '{"jsonrpc":"2.0","method":"ping","id":1}',
362
565
  * }))
363
566
  * ```
364
567
  */
365
- function createMCPPostHandler(mcp, streaming) {
366
- return async (request) => {
367
- const protocol = request.headers.get(MCP_PROTOCOL_VERSION_HEADER);
368
- if (protocol !== null && !SUPPORTED_PROTOCOL_VERSIONS.includes(protocol)) return Response.json(jsonRPCError(null, JSONRPC_INVALID_REQUEST, `Unsupported MCP protocol version '${protocol}'`), { status: 400 });
568
+ function createMCPPostHandler(mcp, options) {
569
+ const streaming = options?.streaming ?? true;
570
+ const origin = options?.origin;
571
+ return async (request, context) => {
572
+ if (!allowsOrigin(request, origin)) return new Response(null, { status: 403 });
369
573
  let text;
370
574
  try {
371
575
  text = await request.text();
372
576
  } catch {
373
- return Response.json(jsonRPCError(null, JSONRPC_PARSE_ERROR, "Parse error"), { status: 400 });
577
+ return Response.json(buildJSONRPCError(null, JSONRPC_PARSE_ERROR, "Parse error"), { status: 400 });
374
578
  }
375
579
  let parsed;
376
580
  try {
377
581
  parsed = JSON.parse(text);
378
582
  } catch {
379
- return Response.json(jsonRPCError(null, JSONRPC_PARSE_ERROR, "Parse error"), { status: 400 });
583
+ return Response.json(buildJSONRPCError(null, JSONRPC_PARSE_ERROR, "Parse error"), { status: 400 });
380
584
  }
381
585
  const rpcRequest = parseJSONRPCMessage(parsed);
382
- if (rpcRequest === void 0 || !("method" in rpcRequest)) return Response.json(jsonRPCError(null, JSONRPC_INVALID_REQUEST, "Invalid Request"), { status: 400 });
383
- const response = await mcp.dispatch(rpcRequest);
384
- if (response === void 0) return new Response(null, { status: 202 });
385
- if (streaming && acceptsEventStream(request)) {
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;
589
+ const protocol = request.headers.get(MCP_PROTOCOL_VERSION_HEADER);
590
+ 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 });
595
+ if (protocol !== null && !isMCPVersion(protocol)) return Response.json(buildJSONRPCError(id, MCP_UNSUPPORTED_VERSION, `Unsupported MCP protocol version '${protocol}'`, {
596
+ supported: SUPPORTED_PROTOCOL_VERSIONS,
597
+ requested: protocol
598
+ }), { status: 400 });
599
+ }
600
+ const disconnect = new HTTPDisconnect(request.signal, options?.keepalive);
601
+ const caller = options?.caller?.(request, context);
602
+ const response = await mcp.dispatch(rpcRequest, {
603
+ signal: disconnect.signal,
604
+ ...caller === void 0 ? {} : { caller }
605
+ });
606
+ if (response !== void 0 && Symbol.asyncIterator in response) {
607
+ const stream = openStream();
608
+ 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
+ });
621
+ return disconnect.bridge(stream);
622
+ }
623
+ const status = inferStatus(response, era);
624
+ if (response === void 0) return new Response(null, { status });
625
+ if (status === 200 && streaming && acceptsEventStream(request)) {
386
626
  const stream = openStream();
627
+ stream.response.headers.set(SSE_BUFFERING_HEADER, "no");
387
628
  stream.write({ data: JSON.stringify(response) });
388
629
  stream.end();
389
630
  return stream.response;
390
631
  }
391
- return Response.json(response);
632
+ return Response.json(response, { status });
392
633
  };
393
634
  }
394
635
  //#endregion
@@ -412,16 +653,17 @@ function createMCPPostHandler(mcp, streaming) {
412
653
  * readEventStream}) — the inverse of the server's `openStream` seam, so the wire
413
654
  * round-trips. A `202`
414
655
  * Accepted (a notification) carries no body and emits nothing.
415
- * - **Session and protocol echo.** `start()` is a no-op (a
656
+ * - **Session and protocol headers.** `start()` is a no-op (a
416
657
  * request/response transport opens no long-lived connection). The
417
658
  * `mcp-session-id` response header, when a STATEFUL server sends one (on
418
659
  * `initialize`), is captured into `session` and then ECHOED as the
419
660
  * `mcp-session-id` request header on every SUBSEQUENT request — so an
420
661
  * `MCPClient` passes a stateful server's session validation. The
421
662
  * initialize result's `protocolVersion` is likewise captured, but only
422
- * when it is a SUPPORTED value, and echoed as `mcp-protocol-version` on
423
- * every subsequent request, as required by the 2025-06-18 Streamable-HTTP
424
- * transport. Before initialize returns, neither captured header is sent.
663
+ * when it is a SUPPORTED value, and echoed as `mcp-protocol-version` alone on
664
+ * subsequent legacy requests. Modern requests instead derive protocol and method
665
+ * headers from the message, plus the name header only for `tools/call`.
666
+ * Before initialize returns, neither captured legacy header is sent.
425
667
  * `close()` clears the captured protocol so a reconnect's `initialize`
426
668
  * POST is headerless; the captured `session` persists across `close()`.
427
669
  * - **Total at the boundary (§14).** Every reply is narrowed (`parseJSONRPCMessage`,
@@ -468,7 +710,7 @@ var HTTPClientTransport = class {
468
710
  "content-type": "application/json",
469
711
  accept: "application/json, text/event-stream",
470
712
  ...this.#session === void 0 ? {} : { [MCP_SESSION_HEADER]: this.#session },
471
- ...this.#protocol === void 0 ? {} : { [MCP_PROTOCOL_VERSION_HEADER]: this.#protocol },
713
+ ...this.#buildHeaders(message),
472
714
  ...this.#headers
473
715
  },
474
716
  body: JSON.stringify(message),
@@ -486,6 +728,18 @@ var HTTPClientTransport = class {
486
728
  this.#protocol = void 0;
487
729
  this.#emitter.emit("close");
488
730
  }
731
+ #buildHeaders(message) {
732
+ if (isJSONRPCRequest(message) && isModernRequest(message)) {
733
+ const version = (isRecord(message.params?.["_meta"]) ? message.params["_meta"] : void 0)?.[MCP_META_VERSION];
734
+ const name = message.params?.["name"];
735
+ return {
736
+ ...isString(version) ? { [MCP_PROTOCOL_VERSION_HEADER]: version } : {},
737
+ [MCP_METHOD_HEADER]: message.method,
738
+ ...message.method === "tools/call" && isString(name) ? { [MCP_NAME_HEADER]: name } : {}
739
+ };
740
+ }
741
+ return this.#protocol === void 0 ? {} : { [MCP_PROTOCOL_VERSION_HEADER]: this.#protocol };
742
+ }
489
743
  async #deliver(response) {
490
744
  if (response.status === 202) return;
491
745
  const type = response.headers.get("content-type") ?? "";
@@ -503,7 +757,7 @@ var HTTPClientTransport = class {
503
757
  }
504
758
  }
505
759
  #capture(message) {
506
- if (isJSONRPCResponse(message) && isRecord(message.result) && isString(message.result["protocolVersion"]) && SUPPORTED_PROTOCOL_VERSIONS.includes(message.result["protocolVersion"])) this.#protocol = message.result["protocolVersion"];
760
+ if (isJSONRPCResponse(message) && isRecord(message.result) && isMCPVersion(message.result["protocolVersion"])) this.#protocol = message.result["protocolVersion"];
507
761
  this.#emitter.emit("message", message);
508
762
  }
509
763
  };
@@ -1034,12 +1288,11 @@ var StdioServerTransport = class {
1034
1288
  * - A **transport** failure — a malformed JSON body, or a parsed value that is not a
1035
1289
  * JSON-RPC REQUEST — is an HTTP `400` carrying a JSON-RPC error BODY (`-32700` Parse
1036
1290
  * error / `-32600` Invalid Request, id `null`).
1037
- * - A present `mcp-protocol-version` header is validated before dispatch: a supported
1038
- * value proceeds, while an unsupported value returns HTTP `400` with a JSON-RPC
1039
- * `-32600` body. An absent value proceeds for initialize/bootstrap compatibility.
1040
- * - A **dispatch** result a success OR an IN-BAND JSON-RPC error from `mcp.dispatch`
1041
- * (e.g. `-32601` method-not-found) is an HTTP `200` carrying the JSON-RPC response
1042
- * envelope (the error is in-band per JSON-RPC, NOT an HTTP error).
1291
+ * - Modern protocol/method/name headers are validated against the body; a mismatch is
1292
+ * HTTP `400` + `-32020`. Headerless initialize is accepted, a live legacy session supplies
1293
+ * its pinned revision, and every other headerless request is rejected.
1294
+ * - Legacy dispatch errors stay IN-BAND at HTTP `200`; modern errors map to `400` for
1295
+ * `-32020` / `-32021` / `-32022` / `-32602`, `404` for `-32601`, and `200` otherwise.
1043
1296
  * - A **notification** (a request with no `id`, which `dispatch` resolves to
1044
1297
  * `undefined`) is a `202 Accepted` with no body.
1045
1298
  *
@@ -1054,13 +1307,15 @@ var StdioServerTransport = class {
1054
1307
  * validates the `mcp-session-id`, and serves the resumable `GET {path}` + `DELETE {path}`,
1055
1308
  * leaving this route to dispatch the validated `POST`.
1056
1309
  *
1057
- * This is MECHANISM, not policy: compose auth / CORS / rate-limiting (and the session
1058
- * middleware) IN FRONT as ordinary middleware the transport route adds none.
1310
+ * This is MECHANISM, not policy: compose auth / rate-limiting (and the session middleware)
1311
+ * IN FRONT as ordinary middleware; the optional `origin` group carries the deployment's shared
1312
+ * allowlist or explicitly delegates validation to an upstream layer.
1059
1313
  *
1060
1314
  * @typeParam TState - The consumer's opaque per-request state type
1061
1315
  * @param mcp - The transport-agnostic {@link MCPServerInterface} to expose over HTTP
1062
1316
  * @param options - Optional `path` (default {@link DEFAULT_MCP_PATH}) and `streaming`
1063
- * (default `true`); see {@link HTTPTransportOptions}
1317
+ * (default `true`), plus shared origin, keepalive, and synchronous caller-extraction options; see
1318
+ * {@link HTTPTransportOptions}
1064
1319
  * @returns The {@link RouteInput}s to register with the router
1065
1320
  *
1066
1321
  * @example
@@ -1068,7 +1323,7 @@ var StdioServerTransport = class {
1068
1323
  * import { createMCPServer, createToolManager } from '@src/core'
1069
1324
  * import { createMCPRoutes } from '@src/server'
1070
1325
  *
1071
- * const mcp = createMCPServer({ name: 'docs', version: '1.0.0', tools: createToolManager() })
1326
+ * const mcp = createMCPServer({ identity: { name: 'docs', version: '1.0.0' }, tools: createToolManager() })
1072
1327
  * const routes = createMCPRoutes(mcp) // POST /mcp dispatches JSON-RPC (JSON or SSE per Accept)
1073
1328
  * ```
1074
1329
  */
@@ -1077,7 +1332,7 @@ function createMCPRoutes(mcp, options) {
1077
1332
  method: "POST",
1078
1333
  path: options?.path ?? "/mcp",
1079
1334
  name: "mcp",
1080
- handler: createMCPPostHandler(mcp, options?.streaming ?? true)
1335
+ handler: createMCPPostHandler(mcp, options)
1081
1336
  }];
1082
1337
  }
1083
1338
  /**
@@ -1094,9 +1349,9 @@ function createMCPRoutes(mcp, options) {
1094
1349
  * correlation. Add `options.headers` (e.g. an `Authorization` bearer) to reach a guarded
1095
1350
  * server. `start` / `close` hold no connection; against a STATEFUL server it captures the
1096
1351
  * `mcp-session-id` from `initialize` and echoes it on later requests. It also captures
1097
- * the initialize result's `protocolVersion` and sends `mcp-protocol-version` on every
1098
- * subsequent request, so the same `MCPClient` passes the session and 2025-06-18
1099
- * protocol gates without caller wiring.
1352
+ * the initialize result's `protocolVersion` and sends `mcp-protocol-version` alone on each
1353
+ * subsequent legacy request. Modern requests derive protocol and method headers directly
1354
+ * from the message, plus a name header only for `tools/call`.
1100
1355
  *
1101
1356
  * @param options - `url` (the remote endpoint; REQUIRED), optional `headers` merged onto
1102
1357
  * every request, optional `fetch` (default `globalThis.fetch`), and optional `timeout`
@@ -1158,7 +1413,7 @@ function createHTTPClientTransport(options) {
1158
1413
  * import { createMCPServer, createToolManager } from '@src/core'
1159
1414
  * import { createWebSocketServer } from '@src/server'
1160
1415
  *
1161
- * const mcp = createMCPServer({ name: 'docs', version: '1.0.0', tools: createToolManager() })
1416
+ * const mcp = createMCPServer({ identity: { name: 'docs', version: '1.0.0' }, tools: createToolManager() })
1162
1417
  * server.upgrade(createWebSocketServer(mcp)) // an MCP client now connects over ws://…/mcp
1163
1418
  * ```
1164
1419
  */
@@ -1279,7 +1534,7 @@ function createStdioClientTransport(options) {
1279
1534
  * import { createMCPServer, createToolManager } from '@src/core'
1280
1535
  * import { createStdioServer } from '@src/server'
1281
1536
  *
1282
- * const mcp = createMCPServer({ name: 'docs', version: '1.0.0', tools: createToolManager() })
1537
+ * const mcp = createMCPServer({ identity: { name: 'docs', version: '1.0.0' }, tools: createToolManager() })
1283
1538
  * createStdioServer(mcp).start() // an MCP client now connects over this process's stdio
1284
1539
  * ```
1285
1540
  */
@@ -1309,12 +1564,17 @@ function createStdioServer(mcp, options) {
1309
1564
  * `path` (default {@link DEFAULT_MCP_PATH}); a request to any other path passes straight
1310
1565
  * through (`next()`).
1311
1566
  *
1567
+ * A modern-shaped POST also passes straight through via `next()`, ignoring any session id.
1568
+ * The remaining behavior is the legacy session layer:
1569
+ *
1312
1570
  * - **`POST {path}`.** Buffers `const text = await request.text()` (so the downstream route
1313
1571
  * can re-read it via a freshly-built forwarded `Request`). Resolves a session via {@link
1314
1572
  * readSessionHeader}: a VALID id touches the entry and sets `context.state.session`; an
1315
1573
  * ABSENT / unknown id whose (guarded) body parses to an `initialize` request ({@link
1316
1574
  * isInitializeRequest}) MINTS a fresh {@link MCPSession} (`crypto.randomUUID()`, `capacity`)
1317
- * and sets `context.state.session`; neither → {@link rejectUnknownSession} (`404`). It then
1575
+ * and sets `context.state.session`; neither → {@link rejectUnknownSession} (`404`). The
1576
+ * minted entry pins the negotiated legacy revision, which is supplied to a later headerless
1577
+ * live-session request. It then
1318
1578
  * FORWARDS a fresh `Request` carrying the buffered `text` (`next(forwarded)`) — never the
1319
1579
  * already-consumed original — so the route re-reads the same body, and stamps the response
1320
1580
  * with {@link MCP_SESSION_HEADER}.
@@ -1322,8 +1582,8 @@ function createStdioServer(mcp, options) {
1322
1582
  * an invalid / unknown id is the same `404`. A valid session opens the resumable
1323
1583
  * server→client stream via `@orkestrel/server`'s {@link import('@orkestrel/server').openStream}:
1324
1584
  * replays every event after the client's `Last-Event-ID` ({@link readLastEventId}) BEFORE
1325
- * attaching the stream for live pushes, then attaches; a client disconnect (`request.signal`)
1326
- * detaches it. Long-lived — never `end()`ed here.
1585
+ * attaching the stream for live pushes, then attaches; cancellation of the streamed response
1586
+ * body composes with `request.signal` and detaches it. Long-lived — never `end()`ed here.
1327
1587
  * - **`DELETE {path}`.** Resolves the session; a valid id deletes it from the store and answers
1328
1588
  * `204`; an invalid / unknown id is the same `404`.
1329
1589
  *
@@ -1337,7 +1597,8 @@ function createStdioServer(mcp, options) {
1337
1597
  * @param options - Optional `path` (default {@link DEFAULT_MCP_PATH}), `ttl` (idle-session
1338
1598
  * sweep window, ms — omit for sessions that live until an explicit `DELETE`), `capacity`
1339
1599
  * (the folded per-session replay-log bound), and `clock` (the deterministic epoch-ms clock;
1340
- * defaults to `Date.now`); see {@link MCPSessionOptions}
1600
+ * defaults to `Date.now`), plus the shared `origin` validation options; see
1601
+ * {@link MCPSessionOptions}
1341
1602
  * @returns A {@link MiddlewareHandler} that mints / validates sessions + serves the resumable
1342
1603
  * `GET` / `DELETE`
1343
1604
  *
@@ -1346,7 +1607,7 @@ function createStdioServer(mcp, options) {
1346
1607
  * import { createMCPServer, createToolManager } from '@src/core'
1347
1608
  * import { createMCPRoutes, createMCPSession } from '@src/server'
1348
1609
  *
1349
- * const mcp = createMCPServer({ name: 'docs', version: '1.0.0', tools: createToolManager() })
1610
+ * const mcp = createMCPServer({ identity: { name: 'docs', version: '1.0.0' }, tools: createToolManager() })
1350
1611
  * router.use(createMCPSession({ ttl: 60_000 })) // stateful: mint + validate + resumable GET / DELETE
1351
1612
  * router.add(createMCPRoutes(mcp)) // the route stays session-agnostic
1352
1613
  * ```
@@ -1356,9 +1617,27 @@ function createMCPSession(options) {
1356
1617
  const capacity = options?.capacity;
1357
1618
  const ttl = options?.ttl;
1358
1619
  const clock = options?.clock ?? Date.now;
1620
+ const origin = options?.origin;
1359
1621
  const store = /* @__PURE__ */ new Map();
1360
1622
  return async (request, context, next) => {
1361
1623
  if (context.url.pathname !== path) return next();
1624
+ if (!allowsOrigin(request, origin)) return new Response(null, { status: 403 });
1625
+ let parsed;
1626
+ let text;
1627
+ if (context.method === "POST") {
1628
+ try {
1629
+ text = await request.text();
1630
+ parsed = parseJSONRPCMessage(JSON.parse(text));
1631
+ } catch {
1632
+ parsed = void 0;
1633
+ }
1634
+ if (text !== void 0 && parsed !== void 0 && isModernRequest(parsed)) return next(new Request(context.url, {
1635
+ method: "POST",
1636
+ headers: request.headers,
1637
+ body: text,
1638
+ signal: request.signal
1639
+ }));
1640
+ }
1362
1641
  if (ttl !== void 0) {
1363
1642
  const cutoff = clock() - ttl;
1364
1643
  for (const [id, entry] of store) if (entry.touched <= cutoff) store.delete(id);
@@ -1375,7 +1654,8 @@ function createMCPSession(options) {
1375
1654
  if (current !== void 0) {
1376
1655
  entry = {
1377
1656
  session: current.session,
1378
- touched: clock()
1657
+ touched: clock(),
1658
+ version: current.version
1379
1659
  };
1380
1660
  store.set(id, entry);
1381
1661
  }
@@ -1384,6 +1664,8 @@ function createMCPSession(options) {
1384
1664
  if (entry === void 0) return rejectUnknownSession();
1385
1665
  const session = entry.session;
1386
1666
  const stream = openStream();
1667
+ const disconnect = new HTTPDisconnect(request.signal, options?.keepalive);
1668
+ stream.response.headers.set(SSE_BUFFERING_HEADER, "no");
1387
1669
  stream.comment("open");
1388
1670
  const lastEventId = readLastEventId(request);
1389
1671
  if (lastEventId !== void 0) for (const e of session.replay(lastEventId)) stream.write({
@@ -1391,38 +1673,45 @@ function createMCPSession(options) {
1391
1673
  data: JSON.stringify(e.message)
1392
1674
  });
1393
1675
  session.attach(stream);
1394
- if (request.signal.aborted) session.detach(stream);
1395
- else request.signal.addEventListener("abort", () => session.detach(stream), { once: true });
1396
- return stream.response;
1676
+ if (disconnect.signal.aborted) session.detach(stream);
1677
+ else disconnect.signal.addEventListener("abort", () => session.detach(stream), { once: true });
1678
+ return disconnect.bridge(stream);
1397
1679
  }
1398
- const text = await request.text();
1399
- if (entry === void 0) {
1400
- let parsed;
1401
- try {
1402
- parsed = parseJSONRPCMessage(JSON.parse(text));
1403
- } catch {
1404
- parsed = void 0;
1680
+ if (context.method !== "POST" || text === void 0) return next();
1681
+ 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();
1690
+ if (!Reflect.set(context.state, "session", entry.session)) throw new Error("MCP session state is not writable");
1691
+ const headers = new Headers(request.headers);
1692
+ 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 });
1405
1698
  }
1406
- if (parsed !== void 0 && isInitializeRequest(parsed)) {
1407
- const session = new MCPSession(crypto.randomUUID(), capacity !== void 0 ? { capacity } : {});
1408
- entry = {
1409
- session,
1410
- touched: clock()
1411
- };
1412
- store.set(session.id, entry);
1413
- } else return rejectUnknownSession();
1414
1699
  }
1415
- if (!Reflect.set(context.state, "session", entry.session)) throw new Error("MCP session state is not writable");
1416
1700
  const response = await next(new Request(context.url, {
1417
1701
  method: "POST",
1418
- headers: request.headers,
1419
- body: text
1702
+ headers,
1703
+ body: text,
1704
+ signal: request.signal
1420
1705
  }));
1706
+ if (created !== void 0) {
1707
+ if (!response.ok) return response;
1708
+ store.set(created.session.id, created);
1709
+ }
1421
1710
  response.headers.set(MCP_SESSION_HEADER, entry.session.id);
1422
1711
  return response;
1423
1712
  };
1424
1713
  }
1425
1714
  //#endregion
1426
- export { DEFAULT_MCP_PATH, DEFAULT_MCP_SESSION_CAPACITY, DEFAULT_MCP_SESSION_TTL, HTTPClientTransport, MCPSession, MCP_PROTOCOL_VERSION_HEADER, MCP_SESSION_HEADER, MCP_WEBSOCKET_SUBPROTOCOL, StdioClientTransport, StdioServerTransport, WebSocketClientTransport, WebSocketServerTransport, acceptsEventStream, bridgeMessageTransport, createHTTPClientTransport, createMCPPostHandler, createMCPRoutes, createMCPSession, createStdioClientTransport, createStdioServer, createWebSocketClientTransport, createWebSocketServer, decodeEvent, dispatchLines, extractLines, readEventStream, readLastEventId, readSessionHeader, rejectUnknownSession, upgradeRequestPath };
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 };
1427
1716
 
1428
1717
  //# sourceMappingURL=index.js.map