@orkestrel/mcp 0.0.4 → 0.0.6

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,13 +1,13 @@
1
1
  import { createSSEParser } from "@orkestrel/sse";
2
- import { JSONRPC_INVALID_REQUEST, JSONRPC_PARSE_ERROR, isInitializeRequest, isJSONRPCRequest, jsonRPCError, parseJSONRPCMessage } from "../core/index.js";
3
- import { isString } from "@orkestrel/contract";
2
+ import { JSONRPC_INVALID_REQUEST, JSONRPC_PARSE_ERROR, SUPPORTED_PROTOCOL_VERSIONS, bindServer, isInitializeRequest, isJSONRPCRequest, isJSONRPCResponse, jsonRPCError, parseJSONRPCMessage } from "../core/index.js";
3
+ import { isRecord, isString } from "@orkestrel/contract";
4
+ import { openStream } from "@orkestrel/server";
4
5
  import { Emitter } from "@orkestrel/emitter";
5
6
  import { randomBytes } from "node:crypto";
6
7
  import { request } from "node:http";
7
8
  import { request as request$1 } from "node:https";
8
9
  import { WEBSOCKET_VERSION, computeWebSocketAccept, createNodeWebSocket } from "@orkestrel/websocket";
9
10
  import { spawn } from "node:child_process";
10
- import { openStream } from "@orkestrel/server";
11
11
  //#region src/server/constants.ts
12
12
  /**
13
13
  * The Streamable-HTTP transport header that carries the MCP session id. When a {@link
@@ -17,10 +17,13 @@ import { openStream } from "@orkestrel/server";
17
17
  */
18
18
  var MCP_SESSION_HEADER = "mcp-session-id";
19
19
  /**
20
- * The Streamable-HTTP transport header that carries the negotiated MCP protocol version
21
- * on a subsequent request. The version is negotiated in the `initialize` JSON-RPC result
22
- * body; a stateful transport MAY additionally read this header to pin the per-request
23
- * protocol version (optional — the result body remains the source of truth).
20
+ * The Streamable-HTTP transport header carrying the negotiated MCP protocol version
21
+ * on every post-initialize client request.
22
+ *
23
+ * @remarks
24
+ * Required by MCP 2025-06-18 after initialization. Both HTTP client transports
25
+ * capture the initialize result's `protocolVersion` and send it on subsequent
26
+ * requests; `createMCPRoutes` rejects a present unsupported value before dispatch.
24
27
  */
25
28
  var MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version";
26
29
  /** The default request path `createMCPRoutes` mounts the transport's `POST` route at. */
@@ -263,6 +266,131 @@ function dispatchLines(emitter, lines) {
263
266
  emitter.emit("message", message);
264
267
  }
265
268
  }
