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