269
+ /**
270
+ * Bridge a message-channel {@link ClientTransportInterface} (the shape the stdio and
271
+ * WebSocket SERVER transports already implement) into the environment-agnostic
272
+ * {@link import('@src/core').MCPTransportInterface} port — the adapter
273
+ * {@link import('./factories.js').createStdioServer} and {@link
274
+ * import('./factories.js').createWebSocketServer} pipe through `bindServer`, so the
275
+ * request/reply/error pump those two factories used to hand-roll identically now
276
+ * lives ONCE in the core binder.
277
+ *
278
+ * @remarks
279
+ * `send` decodes the already-serialized reply string back to a {@link JSONRPCMessage}
280
+ * and writes it via `transport.send` (the SAME `JSON.stringify` the underlying
281
+ * transport already performs, so the wire bytes are unchanged). `listen` filters
282
+ * `transport`'s `message` event to REQUESTS ONLY — a stray response is ignored,
283
+ * exactly as the prior hand-rolled pumps did — and re-serializes each one back to a
284
+ * string for `bindServer`. `closed` bridges `transport`'s `close` event. `close`
285
+ * closes the underlying `transport`.
286
+ *
287
+ * @remarks Per {@link import('@src/core').MCPTransportInterface}, `listen`/`closed`
288
+ * each hold THE SINGLE current handler (a second call REPLACES the first, never adds).
289
+ * Since the underlying `transport.emitter` is ADD-based (`on` subscribes, never
290
+ * replaces), this bridge installs ONE stable emitter listener per event on first use
291
+ * and re-routes it to whichever handler is CURRENTLY registered (`undefined` while
292
+ * none is), so rebinding never double-dispatches.
293
+ *
294
+ * @remarks A response whose `result` serializes away (e.g. `undefined`) is dropped by
295
+ * the message validators on the wire's decode side — an asymmetry the stdio/WS carrier
296
+ * shares with the streamable-HTTP face, since both round-trip through `JSON.stringify`
297
+ * / `JSON.parse` before re-validation.
298
+ *
299
+ * @param transport - The message-channel transport to bridge (stdio or WebSocket)
300
+ * @returns An {@link import('@src/core').MCPTransportInterface} `bindServer` can drive
301
+ *
302
+ * @example
303
+ * ```ts
304
+ * import { bindServer } from '@src/core'
305
+ *
306
+ * const transport = new StdioServerTransport(process.stdin, process.stdout)
307
+ * bindServer(mcp, bridgeMessageTransport(transport))
308
+ * ```
309
+ */
310
+ function bridgeMessageTransport(transport) {
311
+ let onMessage;
312
+ let onClosed;
313
+ transport.emitter.on("message", (message) => {
314
+ if (!isJSONRPCRequest(message)) return;
315
+ onMessage?.(JSON.stringify(message));
316
+ });
317
+ transport.emitter.on("close", () => {
318
+ onClosed?.();
319
+ });
320
+ return {
321
+ async send(message) {
322
+ const decoded = decodeEvent(message);
323
+ if (decoded === void 0) return;
324
+ await transport.send(decoded);
325
+ },
326
+ listen(handler) {
327
+ onMessage = handler;
328
+ },
329
+ closed(handler) {
330
+ onClosed = handler;
331
+ },
332
+ async close() {
333
+ await transport.close();
334
+ }
335
+ };
336
+ }
337
+ //#endregion
338
+ //#region src/server/handlers.ts
339
+ /**
340
+ * Create the Streamable-HTTP POST handler used by `createMCPRoutes`.
341
+ *
342
+ * @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
+ *
347
+ * @param mcp - The transport-agnostic MCP server to dispatch through
348
+ * @param streaming - Whether an event-stream response may be negotiated
349
+ * @returns A request handler for the stateless MCP POST route
350
+ *
351
+ * @example
352
+ * ```ts
353
+ * import { createMCPServer } from '@orkestrel/mcp'
354
+ * import { createMCPPostHandler } from '@orkestrel/mcp/server'
355
+ * import { createToolManager } from '@orkestrel/agent'
356
+ *
357
+ * const mcp = createMCPServer({ name: 'docs', version: '1.0.0', tools: createToolManager() })
358
+ * const handler = createMCPPostHandler(mcp, true)
359
+ * await handler(new Request('http://localhost/mcp', {
360
+ * method: 'POST',
361
+ * body: '{"jsonrpc":"2.0","method":"ping","id":1}',
362
+ * }))
363
+ * ```
364
+ */
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 });
369
+ let text;
370
+ try {
371
+ text = await request.text();
372
+ } catch {
373
+ return Response.json(jsonRPCError(null, JSONRPC_PARSE_ERROR, "Parse error"), { status: 400 });
374
+ }
375
+ let parsed;
376
+ try {
377
+ parsed = JSON.parse(text);
378
+ } catch {
379
+ return Response.json(jsonRPCError(null, JSONRPC_PARSE_ERROR, "Parse error"), { status: 400 });
380
+ }
381
+ 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)) {
386
+ const stream = openStream();
387
+ stream.write({ data: JSON.stringify(response) });
388
+ stream.end();
389
+ return stream.response;
390
+ }
391
+ return Response.json(response);
392
+ };
393
+ }
266
394
  //#endregion
267
395
  //#region src/server/transports/HTTPClientTransport.ts
268
396
  /**
@@ -272,7 +400,7 @@ function dispatchLines(emitter, lines) {
272
400
  *
273
401
  * @remarks
274
402
  * - **Request/response over `fetch`.** `send(message)` POSTs the JSON-serialized
275
- * message (or batch) to `options.url` with `content-type: application/json` and an
403
+ * message to `options.url` with `content-type: application/json` and an
276
404
  * `Accept` of BOTH `application/json` and `text/event-stream` (so the server may
277
405
  * answer with either framing) — plus any `options.headers` (e.g. an `Authorization`
278
406
  * bearer). It then decodes the reply and emits each decoded {@link JSONRPCMessage} on
@@ -284,13 +412,18 @@ function dispatchLines(emitter, lines) {
284
412
  * readEventStream}) — the inverse of the server's `openStream` seam, so the wire
285
413
  * round-trips. A `202`
286
414
  * Accepted (a notification) carries no body and emits nothing.
287
- * - **Session echo.** `start()` / `close()` are no-ops (a request/response transport
288
- * holds no long-lived connection). The `mcp-session-id` response header, when a
289
- * STATEFUL server sends one (on `initialize`), is captured into `session` and then
290
- * ECHOED as the `mcp-session-id` request header on every SUBSEQUENT request — so an
291
- * `MCPClient` passes a stateful server's session validation. Before initialize returns
292
- * an id, `session` is `undefined` and no header is sent (safe against a stateless
293
- * server, which neither sends nor expects one).
415
+ * - **Session and protocol echo.** `start()` is a no-op (a
416
+ * request/response transport opens no long-lived connection). The
417
+ * `mcp-session-id` response header, when a STATEFUL server sends one (on
418
+ * `initialize`), is captured into `session` and then ECHOED as the
419
+ * `mcp-session-id` request header on every SUBSEQUENT request so an
420
+ * `MCPClient` passes a stateful server's session validation. The
421
+ * 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.
425
+ * `close()` clears the captured protocol so a reconnect's `initialize`
426
+ * POST is headerless; the captured `session` persists across `close()`.
294
427
  * - **Total at the boundary (§14).** Every reply is narrowed (`parseJSONRPCMessage`,
295
428
  * the SSE decoder) — a non-message reply is dropped, never asserted; a `fetch` /
296
429
  * decode failure surfaces on the `error` event rather than escaping `send`.
@@ -311,6 +444,7 @@ var HTTPClientTransport = class {
311
444
  #fetch;
312
445
  #timeout;
313
446
  #session = void 0;
447
+ #protocol = void 0;
314
448
  constructor(options) {
315
449
  this.#emitter = new Emitter();
316
450
  this.#url = options.url;
@@ -334,6 +468,7 @@ var HTTPClientTransport = class {
334
468
  "content-type": "application/json",
335
469
  accept: "application/json, text/event-stream",
336
470
  ...this.#session === void 0 ? {} : { [MCP_SESSION_HEADER]: this.#session },
471
+ ...this.#protocol === void 0 ? {} : { [MCP_PROTOCOL_VERSION_HEADER]: this.#protocol },
337
472
  ...this.#headers
338
473
  },
339
474
  body: JSON.stringify(message),
@@ -348,6 +483,7 @@ var HTTPClientTransport = class {
348
483
  await this.#deliver(response);
349
484
  }
350
485
  async close() {
486
+ this.#protocol = void 0;
351
487
  this.#emitter.emit("close");
352
488
  }
353
489
  async #deliver(response) {
@@ -355,17 +491,21 @@ var HTTPClientTransport = class {
355
491
  const type = response.headers.get("content-type") ?? "";
356
492
  try {
357
493
  if (type.includes("text/event-stream")) {
358
- for (const message of await readEventStream(response)) this.#emitter.emit("message", message);
494
+ for (const message of await readEventStream(response)) this.#capture(message);
359
495
  return;
360
496
  }
361
497
  if (type.includes("application/json")) {
362
498
  const message = parseJSONRPCMessage(await response.json());
363
- if (message !== void 0) this.#emitter.emit("message", message);
499
+ if (message !== void 0) this.#capture(message);
364
500
  }
365
501
  } catch (error) {
366
502
  this.#emitter.emit("error", error);
367
503
  }
368
504
  }
505
+ #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"];
507
+ this.#emitter.emit("message", message);
508
+ }
369
509
  };
370
510
  //#endregion
371
511
  //#region src/server/MCPSession.ts
@@ -507,8 +647,8 @@ var MCPSession = class {
507
647
  * parsed envelope the {@link import('@src/core').MCPServerInterface} pump dispatches), while
508
648
  * a non-JSON or non-message frame is surfaced on `error` and DROPPED, never thrown (§14). It
509
649
  * also bridges the socket's `close` → this transport's `close`, and the socket's `error`.
510
- * - **Outbound (`send`).** `send(message | messages)` writes ONE text frame per message
511
- * (`nodeWs.send(JSON.stringify(...))`); the underlying wrapper no-ops a write on a
650
+ * - **Outbound (`send`).** `send(message)` writes one text frame
651
+ * (`nodeWs.send(JSON.stringify(message))`); the underlying wrapper no-ops a write on a
512
652
  * non-open socket, so a closed connection drops silently rather than throwing.
513
653
  * - **`close()`** closes the underlying socket (the RFC 6455 close handshake) and fires the
514
654
  * transport's `close` event (idempotent — a second `close`, or a socket-driven close, emits
@@ -538,8 +678,7 @@ var WebSocketServerTransport = class {
538
678
  this.#socket.emitter.on("error", (error) => this.#emitter.emit("error", error));
539
679
  }
540
680
  async send(message) {
541
- const messages = Array.isArray(message) ? message : [message];
542
- for (const one of messages) this.#socket.send(JSON.stringify(one));
681
+ this.#socket.send(JSON.stringify(message));
543
682
  }
544
683
  async close() {
545
684
  if (this.#closed) return;
@@ -590,7 +729,7 @@ var WebSocketServerTransport = class {
590
729
  * event (the reply the {@link import('@src/core').MCPClientInterface} correlates by `id`); a
591
730
  * non-JSON / non-message frame surfaces on `error` and is dropped (§14). The socket's `close`
592
731
  * / `error` bridge to this transport's events.
593
- * - **Outbound (`send`).** `send(message | messages)` writes ONE masked text frame per message.
732
+ * - **Outbound (`send`).** `send(message)` writes one masked text frame.
594
733
  * - **`close()`** closes the underlying socket and fires `close` (idempotent).
595
734
  * - **URL scheme.** `options.url` accepts a `ws://` / `wss://` URL or an `http://` / `https://`
596
735
  * one; a `ws(s)` scheme is converted to `http(s)` for the underlying upgrade request (`wss`
@@ -629,12 +768,6 @@ var WebSocketClientTransport = class {
629
768
  const secure = url.protocol === "https:";
630
769
  const send = secure ? request$1 : request;
631
770
  await new Promise((resolve, reject) => {
632
- let settled = false;
633
- const fail = (error) => {
634
- if (settled) return;
635
- settled = true;
636
- reject(error);
637
- };
638
771
  const request = send({
639
772
  hostname: url.hostname,
640
773
  port: url.port.length > 0 ? Number(url.port) : secure ? 443 : 80,
@@ -652,7 +785,7 @@ var WebSocketClientTransport = class {
652
785
  const accept = response.headers["sec-websocket-accept"];
653
786
  if (!isString(accept) || accept !== computeWebSocketAccept(key)) {
654
787
  socket.destroy();
655
- fail(/* @__PURE__ */ new Error("WebSocket handshake failed: Sec-WebSocket-Accept mismatch"));
788
+ reject(/* @__PURE__ */ new Error("WebSocket handshake failed: Sec-WebSocket-Accept mismatch"));
656
789
  return;
657
790
  }
658
791
  const ws = createNodeWebSocket({
@@ -661,24 +794,20 @@ var WebSocketClientTransport = class {
661
794
  });
662
795
  this.#socket = ws;
663
796
  this.#bind(ws);
664
- if (!settled) {
665
- settled = true;
666
- resolve();
667
- }
797
+ resolve();
668
798
  });
669
799
  request.on("response", (response) => {
670
800
  response.resume();
671
- fail(/* @__PURE__ */ new Error(`WebSocket upgrade declined with status ${response.statusCode ?? 0}`));
801
+ reject(/* @__PURE__ */ new Error(`WebSocket upgrade declined with status ${response.statusCode ?? 0}`));
672
802
  });
673
- request.on("error", (error) => fail(error instanceof Error ? error : new Error(String(error))));
803
+ request.on("error", (error) => reject(error instanceof Error ? error : new Error(String(error))));
674
804
  request.end();
675
805
  });
676
806
  }
677
807
  async send(message) {
678
808
  const socket = this.#socket;
679
809
  if (socket === void 0) throw new Error("WebSocket transport is not connected");
680
- const messages = Array.isArray(message) ? message : [message];
681
- for (const one of messages) socket.send(JSON.stringify(one));
810
+ socket.send(JSON.stringify(message));
682
811
  }
683
812
  async close() {
684
813
  if (this.#closed) return;
@@ -742,8 +871,8 @@ var WebSocketClientTransport = class {
742
871
  * {@link dispatchLines} helper — a well-formed {@link JSONRPCMessage} emits
743
872
  * `message`, a malformed line emits `error` (§14, never throws). The child's
744
873
  * `close` bridges to this transport's `close`.
745
- * - **Outbound (`send`).** `send(message | messages)` writes ONE newline-terminated
746
- * `JSON.stringify`d line per message to the child's `stdin`.
874
+ * - **Outbound (`send`).** `send(message)` writes one newline-terminated
875
+ * `JSON.stringify`d line to the child's `stdin`.
747
876
  * - **`close()`** kills the child process and fires `close` (idempotent).
748
877
  * - **Observable (§13).** Owns the `emitter` ({@link ClientTransportEventMap}); the
749
878
  * emitter isolates a listener throw; `error` is a DOMAIN event (a transport-level
@@ -794,8 +923,7 @@ var StdioClientTransport = class {
794
923
  async send(message) {
795
924
  const child = this.#child;
796
925
  if (child === void 0) throw new Error("stdio transport is not connected");
797
- const messages = Array.isArray(message) ? message : [message];
798
- for (const one of messages) child.stdin.write(`${JSON.stringify(one)}\n`);
926
+ child.stdin.write(`${JSON.stringify(message)}\n`);
799
927
  }
800
928
  async close() {
801
929
  if (this.#closed) return;
@@ -838,8 +966,8 @@ var StdioClientTransport = class {
838
966
  * well-formed {@link JSONRPCMessage} re-emits on `message`, a malformed line
839
967
  * emits `error` (§14, never throws). `input`'s `close` bridges to this
840
968
  * transport's `close`.
841
- * - **Outbound (`send`).** `send(message | messages)` writes ONE newline-terminated
842
- * `JSON.stringify`d line per message to `output`.
969
+ * - **Outbound (`send`).** `send(message)` writes one newline-terminated
970
+ * `JSON.stringify`d line to `output`.
843
971
  * - **`close()`** fires this transport's `close` (idempotent) — the injected streams
844
972
  * are owned by the caller (typically `process.stdin`/`process.stdout`, which must
845
973
  * never be closed out from under the process) and are not torn down here.
@@ -871,8 +999,7 @@ var StdioServerTransport = class {
871
999
  this.#input.on("error", (error) => this.#emitter.emit("error", error));
872
1000
  }
873
1001
  async send(message) {
874
- const messages = Array.isArray(message) ? message : [message];
875
- for (const one of messages) this.#output.write(`${JSON.stringify(one)}\n`);
1002
+ this.#output.write(`${JSON.stringify(message)}\n`);
876
1003
  }
877
1004
  async close() {
878
1005
  if (this.#closed) return;
@@ -907,6 +1034,9 @@ var StdioServerTransport = class {
907
1034
  * - A **transport** failure — a malformed JSON body, or a parsed value that is not a
908
1035
  * JSON-RPC REQUEST — is an HTTP `400` carrying a JSON-RPC error BODY (`-32700` Parse
909
1036
  * 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.
910
1040
  * - A **dispatch** result — a success OR an IN-BAND JSON-RPC error from `mcp.dispatch`
911
1041
  * (e.g. `-32601` method-not-found) — is an HTTP `200` carrying the JSON-RPC response
912
1042
  * envelope (the error is in-band per JSON-RPC, NOT an HTTP error).
@@ -943,37 +1073,11 @@ var StdioServerTransport = class {
943
1073
  * ```
944
1074
  */
945
1075
  function createMCPRoutes(mcp, options) {
946
- const path = options?.path ?? "/mcp";
947
- const streaming = options?.streaming ?? true;
948
1076
  return [{
949
1077
  method: "POST",
950
- path,
1078
+ path: options?.path ?? "/mcp",
951
1079
  name: "mcp",
952
- handler: async (request) => {
953
- let text;
954
- try {
955
- text = await request.text();
956
- } catch {
957
- return Response.json(jsonRPCError(null, JSONRPC_PARSE_ERROR, "Parse error"), { status: 400 });
958
- }
959
- let parsed;
960
- try {
961
- parsed = JSON.parse(text);
962
- } catch {
963
- return Response.json(jsonRPCError(null, JSONRPC_PARSE_ERROR, "Parse error"), { status: 400 });
964
- }
965
- const rpcRequest = parseJSONRPCMessage(parsed);
966
- if (rpcRequest === void 0 || !("method" in rpcRequest)) return Response.json(jsonRPCError(null, JSONRPC_INVALID_REQUEST, "Invalid Request"), { status: 400 });
967
- const response = await mcp.dispatch(rpcRequest);
968
- if (response === void 0) return new Response(null, { status: 202 });
969
- if (streaming && acceptsEventStream(request)) {
970
- const s = openStream();
971
- s.write({ data: JSON.stringify(response) });
972
- s.end();
973
- return s.response;
974
- }
975
- return Response.json(response);
976
- }
1080
+ handler: createMCPPostHandler(mcp, options?.streaming ?? true)
977
1081
  }];
978
1082
  }
979
1083
  /**
@@ -989,8 +1093,10 @@ function createMCPRoutes(mcp, options) {
989
1093
  * and the reply is surfaced on the transport's `message` event for the client's id
990
1094
  * correlation. Add `options.headers` (e.g. an `Authorization` bearer) to reach a guarded
991
1095
  * server. `start` / `close` hold no connection; against a STATEFUL server it captures the
992
- * `mcp-session-id` from `initialize` and echoes it on later requests, so the same
993
- * `MCPClient` passes session validation (a stateless server sends none).
1096
+ * `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.
994
1100
  *
995
1101
  * @param options - `url` (the remote endpoint; REQUIRED), optional `headers` merged onto
996
1102
  * every request, optional `fetch` (default `globalThis.fetch`), and optional `timeout`
@@ -1030,12 +1136,13 @@ function createHTTPClientTransport(options) {
1030
1136
  * - **Claims (returns `true`)** otherwise: it builds `createNodeWebSocket({ socket, key, head,
1031
1137
  * protocol })` (SERVER mode → writes the `101` handshake, echoing the `subprotocol`, default
1032
1138
  * {@link MCP_WEBSOCKET_SUBPROTOCOL} `'mcp'`, and sends UNMASKED frames), wraps it in a
1033
- * {@link WebSocketServerTransport}, and PUMPS: each inbound {@link
1034
- * import('@src/core').JSONRPCMessage} that is a REQUEST runs through `mcp.dispatch`, and a
1035
- * defined response is written back as a frame — a NOTIFICATION (`dispatch` → `undefined`)
1036
- * sends nothing. A non-request message (a stray response) is ignored. The dispatch is
1037
- * guarded so a `dispatch` / `send` fault surfaces on the transport's `error` event rather
1038
- * than escaping the (async) message listener.
1139
+ * {@link WebSocketServerTransport}, and pipes it through the core {@link
1140
+ * import('@src/core').MCPTransportInterface} port via {@link
1141
+ * import('./helpers.js').bridgeMessageTransport} + {@link import('@src/core').bindServer}:
1142
+ * each inbound REQUEST runs through `mcp.dispatch`, and a defined response is written back
1143
+ * as a frame — a NOTIFICATION sends nothing, and a non-request message (a stray response) is
1144
+ * ignored. A `dispatch` / `send` fault surfaces on `mcp.emitter`'s `error` event rather than
1145
+ * escaping the (async) message pump.
1039
1146
  *
1040
1147
  * It is MECHANISM, not policy: compose an auth guard IN FRONT by registering an upgrade
1041
1148
  * handler BEFORE this one — that handler can claim (decline + destroy) an unauthenticated
@@ -1072,19 +1179,7 @@ function createWebSocketServer(mcp, options) {
1072
1179
  head,
1073
1180
  protocol: subprotocol
1074
1181
  }));
1075
- transport.emitter.on("message", (message) => {
1076
- if (!isJSONRPCRequest(message)) return;
1077
- (async () => {
1078
- try {
1079
- const response = await mcp.dispatch(message);
1080
- if (response !== void 0) await transport.send(response);
1081
- } catch (error) {
1082
- try {
1083
- transport.emitter.emit("error", error);
1084
- } catch {}
1085
- }
1086
- })();
1087
- });
1182
+ bindServer(mcp, bridgeMessageTransport(transport));
1088
1183
  transport.start();
1089
1184
  return true;
1090
1185
  };
@@ -1166,12 +1261,13 @@ function createStdioClientTransport(options) {
1166
1261
  * @remarks
1167
1262
  * Wraps `options.input` (default `process.stdin`) / `options.output` (default
1168
1263
  * `process.stdout`) in a {@link import('./transports/StdioServerTransport.js').StdioServerTransport}
1169
- * and PUMPS: each inbound {@link import('@src/core').JSONRPCMessage} that is a
1170
- * REQUEST runs through `mcp.dispatch`, and a defined response is written back as a
1171
- * newline-terminated line a NOTIFICATION (`dispatch` → `undefined`) writes
1172
- * nothing. A non-request message is ignored. The dispatch is guarded so a
1173
- * `dispatch` / `send` fault surfaces on the transport's `error` event rather than
1174
- * escaping the (async) message listener.
1264
+ * and pipes it through the core {@link import('@src/core').MCPTransportInterface} port
1265
+ * via {@link import('./helpers.js').bridgeMessageTransport} + {@link
1266
+ * import('@src/core').bindServer}: each inbound REQUEST runs through `mcp.dispatch`, and
1267
+ * a defined response is written back as a newline-terminated line a NOTIFICATION
1268
+ * writes nothing, and a non-request message is ignored. A `dispatch` / `send` fault
1269
+ * surfaces on `mcp.emitter`'s `error` event rather than escaping the (async) message
1270
+ * pump.
1175
1271
  *
1176
1272
  * @param mcp - The transport-agnostic {@link MCPServerInterface} to expose over stdio
1177
1273
  * @param options - Optional injectable `input` / `output` streams; see
@@ -1189,19 +1285,7 @@ function createStdioClientTransport(options) {
1189
1285
  */
1190
1286
  function createStdioServer(mcp, options) {
1191
1287
  const transport = new StdioServerTransport(options?.input ?? process.stdin, options?.output ?? process.stdout);
1192
- transport.emitter.on("message", (message) => {
1193
- if (!isJSONRPCRequest(message)) return;
1194
- (async () => {
1195
- try {
1196
- const response = await mcp.dispatch(message);
1197
- if (response !== void 0) await transport.send(response);
1198
- } catch (error) {
1199
- try {
1200
- transport.emitter.emit("error", error);
1201
- } catch {}
1202
- }
1203
- })();
1204
- });
1288
+ bindServer(mcp, bridgeMessageTransport(transport));
1205
1289
  return {
1206
1290
  start() {
1207
1291
  transport.start();
@@ -1275,30 +1359,43 @@ function createMCPSession(options) {
1275
1359
  const store = /* @__PURE__ */ new Map();
1276
1360
  return async (request, context, next) => {
1277
1361
  if (context.url.pathname !== path) return next();
1278
- sweep();
1362
+ if (ttl !== void 0) {
1363
+ const cutoff = clock() - ttl;
1364
+ for (const [id, entry] of store) if (entry.touched <= cutoff) store.delete(id);
1365
+ }
1366
+ if (context.method === "DELETE") {
1367
+ const id = readSessionHeader(request);
1368
+ if (id === void 0 || !store.delete(id)) return rejectUnknownSession();
1369
+ return new Response(null, { status: 204 });
1370
+ }
1371
+ let entry;
1372
+ const id = readSessionHeader(request);
1373
+ if (id !== void 0) {
1374
+ const current = store.get(id);
1375
+ if (current !== void 0) {
1376
+ entry = {
1377
+ session: current.session,
1378
+ touched: clock()
1379
+ };
1380
+ store.set(id, entry);
1381
+ }
1382
+ }
1279
1383
  if (context.method === "GET") {
1280
- const entry = resolve(request);
1281
1384
  if (entry === void 0) return rejectUnknownSession();
1385
+ const session = entry.session;
1282
1386
  const stream = openStream();
1283
1387
  stream.comment("open");
1284
1388
  const lastEventId = readLastEventId(request);
1285
- if (lastEventId !== void 0) for (const e of entry.session.replay(lastEventId)) stream.write({
1389
+ if (lastEventId !== void 0) for (const e of session.replay(lastEventId)) stream.write({
1286
1390
  id: e.id,
1287
1391
  data: JSON.stringify(e.message)
1288
1392
  });
1289
- entry.session.attach(stream);
1290
- if (request.signal.aborted) entry.session.detach(stream);
1291
- else request.signal.addEventListener("abort", () => entry.session.detach(stream), { once: true });
1393
+ session.attach(stream);
1394
+ if (request.signal.aborted) session.detach(stream);
1395
+ else request.signal.addEventListener("abort", () => session.detach(stream), { once: true });
1292
1396
  return stream.response;
1293
1397
  }
1294
- if (context.method === "DELETE") {
1295
- const id = readSessionHeader(request);
1296
- if (id === void 0 || !store.has(id)) return rejectUnknownSession();
1297
- store.delete(id);
1298
- return new Response(null, { status: 204 });
1299
- }
1300
1398
  const text = await request.text();
1301
- let entry = resolve(request);
1302
1399
  if (entry === void 0) {
1303
1400
  let parsed;
1304
1401
  try {
@@ -1307,7 +1404,7 @@ function createMCPSession(options) {
1307
1404
  parsed = void 0;
1308
1405
  }
1309
1406
  if (parsed !== void 0 && isInitializeRequest(parsed)) {
1310
- const session = new MCPSession(crypto.randomUUID(), { capacity });
1407
+ const session = new MCPSession(crypto.randomUUID(), capacity !== void 0 ? { capacity } : {});
1311
1408
  entry = {
1312
1409
  session,
1313
1410
  touched: clock()
@@ -1315,7 +1412,7 @@ function createMCPSession(options) {
1315
1412
  store.set(session.id, entry);
1316
1413
  } else return rejectUnknownSession();
1317
1414
  }
1318
- context.state.session = entry.session;
1415
+ if (!Reflect.set(context.state, "session", entry.session)) throw new Error("MCP session state is not writable");
1319
1416
  const response = await next(new Request(context.url, {
1320
1417
  method: "POST",
1321
1418
  headers: request.headers,
@@ -1324,21 +1421,8 @@ function createMCPSession(options) {
1324
1421
  response.headers.set(MCP_SESSION_HEADER, entry.session.id);
1325
1422
  return response;
1326
1423
  };
1327
- function resolve(request) {
1328
- const id = readSessionHeader(request);
1329
- if (id === void 0) return void 0;
1330
- const entry = store.get(id);
1331
- if (entry === void 0) return void 0;
1332
- entry.touched = clock();
1333
- return entry;
1334
- }
1335
- function sweep() {
1336
- if (ttl === void 0) return;
1337
- const cutoff = clock() - ttl;
1338
- for (const [id, entry] of store) if (entry.touched <= cutoff) store.delete(id);
1339
- }
1340
1424
  }
1341
1425
  //#endregion
1342
- 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, createHTTPClientTransport, createMCPRoutes, createMCPSession, createStdioClientTransport, createStdioServer, createWebSocketClientTransport, createWebSocketServer, decodeEvent, dispatchLines, extractLines, readEventStream, readLastEventId, readSessionHeader, rejectUnknownSession, upgradeRequestPath };
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 };
1343
1427
 
1344
1428
  //# sourceMappingURL=index.js.map