@orkestrel/mcp 0.0.1

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.
@@ -0,0 +1,1372 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _orkestrel_sse = require("@orkestrel/sse");
3
+ let _src_core = require("../core/index.cjs");
4
+ let _orkestrel_contract = require("@orkestrel/contract");
5
+ let _orkestrel_emitter = require("@orkestrel/emitter");
6
+ let node_crypto = require("node:crypto");
7
+ let node_http = require("node:http");
8
+ let node_https = require("node:https");
9
+ let _orkestrel_websocket = require("@orkestrel/websocket");
10
+ let node_child_process = require("node:child_process");
11
+ let _orkestrel_server = require("@orkestrel/server");
12
+ //#region src/server/constants.ts
13
+ /**
14
+ * The Streamable-HTTP transport header that carries the MCP session id. When a {@link
15
+ * import('./middlewares.js').createMCPSession} middleware is mounted, it SETS this header on
16
+ * the `initialize` response (the minted id) and READS it on every subsequent request
17
+ * (validating the session); the stateless `createMCPRoutes` default neither sets nor reads it.
18
+ */
19
+ var MCP_SESSION_HEADER = "mcp-session-id";
20
+ /**
21
+ * The Streamable-HTTP transport header that carries the negotiated MCP protocol version
22
+ * on a subsequent request. The version is negotiated in the `initialize` JSON-RPC result
23
+ * body; a stateful transport MAY additionally read this header to pin the per-request
24
+ * protocol version (optional — the result body remains the source of truth).
25
+ */
26
+ var MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version";
27
+ /** The default request path `createMCPRoutes` mounts the transport's `POST` route at. */
28
+ var DEFAULT_MCP_PATH = "/mcp";
29
+ /**
30
+ * The WebSocket subprotocol the MCP-over-WebSocket transports negotiate — sent by the
31
+ * client in `Sec-WebSocket-Protocol`, echoed by the server in its `101` handshake.
32
+ *
33
+ * @remarks
34
+ * `createWebSocketServer` echoes it in the upgrade response and `createWebSocketClientTransport`
35
+ * requests it, so an MCP WebSocket endpoint is distinguishable from any other WebSocket on the
36
+ * same path. The default WebSocket upgrade path is {@link DEFAULT_MCP_PATH} (the same `'/mcp'`
37
+ * the HTTP transport mounts at) — the upgrade is selected by the `Upgrade: websocket` header,
38
+ * not a separate path.
39
+ */
40
+ var MCP_WEBSOCKET_SUBPROTOCOL = "mcp";
41
+ /**
42
+ * The default capacity of a session's FOLDED resumable event log (the per-{@link
43
+ * import('./MCPSession.js').MCPSession} replay log) — the maximum number of pushed
44
+ * server→client messages retained for replay before the OLDEST is evicted.
45
+ *
46
+ * @remarks
47
+ * Bounds the replay log's memory: only the most-recent {@link DEFAULT_MCP_SESSION_CAPACITY}
48
+ * pushes are retained, so a client reconnecting with a `Last-Event-ID` older than that window
49
+ * replays nothing (its cursor fell off the back). Override per `createMCPSession`'s `capacity`
50
+ * for a deeper / shallower window.
51
+ */
52
+ var DEFAULT_MCP_SESSION_CAPACITY = 1024;
53
+ /**
54
+ * The default per-event idle lifetime (ms) of a session's folded resumable event log — an
55
+ * entry older than this is lazily evicted on the next access (no background timer), bounding
56
+ * how far back a reconnecting client may replay.
57
+ *
58
+ * @remarks
59
+ * Five minutes — a generous reconnection window for a dropped SSE stream without retaining
60
+ * stale pushes indefinitely. The session's own idle TTL is the `createMCPSession` `ttl` knob;
61
+ * this bounds the replay log paired with it.
62
+ */
63
+ var DEFAULT_MCP_SESSION_TTL = 3e5;
64
+ //#endregion
65
+ //#region src/server/helpers.ts
66
+ /**
67
+ * Whether the request's `Accept` header opts into a Server-Sent-Events response.
68
+ *
69
+ * @remarks
70
+ * Reads the fetch-standard `Request.headers.get('accept')` and returns `true` when it
71
+ * contains `text/event-stream` (case-insensitive). The MCP `POST` handler uses it
72
+ * (together with the `streaming` option) to pick the Streamable-HTTP SSE response
73
+ * framing over a plain JSON body; the JSON-RPC envelope is identical either way. Total
74
+ * — an absent / unmatched header returns `false`.
75
+ *
76
+ * @param request - The fetch-standard `Request`
77
+ * @returns `true` when the client `Accept`s `text/event-stream`, else `false`
78
+ */
79
+ function acceptsEventStream(request) {
80
+ const accept = request.headers.get("accept");
81
+ if (accept === null) return false;
82
+ return accept.toLowerCase().includes("text/event-stream");
83
+ }
84
+ /**
85
+ * Read the request's `mcp-session-id` header — the session id a stateful transport
86
+ * validates, or `undefined` when absent.
87
+ *
88
+ * @remarks
89
+ * Reads `request.headers.get(MCP_SESSION_HEADER)` — a fetch-standard `Headers` lookup
90
+ * (single-valued by construction, never an array) — so a missing header reads as
91
+ * `undefined` (no session). {@link import('./middlewares.js').createMCPSession} uses it on
92
+ * every `POST` / `GET` / `DELETE` to look the session up in its closure store; an
93
+ * `undefined` id is treated exactly like an unknown one (a `404`). Total — never throws.
94
+ *
95
+ * @param request - The fetch-standard `Request`
96
+ * @returns The session id, or `undefined` when the header is absent
97
+ */
98
+ function readSessionHeader(request) {
99
+ const id = request.headers.get(MCP_SESSION_HEADER);
100
+ return id === null ? void 0 : id;
101
+ }
102
+ /**
103
+ * Read the request's `Last-Event-ID` header — the SSE resume cursor a client sends when it
104
+ * reconnects to the resumable `GET {path}` stream, or `undefined` when absent.
105
+ *
106
+ * @remarks
107
+ * Reads `request.headers.get('last-event-id')` — a fetch-standard `Headers` lookup — so a
108
+ * missing header reads as `undefined` (no resume, the stream starts fresh). The resumable
109
+ * `GET` handler in {@link import('./middlewares.js').createMCPSession} passes a present value
110
+ * to the session's {@link import('./types.js').MCPSessionInterface.replay} to re-deliver the
111
+ * missed events before attaching the stream for live pushes. Total — never throws.
112
+ *
113
+ * @param request - The fetch-standard `Request`
114
+ * @returns The last-event-id, or `undefined` when the header is absent
115
+ */
116
+ function readLastEventId(request) {
117
+ const id = request.headers.get("last-event-id");
118
+ return id === null ? void 0 : id;
119
+ }
120
+ /**
121
+ * Build the stateful transport's "unknown session" rejection — an HTTP `404` carrying a
122
+ * JSON-RPC error body.
123
+ *
124
+ * @remarks
125
+ * Returns `Response.json(jsonRPCError(null, JSONRPC_INVALID_REQUEST, 'Session not found'),
126
+ * { status: 404 })`, mirroring `createMCPRoutes`'s `400` transport-failure shape (a
127
+ * JSON-RPC error BODY with a `null` id) but at the session-not-found status. Shared by
128
+ * every {@link import('./middlewares.js').createMCPSession} validation site — the
129
+ * non-`initialize` `POST` path, the resumable `GET {path}` open, and the `DELETE {path}`
130
+ * session-end (each a missing / unknown / TTL-evicted id) — so the single `404` envelope
131
+ * is defined once. Total — never throws.
132
+ *
133
+ * @returns The `404` JSON-RPC error `Response`
134
+ */
135
+ function rejectUnknownSession() {
136
+ return Response.json((0, _src_core.jsonRPCError)(null, _src_core.JSONRPC_INVALID_REQUEST, "Session not found"), { status: 404 });
137
+ }
138
+ /**
139
+ * Decode a `fetch` Response's Server-Sent-Events body into the JSON-RPC messages it
140
+ * carried — the CLIENT-side inverse of the server's Streamable-HTTP SSE response.
141
+ *
142
+ * @remarks
143
+ * Reads the whole `response.body` stream chunk-by-chunk through a `TextDecoder({
144
+ * stream: true })` (handling a multi-byte char split across reads) and `@orkestrel/sse`'s
145
+ * {@link SSEParserInterface} (handling a partial line / in-progress event split across
146
+ * reads), then narrows each dispatched event's `data` to a {@link JSONRPCMessage} via
147
+ * `parseJSONRPCMessage` (so a non-message / non-JSON `data:` event is DROPPED, never
148
+ * thrown — total, §14). It reuses the SAME `SSEParser` the server's `openStream` seam
149
+ * serializes against, so the wire round-trips. A `null` body (no stream) yields no
150
+ * messages; the {@link import('./transports/HTTPClientTransport.js').HTTPClientTransport}
151
+ * reads a request/response SSE reply (the server sends one `data:` event then ends), so
152
+ * this drains to completion.
153
+ *
154
+ * @param response - The SSE `fetch` Response to decode (its `body` is read to completion)
155
+ * @returns Every {@link JSONRPCMessage} the stream carried, in order
156
+ */
157
+ async function readEventStream(response) {
158
+ const body = response.body;
159
+ if (body === null) return [];
160
+ const reader = body.getReader();
161
+ const decoder = new TextDecoder();
162
+ const parser = (0, _orkestrel_sse.createSSEParser)();
163
+ const messages = [];
164
+ try {
165
+ for (;;) {
166
+ const { done, value } = await reader.read();
167
+ if (done) break;
168
+ for (const event of parser.parse(decoder.decode(value, { stream: true }))) {
169
+ const message = decodeEvent(event.data);
170
+ if (message !== void 0) messages.push(message);
171
+ }
172
+ }
173
+ } finally {
174
+ reader.releaseLock();
175
+ }
176
+ return messages;
177
+ }
178
+ /**
179
+ * Decode one SSE event's `data` string into a {@link JSONRPCMessage}, or `undefined`
180
+ * when it is not one — the per-event step {@link readEventStream} folds over.
181
+ *
182
+ * @remarks
183
+ * `JSON.parse`s the `data` (the server serializes the JSON-RPC envelope as the event's
184
+ * `data`) inside a try/catch and narrows the parsed value with `parseJSONRPCMessage`.
185
+ * Total (§14): malformed JSON or a non-message value yields `undefined`, never throws.
186
+ *
187
+ * @param data - One SSE event's `data` payload
188
+ * @returns The decoded {@link JSONRPCMessage}, or `undefined`
189
+ */
190
+ function decodeEvent(data) {
191
+ try {
192
+ return (0, _src_core.parseJSONRPCMessage)(JSON.parse(data));
193
+ } catch {
194
+ return;
195
+ }
196
+ }
197
+ /**
198
+ * Read the path (without the query string) of a raw `node:http` protocol-upgrade request —
199
+ * the `createWebSocketServer` upgrade-path match.
200
+ *
201
+ * @remarks
202
+ * A `node:http` {@link import('node:http').IncomingMessage}'s `url` is the request TARGET
203
+ * (`'/mcp?x=1'`), narrowed with `isString` (§14, never `as`) and defaulting to `'/'` for an
204
+ * absent target; it is parsed against a dummy base (only the pathname matters for the upgrade
205
+ * decision) and the `pathname` returned. The upgrade handler compares this against its
206
+ * configured `path` to decide whether to claim the socket. Total — never throws on an
207
+ * adversarial / absent target.
208
+ *
209
+ * @param request - The raw upgrade {@link import('node:http').IncomingMessage}
210
+ * @returns The request's path (the `pathname`, no query), or `'/'` when the target is absent
211
+ */
212
+ function upgradeRequestPath(request) {
213
+ const target = (0, _orkestrel_contract.isString)(request.url) ? request.url : "/";
214
+ return new URL(target, "http://localhost").pathname;
215
+ }
216
+ /**
217
+ * Fold one more chunk of raw stdio bytes into a newline-framed buffer — the shared
218
+ * line-framing step both stdio transports (client and server) read their inbound
219
+ * newline-delimited JSON-RPC messages through.
220
+ *
221
+ * @remarks
222
+ * Concatenates `buffer` (the carried-forward partial line from the previous call)
223
+ * with `chunk`, splits on `'\n'`, and returns every COMPLETE line (a `'\r'` trailing
224
+ * a line, from a CRLF-framed peer, is trimmed) plus the final, possibly-empty
225
+ * fragment as the new `remainder` — the caller threads it back in as the next call's
226
+ * `buffer`. A chunk containing no `'\n'` yields no lines and the whole (buffer +
227
+ * chunk) as `remainder`. Pure — no I/O, no instance state.
228
+ *
229
+ * @param buffer - The partial line carried forward from the previous chunk (`''` initially)
230
+ * @param chunk - The newly-read raw bytes (already decoded to a string)
231
+ * @returns The complete `lines` extracted (in order) and the trailing `remainder`
232
+ */
233
+ function extractLines(buffer, chunk) {
234
+ const parts = (buffer + chunk).split("\n");
235
+ const remainder = parts[parts.length - 1] ?? "";
236
+ return {
237
+ lines: parts.slice(0, -1).map((line) => line.endsWith("\r") ? line.slice(0, -1) : line),
238
+ remainder
239
+ };
240
+ }
241
+ /**
242
+ * Decode and deliver each complete newline-framed line onto a {@link
243
+ * ClientTransportEventMap} emitter — the shared per-chunk dispatch step both stdio
244
+ * transports (client and server) run their {@link extractLines} output through.
245
+ *
246
+ * @remarks
247
+ * A blank line is skipped (a stray trailing newline). Every other line is decoded
248
+ * with {@link decodeEvent} (`JSON.parse` + `parseJSONRPCMessage`, guarded); a
249
+ * well-formed {@link JSONRPCMessage} emits `message`, a malformed / non-message line
250
+ * emits `error` (§14 — total, never throws). Pure w.r.t. its own state — the emit is
251
+ * the caller-owned side effect.
252
+ *
253
+ * @param emitter - The transport's {@link EmitterInterface} to emit `message` / `error` onto
254
+ * @param lines - The complete lines (from {@link extractLines}) to decode and deliver
255
+ */
256
+ function dispatchLines(emitter, lines) {
257
+ for (const line of lines) {
258
+ if (line.length === 0) continue;
259
+ const message = decodeEvent(line);
260
+ if (message === void 0) {
261
+ emitter.emit("error", /* @__PURE__ */ new Error("non-JSON-RPC stdio line"));
262
+ continue;
263
+ }
264
+ emitter.emit("message", message);
265
+ }
266
+ }
267
+ //#endregion
268
+ //#region src/server/transports/HTTPClientTransport.ts
269
+ /**
270
+ * The HTTP CLIENT transport for the Model Context Protocol — a
271
+ * {@link ClientTransportInterface} that drives a REMOTE Streamable-HTTP MCP server over
272
+ * `fetch`, the egress mirror of the server's `createMCPRoutes`.
273
+ *
274
+ * @remarks
275
+ * - **Request/response over `fetch`.** `send(message)` POSTs the JSON-serialized
276
+ * message (or batch) to `options.url` with `content-type: application/json` and an
277
+ * `Accept` of BOTH `application/json` and `text/event-stream` (so the server may
278
+ * answer with either framing) — plus any `options.headers` (e.g. an `Authorization`
279
+ * bearer). It then decodes the reply and emits each decoded {@link JSONRPCMessage} on
280
+ * the `message` event the {@link import('@src/core').MCPClientInterface} subscribes
281
+ * to.
282
+ * - **Both reply framings.** A `200` with an `application/json` body is parsed with
283
+ * `parseJSONRPCMessage`; a `200` with a `text/event-stream` body is decoded via the
284
+ * `@orkestrel/sse` {@link import('@orkestrel/sse').SSEParserInterface} ({@link
285
+ * readEventStream}) — the inverse of the server's `openStream` seam, so the wire
286
+ * round-trips. A `202`
287
+ * Accepted (a notification) carries no body and emits nothing.
288
+ * - **Session echo.** `start()` / `close()` are no-ops (a request/response transport
289
+ * holds no long-lived connection). The `mcp-session-id` response header, when a
290
+ * STATEFUL server sends one (on `initialize`), is captured into `session` and then
291
+ * ECHOED as the `mcp-session-id` request header on every SUBSEQUENT request — so an
292
+ * `MCPClient` passes a stateful server's session validation. Before initialize returns
293
+ * an id, `session` is `undefined` and no header is sent (safe against a stateless
294
+ * server, which neither sends nor expects one).
295
+ * - **Total at the boundary (§14).** Every reply is narrowed (`parseJSONRPCMessage`,
296
+ * the SSE decoder) — a non-message reply is dropped, never asserted; a `fetch` /
297
+ * decode failure surfaces on the `error` event rather than escaping `send`.
298
+ * - **Observable (§13).** Owns the `emitter` ({@link ClientTransportEventMap}); fires
299
+ * `message` per decoded reply, `error` on a fault, and `close` on `close()`.
300
+ *
301
+ * @example
302
+ * ```ts
303
+ * const transport = new HTTPClientTransport({ url: 'http://localhost:3000/mcp' })
304
+ * const client = new MCPClient({ transport })
305
+ * await client.connect()
306
+ * ```
307
+ */
308
+ var HTTPClientTransport = class {
309
+ #emitter;
310
+ #url;
311
+ #headers;
312
+ #fetch;
313
+ #timeout;
314
+ #session = void 0;
315
+ constructor(options) {
316
+ this.#emitter = new _orkestrel_emitter.Emitter();
317
+ this.#url = options.url;
318
+ this.#headers = options.headers ?? {};
319
+ this.#fetch = options.fetch ?? globalThis.fetch;
320
+ this.#timeout = options.timeout;
321
+ }
322
+ get emitter() {
323
+ return this.#emitter;
324
+ }
325
+ get session() {
326
+ return this.#session;
327
+ }
328
+ async start() {}
329
+ async send(message) {
330
+ let response;
331
+ try {
332
+ response = await this.#fetch(this.#url, {
333
+ method: "POST",
334
+ headers: {
335
+ "content-type": "application/json",
336
+ accept: "application/json, text/event-stream",
337
+ ...this.#session === void 0 ? {} : { [MCP_SESSION_HEADER]: this.#session },
338
+ ...this.#headers
339
+ },
340
+ body: JSON.stringify(message),
341
+ ...this.#timeout === void 0 ? {} : { signal: AbortSignal.timeout(this.#timeout) }
342
+ });
343
+ } catch (error) {
344
+ this.#emitter.emit("error", error);
345
+ return;
346
+ }
347
+ const session = response.headers.get(MCP_SESSION_HEADER);
348
+ if (session !== null) this.#session = session;
349
+ await this.#deliver(response);
350
+ }
351
+ async close() {
352
+ this.#emitter.emit("close");
353
+ }
354
+ async #deliver(response) {
355
+ if (response.status === 202) return;
356
+ const type = response.headers.get("content-type") ?? "";
357
+ try {
358
+ if (type.includes("text/event-stream")) {
359
+ for (const message of await readEventStream(response)) this.#emitter.emit("message", message);
360
+ return;
361
+ }
362
+ if (type.includes("application/json")) {
363
+ const message = (0, _src_core.parseJSONRPCMessage)(await response.json());
364
+ if (message !== void 0) this.#emitter.emit("message", message);
365
+ }
366
+ } catch (error) {
367
+ this.#emitter.emit("error", error);
368
+ }
369
+ }
370
+ };
371
+ //#endregion
372
+ //#region src/server/MCPSession.ts
373
+ /**
374
+ * One MCP transport session — the per-session entity a {@link
375
+ * import('./middlewares.js').createMCPSession} middleware owns, keyed by its `id`, carrying the
376
+ * resumable server→client push channel with its bounded replay log FOLDED IN.
377
+ *
378
+ * @remarks
379
+ * The single session entity (the old `SessionState` + `EventStore` merged): it holds the
380
+ * session `id`, its OWN bounded, replayable log of pushed server→client messages (the
381
+ * resumable GET-SSE channel — a private `#events` `Map` + a monotone `#counter`, with
382
+ * `capacity` / `ttl` eviction, NOT a separate store), and the set of currently OPEN
383
+ * server→client SSE streams (a resumable `GET {path}` registers via `attach`, unregisters via
384
+ * `detach` on disconnect). Still a small entity (not a record), built minimal + extensible.
385
+ *
386
+ * - **`push` is the server-initiated primitive.** It APPENDS the message to the log (assigning
387
+ * a monotone base36 event id) and FANS it out to every attached stream as one `id:`-tagged
388
+ * SSE event (`stream.write({ id, data })`). A push with NO attached stream is still logged,
389
+ * so a client that connects (or reconnects with a `Last-Event-ID`) LATER replays it from the
390
+ * log. A `write` to a closed stream is a safe no-op (the {@link
391
+ * `@orkestrel/server`'s `openStream` contract), so a just-disconnected stream that
392
+ * has not yet been `detach`ed never throws. A replayed event and the live one carry the
393
+ * IDENTICAL id (the log assigns it once).
394
+ *
395
+ * - **`replay(afterId)` is strictly-after.** It returns every retained log entry whose id sorts
396
+ * AFTER `afterId` in append order — the missed-events list the `GET {path}` handler writes
397
+ * before attaching the stream for live pushes. The decision for an UNKNOWN / already-evicted
398
+ * `afterId` (the client's cursor fell off the back of the capacity window, or never existed):
399
+ * replay NOTHING. Replaying the whole retained log would re-deliver events the client never
400
+ * lost (its cursor is OLDER than everything retained); returning `[]` lets the handler then
401
+ * stream only the fresh pushes that follow `attach` — the spec-sane resume.
402
+ *
403
+ * - **Bounded, append-ordered, plain `Map` (§21).** The log lives in ONE insertion-ordered
404
+ * `Map<id, entry>` — insertion order IS append order IS id order, so `replay` and capacity
405
+ * eviction both walk the map directly. NO database mirror — the log is process-local
406
+ * transport mechanics, not durable state. `push` first drops every entry older than `ttl`
407
+ * (lazy TTL — no background timer, the middleware's lazy-window idiom), appends, then evicts
408
+ * the OLDEST entries until at most `capacity` remain; `replay` also runs the lazy TTL sweep
409
+ * first, so a stale entry is never replayed.
410
+ *
411
+ * - **No transport coupling beyond the SSE seam.** It holds session state + the generic {@link
412
+ * StreamInterface} handles `attach` was handed — never a raw socket, request, or response.
413
+ * The middleware opens the stream (the spine seam) and registers it here; this class only
414
+ * serializes a message onto the already-open streams.
415
+ *
416
+ * - **Injected clock.** `push` / `replay` accept an optional `now` (epoch ms), defaulting to
417
+ * `Date.now()` — so a test drives TTL eviction with an elapsed clock rather than a real timer
418
+ * (AGENTS §16).
419
+ *
420
+ * @example
421
+ * ```ts
422
+ * const session = new MCPSession(crypto.randomUUID())
423
+ * session.attach(stream) // an open resumable GET-SSE stream
424
+ * session.push({ jsonrpc: '2.0', method: 'notifications/message', params: { text: 'hi' } })
425
+ * // → logged AND written to `stream` as an `id:`-tagged event; a reconnect replays it
426
+ * ```
427
+ */
428
+ var MCPSession = class {
429
+ #id;
430
+ #events = /* @__PURE__ */ new Map();
431
+ #streams = /* @__PURE__ */ new Set();
432
+ #capacity;
433
+ #ttl;
434
+ #counter = 0;
435
+ constructor(id, options) {
436
+ this.#id = id;
437
+ this.#capacity = options?.capacity ?? 1024;
438
+ this.#ttl = options?.ttl ?? 3e5;
439
+ }
440
+ get id() {
441
+ return this.#id;
442
+ }
443
+ attach(stream) {
444
+ this.#streams.add(stream);
445
+ }
446
+ detach(stream) {
447
+ this.#streams.delete(stream);
448
+ }
449
+ push(message, now = Date.now()) {
450
+ const id = this.#append(message, now);
451
+ const data = JSON.stringify(message);
452
+ for (const stream of this.#streams) stream.write({
453
+ id,
454
+ data
455
+ });
456
+ return id;
457
+ }
458
+ replay(afterId, now = Date.now()) {
459
+ this.#evict(now);
460
+ const out = [];
461
+ let found = false;
462
+ for (const entry of this.#events.values()) if (found) out.push(entry);
463
+ else if (entry.id === afterId) found = true;
464
+ return found ? out : [];
465
+ }
466
+ #append(message, now) {
467
+ this.#evict(now);
468
+ this.#counter += 1;
469
+ const id = this.#counter.toString(36);
470
+ this.#events.set(id, {
471
+ id,
472
+ message,
473
+ timestamp: now
474
+ });
475
+ while (this.#events.size > this.#capacity) {
476
+ const oldest = this.#events.keys().next().value;
477
+ if (oldest === void 0) break;
478
+ this.#events.delete(oldest);
479
+ }
480
+ return id;
481
+ }
482
+ #evict(now) {
483
+ if (this.#ttl <= 0) return;
484
+ const cutoff = now - this.#ttl;
485
+ for (const [id, entry] of this.#events) if (entry.timestamp <= cutoff) this.#events.delete(id);
486
+ else break;
487
+ }
488
+ };
489
+ //#endregion
490
+ //#region src/server/transports/WebSocketServerTransport.ts
491
+ /**
492
+ * The per-connection JSON-RPC-over-WebSocket SERVER bridge — wraps a
493
+ * {@link NodeWebSocketInterface} (the RFC 6455 wire wrapper) as a
494
+ * {@link ClientTransportInterface}, the bidirectional JSON-RPC message channel
495
+ * `createWebSocketServer` pumps `mcp.dispatch` over and the egress mirror's
496
+ * {@link import('./WebSocketClientTransport.js').WebSocketClientTransport} reuses.
497
+ *
498
+ * @remarks
499
+ * - **Reuses `ClientTransportInterface` (§21).** It IS the same generic carrier the HTTP
500
+ * client transport implements — `emitter` (`message` / `close` / `error`), `start`,
501
+ * `send`, `close` — so the WebSocket server and client both speak ONE transport contract,
502
+ * no near-duplicate sibling interface. `session` is `undefined` (the stateless v1; a
503
+ * session id is the deferred sessions tier). The name keeps the role explicit even though
504
+ * the shape is shared.
505
+ * - **Inbound (`message`).** `start()` subscribes to the socket's `message` event; each text
506
+ * frame is `JSON.parse`d inside a try/catch and narrowed with `parseJSONRPCMessage` — a
507
+ * well-formed {@link JSONRPCMessage} is re-emitted on this transport's `message` event (the
508
+ * parsed envelope the {@link import('@src/core').MCPServerInterface} pump dispatches), while
509
+ * a non-JSON or non-message frame is surfaced on `error` and DROPPED, never thrown (§14). It
510
+ * also bridges the socket's `close` → this transport's `close`, and the socket's `error`.
511
+ * - **Outbound (`send`).** `send(message | messages)` writes ONE text frame per message
512
+ * (`nodeWs.send(JSON.stringify(...))`); the underlying wrapper no-ops a write on a
513
+ * non-open socket, so a closed connection drops silently rather than throwing.
514
+ * - **`close()`** closes the underlying socket (the RFC 6455 close handshake) and fires the
515
+ * transport's `close` event (idempotent — a second `close`, or a socket-driven close, emits
516
+ * once).
517
+ * - **Observable (§13).** Owns the `emitter` ({@link ClientTransportEventMap}); the emitter
518
+ * isolates a listener throw (a buggy observer never corrupts the bridge). `error` is a
519
+ * DOMAIN event (a transport-level fault), distinct from the emitter's listener-error channel.
520
+ */
521
+ var WebSocketServerTransport = class {
522
+ #emitter;
523
+ #socket;
524
+ #started = false;
525
+ #closed = false;
526
+ constructor(socket) {
527
+ this.#emitter = new _orkestrel_emitter.Emitter();
528
+ this.#socket = socket;
529
+ }
530
+ get emitter() {
531
+ return this.#emitter;
532
+ }
533
+ get session() {}
534
+ async start() {
535
+ if (this.#started || this.#closed) return;
536
+ this.#started = true;
537
+ this.#socket.emitter.on("message", (text) => this.#receive(text));
538
+ this.#socket.emitter.on("close", () => this.#onClose());
539
+ this.#socket.emitter.on("error", (error) => this.#emitter.emit("error", error));
540
+ }
541
+ async send(message) {
542
+ const messages = Array.isArray(message) ? message : [message];
543
+ for (const one of messages) this.#socket.send(JSON.stringify(one));
544
+ }
545
+ async close() {
546
+ if (this.#closed) return;
547
+ this.#closed = true;
548
+ this.#socket.close();
549
+ this.#emitter.emit("close");
550
+ }
551
+ #receive(text) {
552
+ let parsed;
553
+ try {
554
+ parsed = JSON.parse(text);
555
+ } catch (error) {
556
+ this.#emitter.emit("error", error);
557
+ return;
558
+ }
559
+ const message = (0, _src_core.parseJSONRPCMessage)(parsed);
560
+ if (message === void 0) {
561
+ this.#emitter.emit("error", /* @__PURE__ */ new Error("non-JSON-RPC WebSocket frame"));
562
+ return;
563
+ }
564
+ this.#emitter.emit("message", message);
565
+ }
566
+ #onClose() {
567
+ if (this.#closed) return;
568
+ this.#closed = true;
569
+ this.#emitter.emit("close");
570
+ }
571
+ };
572
+ //#endregion
573
+ //#region src/server/transports/WebSocketClientTransport.ts
574
+ /**
575
+ * The WebSocket CLIENT transport for the Model Context Protocol — a
576
+ * {@link ClientTransportInterface} that drives a REMOTE MCP server over a WebSocket, the
577
+ * egress mirror of {@link import('./factories.js').createWebSocketServer} and the WebSocket
578
+ * sibling of {@link import('./HTTPClientTransport.js').HTTPClientTransport}.
579
+ *
580
+ * @remarks
581
+ * - **Persistent bidirectional channel (unlike the HTTP transport).** `start()` performs the
582
+ * RFC 6455 client handshake: it opens a `node:http`(`s`) `GET` carrying `Connection: Upgrade`
583
+ * / `Upgrade: websocket` / a random `Sec-WebSocket-Key` / `Sec-WebSocket-Version: 13` /
584
+ * `Sec-WebSocket-Protocol: mcp` (plus any `options.headers`), awaits the client `'upgrade'`
585
+ * event, and VALIDATES `Sec-WebSocket-Accept === computeWebSocketAccept(key)` (the D2 helper)
586
+ * — a mismatch (or a non-`101` response, or a request error) REJECTS `start()` and the socket
587
+ * is destroyed. On success it wraps the raw upgraded socket in `createNodeWebSocket({ socket,
588
+ * head })` (CLIENT mode — no key → frames are MASKED per §5.3) and bridges its `message`.
589
+ * - **Inbound (`message`).** Each decoded text frame is `JSON.parse`d (guarded) and narrowed
590
+ * with `parseJSONRPCMessage` — a {@link JSONRPCMessage} re-emits on this transport's `message`
591
+ * event (the reply the {@link import('@src/core').MCPClientInterface} correlates by `id`); a
592
+ * non-JSON / non-message frame surfaces on `error` and is dropped (§14). The socket's `close`
593
+ * / `error` bridge to this transport's events.
594
+ * - **Outbound (`send`).** `send(message | messages)` writes ONE masked text frame per message.
595
+ * - **`close()`** closes the underlying socket and fires `close` (idempotent).
596
+ * - **URL scheme.** `options.url` accepts a `ws://` / `wss://` URL or an `http://` / `https://`
597
+ * one; a `ws(s)` scheme is converted to `http(s)` for the underlying upgrade request (`wss`
598
+ * → TLS via `node:https`). Either reaches the same endpoint.
599
+ * - **Observable (§13).** Owns the `emitter` ({@link ClientTransportEventMap}); every emit
600
+ * the emitter isolates a listener throw (a buggy observer never corrupts the transport);
601
+ * `error` is a DOMAIN event (a transport-level fault).
602
+ *
603
+ * @example
604
+ * ```ts
605
+ * const transport = new WebSocketClientTransport({ url: 'ws://localhost:3000/mcp' })
606
+ * const client = new MCPClient({ transport })
607
+ * await client.connect() // start() handshakes, then the MCP initialize runs over WS frames
608
+ * ```
609
+ */
610
+ var WebSocketClientTransport = class {
611
+ #emitter;
612
+ #url;
613
+ #headers;
614
+ #socket = void 0;
615
+ #closed = false;
616
+ constructor(options) {
617
+ this.#emitter = new _orkestrel_emitter.Emitter();
618
+ this.#url = options.url;
619
+ this.#headers = options.headers ?? {};
620
+ }
621
+ get emitter() {
622
+ return this.#emitter;
623
+ }
624
+ get session() {}
625
+ async start() {
626
+ if (this.#socket !== void 0) return;
627
+ this.#closed = false;
628
+ const url = this.#httpURL();
629
+ const key = (0, node_crypto.randomBytes)(16).toString("base64");
630
+ const secure = url.protocol === "https:";
631
+ const send = secure ? node_https.request : node_http.request;
632
+ await new Promise((resolve, reject) => {
633
+ let settled = false;
634
+ const fail = (error) => {
635
+ if (settled) return;
636
+ settled = true;
637
+ reject(error);
638
+ };
639
+ const request = send({
640
+ hostname: url.hostname,
641
+ port: url.port.length > 0 ? Number(url.port) : secure ? 443 : 80,
642
+ path: `${url.pathname}${url.search}`,
643
+ headers: {
644
+ Connection: "Upgrade",
645
+ Upgrade: "websocket",
646
+ "Sec-WebSocket-Key": key,
647
+ "Sec-WebSocket-Version": _orkestrel_websocket.WEBSOCKET_VERSION,
648
+ "Sec-WebSocket-Protocol": "mcp",
649
+ ...this.#headers
650
+ }
651
+ });
652
+ request.on("upgrade", (response, socket, head) => {
653
+ const accept = response.headers["sec-websocket-accept"];
654
+ if (!(0, _orkestrel_contract.isString)(accept) || accept !== (0, _orkestrel_websocket.computeWebSocketAccept)(key)) {
655
+ socket.destroy();
656
+ fail(/* @__PURE__ */ new Error("WebSocket handshake failed: Sec-WebSocket-Accept mismatch"));
657
+ return;
658
+ }
659
+ const ws = (0, _orkestrel_websocket.createNodeWebSocket)({
660
+ socket,
661
+ head
662
+ });
663
+ this.#socket = ws;
664
+ this.#bind(ws);
665
+ if (!settled) {
666
+ settled = true;
667
+ resolve();
668
+ }
669
+ });
670
+ request.on("response", (response) => {
671
+ response.resume();
672
+ fail(/* @__PURE__ */ new Error(`WebSocket upgrade declined with status ${response.statusCode ?? 0}`));
673
+ });
674
+ request.on("error", (error) => fail(error instanceof Error ? error : new Error(String(error))));
675
+ request.end();
676
+ });
677
+ }
678
+ async send(message) {
679
+ const socket = this.#socket;
680
+ if (socket === void 0) throw new Error("WebSocket transport is not connected");
681
+ const messages = Array.isArray(message) ? message : [message];
682
+ for (const one of messages) socket.send(JSON.stringify(one));
683
+ }
684
+ async close() {
685
+ if (this.#closed) return;
686
+ this.#closed = true;
687
+ const socket = this.#socket;
688
+ this.#socket = void 0;
689
+ if (socket !== void 0) socket.close();
690
+ this.#emitter.emit("close");
691
+ }
692
+ #bind(ws) {
693
+ ws.emitter.on("message", (text) => this.#receive(text));
694
+ ws.emitter.on("close", () => this.#onClose());
695
+ ws.emitter.on("error", (error) => this.#emitter.emit("error", error));
696
+ }
697
+ #receive(text) {
698
+ let parsed;
699
+ try {
700
+ parsed = JSON.parse(text);
701
+ } catch (error) {
702
+ this.#emitter.emit("error", error);
703
+ return;
704
+ }
705
+ const message = (0, _src_core.parseJSONRPCMessage)(parsed);
706
+ if (message === void 0) {
707
+ this.#emitter.emit("error", /* @__PURE__ */ new Error("non-JSON-RPC WebSocket frame"));
708
+ return;
709
+ }
710
+ this.#emitter.emit("message", message);
711
+ }
712
+ #onClose() {
713
+ if (this.#closed) return;
714
+ this.#closed = true;
715
+ this.#socket = void 0;
716
+ this.#emitter.emit("close");
717
+ }
718
+ #httpURL() {
719
+ const url = new URL(this.#url);
720
+ if (url.protocol === "ws:") url.protocol = "http:";
721
+ else if (url.protocol === "wss:") url.protocol = "https:";
722
+ else if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`unsupported WebSocket URL scheme '${url.protocol}'`);
723
+ return url;
724
+ }
725
+ };
726
+ //#endregion
727
+ //#region src/server/transports/StdioClientTransport.ts
728
+ /**
729
+ * The stdio CLIENT transport for the Model Context Protocol — a
730
+ * {@link ClientTransportInterface} that drives a CHILD PROCESS MCP server over
731
+ * newline-delimited JSON-RPC on `stdin`/`stdout`, the stdio sibling of {@link
732
+ * import('./HTTPClientTransport.js').HTTPClientTransport} and {@link
733
+ * import('./WebSocketClientTransport.js').WebSocketClientTransport}.
734
+ *
735
+ * @remarks
736
+ * - **Spawns the server.** `start()` runs `node:child_process`'s `spawn(options.command,
737
+ * options.args, { env: options.env, stdio: ['pipe', 'pipe', 'inherit'] })` — the
738
+ * child's `stdin`/`stdout` are piped for the JSON-RPC channel, its `stderr` inherits
739
+ * the parent's (diagnostics pass through, never parsed as protocol).
740
+ * - **Inbound (`message`).** Each `stdout` chunk is folded through the shared
741
+ * {@link extractLines} line-framing helper (buffering a partial trailing line
742
+ * across reads); every complete line is decoded and delivered via the shared
743
+ * {@link dispatchLines} helper — a well-formed {@link JSONRPCMessage} emits
744
+ * `message`, a malformed line emits `error` (§14, never throws). The child's
745
+ * `close` bridges to this transport's `close`.
746
+ * - **Outbound (`send`).** `send(message | messages)` writes ONE newline-terminated
747
+ * `JSON.stringify`d line per message to the child's `stdin`.
748
+ * - **`close()`** kills the child process and fires `close` (idempotent).
749
+ * - **Observable (§13).** Owns the `emitter` ({@link ClientTransportEventMap}); the
750
+ * emitter isolates a listener throw; `error` is a DOMAIN event (a transport-level
751
+ * fault), distinct from the emitter's own listener-error channel.
752
+ *
753
+ * @example
754
+ * ```ts
755
+ * const transport = new StdioClientTransport({ command: 'node', args: ['./server.js'] })
756
+ * const client = new MCPClient({ transport })
757
+ * await client.connect() // start() spawns the child, then the MCP initialize runs over stdio
758
+ * ```
759
+ */
760
+ var StdioClientTransport = class {
761
+ #emitter;
762
+ #command;
763
+ #args;
764
+ #env;
765
+ #child = void 0;
766
+ #buffer = "";
767
+ #closed = false;
768
+ constructor(options) {
769
+ this.#emitter = new _orkestrel_emitter.Emitter();
770
+ this.#command = options.command;
771
+ this.#args = options.args ?? [];
772
+ this.#env = options.env;
773
+ }
774
+ get emitter() {
775
+ return this.#emitter;
776
+ }
777
+ get session() {}
778
+ async start() {
779
+ if (this.#child !== void 0) return;
780
+ this.#closed = false;
781
+ this.#buffer = "";
782
+ const child = (0, node_child_process.spawn)(this.#command, [...this.#args], {
783
+ env: this.#env,
784
+ stdio: [
785
+ "pipe",
786
+ "pipe",
787
+ "inherit"
788
+ ]
789
+ });
790
+ this.#child = child;
791
+ child.stdout.on("data", (chunk) => this.#receive(chunk.toString()));
792
+ child.on("close", () => this.#onClose());
793
+ child.on("error", (error) => this.#emitter.emit("error", error));
794
+ }
795
+ async send(message) {
796
+ const child = this.#child;
797
+ if (child === void 0) throw new Error("stdio transport is not connected");
798
+ const messages = Array.isArray(message) ? message : [message];
799
+ for (const one of messages) child.stdin.write(`${JSON.stringify(one)}\n`);
800
+ }
801
+ async close() {
802
+ if (this.#closed) return;
803
+ this.#closed = true;
804
+ const child = this.#child;
805
+ this.#child = void 0;
806
+ if (child !== void 0) child.kill();
807
+ this.#emitter.emit("close");
808
+ }
809
+ #receive(chunk) {
810
+ const { lines, remainder } = extractLines(this.#buffer, chunk);
811
+ this.#buffer = remainder;
812
+ dispatchLines(this.#emitter, lines);
813
+ }
814
+ #onClose() {
815
+ if (this.#closed) return;
816
+ this.#closed = true;
817
+ this.#child = void 0;
818
+ this.#emitter.emit("close");
819
+ }
820
+ };
821
+ //#endregion
822
+ //#region src/server/transports/StdioServerTransport.ts
823
+ /**
824
+ * The stdio SERVER transport for the Model Context Protocol — wraps an injectable
825
+ * readable/writable stream pair (`process.stdin`/`process.stdout` in production, a
826
+ * test double in tests) as a {@link ClientTransportInterface}, the newline-delimited
827
+ * JSON-RPC channel {@link import('../factories.js').createStdioServer} pumps
828
+ * `mcp.dispatch` over, the stdio mirror of {@link
829
+ * import('./WebSocketServerTransport.js').WebSocketServerTransport}.
830
+ *
831
+ * @remarks
832
+ * - **Reuses `ClientTransportInterface` (§21).** The same generic carrier the HTTP
833
+ * and WebSocket server transports implement — `emitter` (`message` / `close` /
834
+ * `error`), `start`, `send`, `close`. `session` is `undefined` (the stateless v1).
835
+ * - **Inbound (`message`).** `start()` subscribes to `input`'s `data` event; each
836
+ * chunk is folded through the shared {@link extractLines} line-framing helper
837
+ * (buffering a partial trailing line across reads), and every complete line is
838
+ * decoded and delivered via the shared {@link dispatchLines} helper — a
839
+ * well-formed {@link JSONRPCMessage} re-emits on `message`, a malformed line
840
+ * emits `error` (§14, never throws). `input`'s `close` bridges to this
841
+ * transport's `close`.
842
+ * - **Outbound (`send`).** `send(message | messages)` writes ONE newline-terminated
843
+ * `JSON.stringify`d line per message to `output`.
844
+ * - **`close()`** fires this transport's `close` (idempotent) — the injected streams
845
+ * are owned by the caller (typically `process.stdin`/`process.stdout`, which must
846
+ * never be closed out from under the process) and are not torn down here.
847
+ * - **Observable (§13).** Owns the `emitter` ({@link ClientTransportEventMap}); the
848
+ * emitter isolates a listener throw; `error` is a DOMAIN event (a transport-level
849
+ * fault), distinct from the emitter's own listener-error channel.
850
+ */
851
+ var StdioServerTransport = class {
852
+ #emitter;
853
+ #input;
854
+ #output;
855
+ #buffer = "";
856
+ #started = false;
857
+ #closed = false;
858
+ constructor(input, output) {
859
+ this.#emitter = new _orkestrel_emitter.Emitter();
860
+ this.#input = input;
861
+ this.#output = output;
862
+ }
863
+ get emitter() {
864
+ return this.#emitter;
865
+ }
866
+ get session() {}
867
+ async start() {
868
+ if (this.#started || this.#closed) return;
869
+ this.#started = true;
870
+ this.#input.on("data", (chunk) => this.#receive(chunk.toString()));
871
+ this.#input.on("close", () => this.#onClose());
872
+ this.#input.on("error", (error) => this.#emitter.emit("error", error));
873
+ }
874
+ async send(message) {
875
+ const messages = Array.isArray(message) ? message : [message];
876
+ for (const one of messages) this.#output.write(`${JSON.stringify(one)}\n`);
877
+ }
878
+ async close() {
879
+ if (this.#closed) return;
880
+ this.#closed = true;
881
+ this.#emitter.emit("close");
882
+ }
883
+ #receive(chunk) {
884
+ const { lines, remainder } = extractLines(this.#buffer, chunk);
885
+ this.#buffer = remainder;
886
+ dispatchLines(this.#emitter, lines);
887
+ }
888
+ #onClose() {
889
+ if (this.#closed) return;
890
+ this.#closed = true;
891
+ this.#emitter.emit("close");
892
+ }
893
+ };
894
+ //#endregion
895
+ //#region src/server/factories.ts
896
+ /**
897
+ * Create the MCP Streamable-HTTP transport routes — mounts a transport-agnostic
898
+ * {@link MCPServerInterface} (the `@src/core` dispatch core) on the fetch-standard router
899
+ * spine, pumping each `POST` body through `mcp.dispatch`. Returns the {@link RouteInput}s to
900
+ * hand to `router.add(...)`.
901
+ *
902
+ * @remarks
903
+ * A SINGLE `POST {path}` route — `createMCPRoutes` is STATELESS. The handler reads its own
904
+ * request body (its own JSON parse try/catch), so it works with or without a session
905
+ * middleware mounted in front. It draws a sharp line between TRANSPORT-level and
906
+ * DISPATCH-level outcomes:
907
+ *
908
+ * - A **transport** failure — a malformed JSON body, or a parsed value that is not a
909
+ * JSON-RPC REQUEST — is an HTTP `400` carrying a JSON-RPC error BODY (`-32700` Parse
910
+ * error / `-32600` Invalid Request, id `null`).
911
+ * - A **dispatch** result — a success OR an IN-BAND JSON-RPC error from `mcp.dispatch`
912
+ * (e.g. `-32601` method-not-found) — is an HTTP `200` carrying the JSON-RPC response
913
+ * envelope (the error is in-band per JSON-RPC, NOT an HTTP error).
914
+ * - A **notification** (a request with no `id`, which `dispatch` resolves to
915
+ * `undefined`) is a `202 Accepted` with no body.
916
+ *
917
+ * When `streaming` is enabled (the default) and the client `Accept`s `text/event-stream`,
918
+ * the `200` reply is framed as a Streamable-HTTP SSE response (one `data:` event carrying
919
+ * the JSON-RPC envelope, then the stream ends) via `@orkestrel/server`'s generic
920
+ * {@link import('@orkestrel/server').openStream} seam; otherwise it is a plain JSON body.
921
+ *
922
+ * **Sessions are a SEPARATE, plug-and-play middleware.** `createMCPRoutes` mints / reads no
923
+ * session id. To make the transport STATEFUL, mount {@link
924
+ * import('./middlewares.js').createMCPSession} IN FRONT — it owns the same `path`, mints +
925
+ * validates the `mcp-session-id`, and serves the resumable `GET {path}` + `DELETE {path}`,
926
+ * leaving this route to dispatch the validated `POST`.
927
+ *
928
+ * This is MECHANISM, not policy: compose auth / CORS / rate-limiting (and the session
929
+ * middleware) IN FRONT as ordinary middleware — the transport route adds none.
930
+ *
931
+ * @typeParam TState - The consumer's opaque per-request state type
932
+ * @param mcp - The transport-agnostic {@link MCPServerInterface} to expose over HTTP
933
+ * @param options - Optional `path` (default {@link DEFAULT_MCP_PATH}) and `streaming`
934
+ * (default `true`); see {@link HTTPTransportOptions}
935
+ * @returns The {@link RouteInput}s to register with the router
936
+ *
937
+ * @example
938
+ * ```ts
939
+ * import { createMCPServer, createToolManager } from '@src/core'
940
+ * import { createMCPRoutes } from '@src/server'
941
+ *
942
+ * const mcp = createMCPServer({ name: 'docs', version: '1.0.0', tools: createToolManager() })
943
+ * const routes = createMCPRoutes(mcp) // POST /mcp dispatches JSON-RPC (JSON or SSE per Accept)
944
+ * ```
945
+ */
946
+ function createMCPRoutes(mcp, options) {
947
+ const path = options?.path ?? "/mcp";
948
+ const streaming = options?.streaming ?? true;
949
+ return [{
950
+ method: "POST",
951
+ path,
952
+ name: "mcp",
953
+ handler: async (request) => {
954
+ let text;
955
+ try {
956
+ text = await request.text();
957
+ } catch {
958
+ return Response.json((0, _src_core.jsonRPCError)(null, _src_core.JSONRPC_PARSE_ERROR, "Parse error"), { status: 400 });
959
+ }
960
+ let parsed;
961
+ try {
962
+ parsed = JSON.parse(text);
963
+ } catch {
964
+ return Response.json((0, _src_core.jsonRPCError)(null, _src_core.JSONRPC_PARSE_ERROR, "Parse error"), { status: 400 });
965
+ }
966
+ const rpcRequest = (0, _src_core.parseJSONRPCMessage)(parsed);
967
+ if (rpcRequest === void 0 || !("method" in rpcRequest)) return Response.json((0, _src_core.jsonRPCError)(null, _src_core.JSONRPC_INVALID_REQUEST, "Invalid Request"), { status: 400 });
968
+ const response = await mcp.dispatch(rpcRequest);
969
+ if (response === void 0) return new Response(null, { status: 202 });
970
+ if (streaming && acceptsEventStream(request)) {
971
+ const s = (0, _orkestrel_server.openStream)();
972
+ s.write({ data: JSON.stringify(response) });
973
+ s.end();
974
+ return s.response;
975
+ }
976
+ return Response.json(response);
977
+ }
978
+ }];
979
+ }
980
+ /**
981
+ * Create the HTTP CLIENT transport for an {@link import('@src/core').MCPClientInterface}
982
+ * — a {@link ClientTransportInterface} that drives a REMOTE Streamable-HTTP MCP server
983
+ * over `fetch`. The egress mirror of {@link createMCPRoutes}.
984
+ *
985
+ * @remarks
986
+ * Hand it to `createMCPClient({ transport })`: each JSON-RPC message the client sends is
987
+ * `POST`ed to `options.url` with `content-type: application/json` and an `Accept` of
988
+ * both `application/json` and `text/event-stream` (the server answers with EITHER — a
989
+ * plain JSON envelope or a Streamable-HTTP SSE `data:` event, decoded via `@orkestrel/sse`),
990
+ * and the reply is surfaced on the transport's `message` event for the client's id
991
+ * correlation. Add `options.headers` (e.g. an `Authorization` bearer) to reach a guarded
992
+ * server. `start` / `close` hold no connection; against a STATEFUL server it captures the
993
+ * `mcp-session-id` from `initialize` and echoes it on later requests, so the same
994
+ * `MCPClient` passes session validation (a stateless server sends none).
995
+ *
996
+ * @param options - `url` (the remote endpoint; REQUIRED), optional `headers` merged onto
997
+ * every request, optional `fetch` (default `globalThis.fetch`), and optional `timeout`
998
+ * (ms, applied via `AbortSignal.timeout`); see {@link HTTPClientTransportOptions}
999
+ * @returns A working {@link ClientTransportInterface} over `fetch`
1000
+ *
1001
+ * @example
1002
+ * ```ts
1003
+ * import { createMCPClient } from '@src/core'
1004
+ * import { createHTTPClientTransport } from '@src/server'
1005
+ *
1006
+ * const client = createMCPClient({
1007
+ * transport: createHTTPClientTransport({ url: 'http://localhost:3000/mcp' }),
1008
+ * })
1009
+ * await client.connect()
1010
+ * const tools = await client.tools()
1011
+ * ```
1012
+ */
1013
+ function createHTTPClientTransport(options) {
1014
+ return new HTTPClientTransport(options);
1015
+ }
1016
+ /**
1017
+ * Create the MCP WebSocket transport INGRESS — an {@link UpgradeHandler} that exposes a
1018
+ * transport-agnostic {@link MCPServerInterface} over a WebSocket, the WebSocket mirror of
1019
+ * {@link createMCPRoutes}. Register it on the spine's upgrade seam.
1020
+ *
1021
+ * @remarks
1022
+ * It composes the lean RFC 6455 `@orkestrel/websocket` wrapper over `@orkestrel/server`'s
1023
+ * generic upgrade seam — the spine speaks no WebSocket, this handler does.
1024
+ *
1025
+ * - **Declines (returns `false`)** when the upgrade is not for it, so the spine fans the
1026
+ * socket to the next handler (or destroys an unclaimed one): the `Upgrade` header is not
1027
+ * `websocket`, the request path is not `options.path` (default {@link DEFAULT_MCP_PATH},
1028
+ * `'/mcp'`), the `Sec-WebSocket-Key` is absent, or the `Sec-WebSocket-Version` is not `13`.
1029
+ * A decline NEVER writes to the socket (it is not yet ours) — the spine owns the unclaimed
1030
+ * outcome.
1031
+ * - **Claims (returns `true`)** otherwise: it builds `createNodeWebSocket({ socket, key, head,
1032
+ * protocol })` (SERVER mode → writes the `101` handshake, echoing the `subprotocol`, default
1033
+ * {@link MCP_WEBSOCKET_SUBPROTOCOL} `'mcp'`, and sends UNMASKED frames), wraps it in a
1034
+ * {@link WebSocketServerTransport}, and PUMPS: each inbound {@link
1035
+ * import('@src/core').JSONRPCMessage} that is a REQUEST runs through `mcp.dispatch`, and a
1036
+ * defined response is written back as a frame — a NOTIFICATION (`dispatch` → `undefined`)
1037
+ * sends nothing. A non-request message (a stray response) is ignored. The dispatch is
1038
+ * guarded so a `dispatch` / `send` fault surfaces on the transport's `error` event rather
1039
+ * than escaping the (async) message listener.
1040
+ *
1041
+ * It is MECHANISM, not policy: compose an auth guard IN FRONT by registering an upgrade
1042
+ * handler BEFORE this one — that handler can claim (decline + destroy) an unauthenticated
1043
+ * upgrade so it never reaches this pump.
1044
+ *
1045
+ * @param mcp - The transport-agnostic {@link MCPServerInterface} to expose over WebSocket
1046
+ * @param options - Optional `path` (default {@link DEFAULT_MCP_PATH}) and `subprotocol`
1047
+ * (default {@link MCP_WEBSOCKET_SUBPROTOCOL}); see {@link WebSocketServerOptions}
1048
+ * @returns An {@link UpgradeHandler} to register with the spine's `upgrade` seam
1049
+ *
1050
+ * @example
1051
+ * ```ts
1052
+ * import { createMCPServer, createToolManager } from '@src/core'
1053
+ * import { createWebSocketServer } from '@src/server'
1054
+ *
1055
+ * const mcp = createMCPServer({ name: 'docs', version: '1.0.0', tools: createToolManager() })
1056
+ * server.upgrade(createWebSocketServer(mcp)) // an MCP client now connects over ws://…/mcp
1057
+ * ```
1058
+ */
1059
+ function createWebSocketServer(mcp, options) {
1060
+ const path = options?.path ?? "/mcp";
1061
+ const subprotocol = options?.subprotocol ?? "mcp";
1062
+ return (request, socket, head) => {
1063
+ const upgrade = request.headers["upgrade"];
1064
+ if (!(0, _orkestrel_contract.isString)(upgrade) || upgrade.toLowerCase() !== "websocket") return false;
1065
+ if (upgradeRequestPath(request) !== path) return false;
1066
+ const key = request.headers["sec-websocket-key"];
1067
+ if (!(0, _orkestrel_contract.isString)(key)) return false;
1068
+ const version = request.headers["sec-websocket-version"];
1069
+ if (!(0, _orkestrel_contract.isString)(version) || version !== _orkestrel_websocket.WEBSOCKET_VERSION) return false;
1070
+ const transport = new WebSocketServerTransport((0, _orkestrel_websocket.createNodeWebSocket)({
1071
+ socket,
1072
+ key,
1073
+ head,
1074
+ protocol: subprotocol
1075
+ }));
1076
+ transport.emitter.on("message", (message) => {
1077
+ if (!(0, _src_core.isJSONRPCRequest)(message)) return;
1078
+ (async () => {
1079
+ try {
1080
+ const response = await mcp.dispatch(message);
1081
+ if (response !== void 0) await transport.send(response);
1082
+ } catch (error) {
1083
+ try {
1084
+ transport.emitter.emit("error", error);
1085
+ } catch {}
1086
+ }
1087
+ })();
1088
+ });
1089
+ transport.start();
1090
+ return true;
1091
+ };
1092
+ }
1093
+ /**
1094
+ * Create the WebSocket CLIENT transport for an {@link import('@src/core').MCPClientInterface}
1095
+ * — a {@link ClientTransportInterface} that drives a REMOTE MCP server over a WebSocket. The
1096
+ * egress mirror of {@link createWebSocketServer} and the WebSocket sibling of {@link
1097
+ * createHTTPClientTransport}.
1098
+ *
1099
+ * @remarks
1100
+ * Hand it to `createMCPClient({ transport })`: `start()` (run by `client.connect()`) performs
1101
+ * the RFC 6455 client handshake against `options.url` (accepting a `ws://` / `wss://` or an
1102
+ * `http://` / `https://` URL — a `ws(s)` scheme is converted to `http(s)` for the underlying
1103
+ * upgrade request), validates the `Sec-WebSocket-Accept` (via `@orkestrel/websocket`'s
1104
+ * `computeWebSocketAccept`), and opens a persistent bidirectional frame channel; each JSON-RPC
1105
+ * message the client `send`s is written as one masked text frame, and each decoded reply is
1106
+ * surfaced on the transport's `message` event for the client's id correlation. Add
1107
+ * `options.headers` (e.g. an `Authorization` bearer) to reach a guarded server.
1108
+ *
1109
+ * @param options - `url` (the remote WebSocket endpoint; REQUIRED) and optional `headers`
1110
+ * merged onto the upgrade request; see {@link WebSocketClientTransportOptions}
1111
+ * @returns A working {@link ClientTransportInterface} over a WebSocket
1112
+ *
1113
+ * @example
1114
+ * ```ts
1115
+ * import { createMCPClient } from '@src/core'
1116
+ * import { createWebSocketClientTransport } from '@src/server'
1117
+ *
1118
+ * const client = createMCPClient({
1119
+ * transport: createWebSocketClientTransport({ url: 'ws://localhost:3000/mcp' }),
1120
+ * })
1121
+ * await client.connect()
1122
+ * const tools = await client.tools()
1123
+ * ```
1124
+ */
1125
+ function createWebSocketClientTransport(options) {
1126
+ return new WebSocketClientTransport(options);
1127
+ }
1128
+ /**
1129
+ * Create the stdio CLIENT transport for an {@link import('@src/core').MCPClientInterface}
1130
+ * — a {@link ClientTransportInterface} that spawns and drives a CHILD PROCESS MCP server
1131
+ * over newline-delimited JSON-RPC on `stdin`/`stdout`, the stdio sibling of {@link
1132
+ * createHTTPClientTransport} and {@link createWebSocketClientTransport}.
1133
+ *
1134
+ * @remarks
1135
+ * Hand it to `createMCPClient({ transport })`: `start()` (run by `client.connect()`)
1136
+ * spawns `options.command` with `options.args` and `options.env`, piping its
1137
+ * `stdin`/`stdout` for the JSON-RPC channel (its `stderr` inherits the parent's for
1138
+ * diagnostics). Each JSON-RPC message the client `send`s is written as one
1139
+ * newline-terminated line to the child's `stdin`; each decoded reply line from the
1140
+ * child's `stdout` is surfaced on the transport's `message` event for the client's
1141
+ * id correlation.
1142
+ *
1143
+ * @param options - `command` (the executable to spawn; REQUIRED), optional `args`,
1144
+ * and optional `env`; see {@link StdioClientTransportOptions}
1145
+ * @returns A working {@link ClientTransportInterface} over a child process's stdio
1146
+ *
1147
+ * @example
1148
+ * ```ts
1149
+ * import { createMCPClient } from '@src/core'
1150
+ * import { createStdioClientTransport } from '@src/server'
1151
+ *
1152
+ * const client = createMCPClient({
1153
+ * transport: createStdioClientTransport({ command: 'node', args: ['./server.js'] }),
1154
+ * })
1155
+ * await client.connect()
1156
+ * const tools = await client.tools()
1157
+ * ```
1158
+ */
1159
+ function createStdioClientTransport(options) {
1160
+ return new StdioClientTransport(options);
1161
+ }
1162
+ /**
1163
+ * Create the MCP stdio transport INGRESS — pumps a transport-agnostic {@link
1164
+ * MCPServerInterface} over newline-delimited JSON-RPC on `stdin`/`stdout` (or an
1165
+ * injected stream pair), the stdio mirror of {@link createWebSocketServer}.
1166
+ *
1167
+ * @remarks
1168
+ * Wraps `options.input` (default `process.stdin`) / `options.output` (default
1169
+ * `process.stdout`) in a {@link import('./transports/StdioServerTransport.js').StdioServerTransport}
1170
+ * and PUMPS: each inbound {@link import('@src/core').JSONRPCMessage} that is a
1171
+ * REQUEST runs through `mcp.dispatch`, and a defined response is written back as a
1172
+ * newline-terminated line — a NOTIFICATION (`dispatch` → `undefined`) writes
1173
+ * nothing. A non-request message is ignored. The dispatch is guarded so a
1174
+ * `dispatch` / `send` fault surfaces on the transport's `error` event rather than
1175
+ * escaping the (async) message listener.
1176
+ *
1177
+ * @param mcp - The transport-agnostic {@link MCPServerInterface} to expose over stdio
1178
+ * @param options - Optional injectable `input` / `output` streams; see
1179
+ * {@link StdioServerOptions}
1180
+ * @returns A `{ start(): void; stop(): void }` handle to arm / tear down the pump
1181
+ *
1182
+ * @example
1183
+ * ```ts
1184
+ * import { createMCPServer, createToolManager } from '@src/core'
1185
+ * import { createStdioServer } from '@src/server'
1186
+ *
1187
+ * const mcp = createMCPServer({ name: 'docs', version: '1.0.0', tools: createToolManager() })
1188
+ * createStdioServer(mcp).start() // an MCP client now connects over this process's stdio
1189
+ * ```
1190
+ */
1191
+ function createStdioServer(mcp, options) {
1192
+ const transport = new StdioServerTransport(options?.input ?? process.stdin, options?.output ?? process.stdout);
1193
+ transport.emitter.on("message", (message) => {
1194
+ if (!(0, _src_core.isJSONRPCRequest)(message)) return;
1195
+ (async () => {
1196
+ try {
1197
+ const response = await mcp.dispatch(message);
1198
+ if (response !== void 0) await transport.send(response);
1199
+ } catch (error) {
1200
+ try {
1201
+ transport.emitter.emit("error", error);
1202
+ } catch {}
1203
+ }
1204
+ })();
1205
+ });
1206
+ return {
1207
+ start() {
1208
+ transport.start();
1209
+ },
1210
+ stop() {
1211
+ transport.close();
1212
+ }
1213
+ };
1214
+ }
1215
+ //#endregion
1216
+ //#region src/server/middlewares.ts
1217
+ /**
1218
+ * Create the native MCP session {@link MiddlewareHandler} — the plug-and-play stateful layer
1219
+ * that fronts a session-agnostic {@link import('./factories.js').createMCPRoutes}. Compose it
1220
+ * via `router.use(createMCPSession())` (or the equivalent middleware seam), mirroring any
1221
+ * other closure-scoped stateful middleware. Has NO dependency on `@orkestrel/middleware` — the
1222
+ * session store, mint-on-`initialize`, and resumable stream are all native to this package.
1223
+ *
1224
+ * @remarks
1225
+ * Owns a closure `Map<string, MCPSessionEntry>` keyed by session id, and a single request
1226
+ * `path` (default {@link DEFAULT_MCP_PATH}); a request to any other path passes straight
1227
+ * through (`next()`).
1228
+ *
1229
+ * - **`POST {path}`.** Buffers `const text = await request.text()` (so the downstream route
1230
+ * can re-read it via a freshly-built forwarded `Request`). Resolves a session via {@link
1231
+ * readSessionHeader}: a VALID id touches the entry and sets `context.state.session`; an
1232
+ * ABSENT / unknown id whose (guarded) body parses to an `initialize` request ({@link
1233
+ * isInitializeRequest}) MINTS a fresh {@link MCPSession} (`crypto.randomUUID()`, `capacity`)
1234
+ * and sets `context.state.session`; neither → {@link rejectUnknownSession} (`404`). It then
1235
+ * FORWARDS a fresh `Request` carrying the buffered `text` (`next(forwarded)`) — never the
1236
+ * already-consumed original — so the route re-reads the same body, and stamps the response
1237
+ * with {@link MCP_SESSION_HEADER}.
1238
+ * - **`GET {path}`.** Resolves the session the same way (no mint — only `initialize` mints);
1239
+ * an invalid / unknown id is the same `404`. A valid session opens the resumable
1240
+ * server→client stream via `@orkestrel/server`'s {@link import('@orkestrel/server').openStream}:
1241
+ * replays every event after the client's `Last-Event-ID` ({@link readLastEventId}) BEFORE
1242
+ * attaching the stream for live pushes, then attaches; a client disconnect (`request.signal`)
1243
+ * detaches it. Long-lived — never `end()`ed here.
1244
+ * - **`DELETE {path}`.** Resolves the session; a valid id deletes it from the store and answers
1245
+ * `204`; an invalid / unknown id is the same `404`.
1246
+ *
1247
+ * It is MECHANISM, not policy, and ADDITIVE: omit it entirely for the stateless default
1248
+ * ({@link import('./factories.js').createMCPRoutes}'s only behavior). The `path` MUST match the
1249
+ * `createMCPRoutes` `path` it fronts. The WebSocket transport is inherently one session per
1250
+ * connection (the socket IS the session), so this middleware does not apply to it.
1251
+ *
1252
+ * @typeParam TState - The consumer's `TState`, which MUST extend {@link MCPSessionState} so
1253
+ * the resolved session can be threaded through `context.state.session`
1254
+ * @param options - Optional `path` (default {@link DEFAULT_MCP_PATH}), `ttl` (idle-session
1255
+ * sweep window, ms — omit for sessions that live until an explicit `DELETE`), `capacity`
1256
+ * (the folded per-session replay-log bound), and `clock` (the deterministic epoch-ms clock;
1257
+ * defaults to `Date.now`); see {@link MCPSessionOptions}
1258
+ * @returns A {@link MiddlewareHandler} that mints / validates sessions + serves the resumable
1259
+ * `GET` / `DELETE`
1260
+ *
1261
+ * @example
1262
+ * ```ts
1263
+ * import { createMCPServer, createToolManager } from '@src/core'
1264
+ * import { createMCPRoutes, createMCPSession } from '@src/server'
1265
+ *
1266
+ * const mcp = createMCPServer({ name: 'docs', version: '1.0.0', tools: createToolManager() })
1267
+ * router.use(createMCPSession({ ttl: 60_000 })) // stateful: mint + validate + resumable GET / DELETE
1268
+ * router.add(createMCPRoutes(mcp)) // the route stays session-agnostic
1269
+ * ```
1270
+ */
1271
+ function createMCPSession(options) {
1272
+ const path = options?.path ?? "/mcp";
1273
+ const capacity = options?.capacity;
1274
+ const ttl = options?.ttl;
1275
+ const clock = options?.clock ?? Date.now;
1276
+ const store = /* @__PURE__ */ new Map();
1277
+ return async (request, context, next) => {
1278
+ if (context.url.pathname !== path) return next();
1279
+ sweep();
1280
+ if (context.method === "GET") {
1281
+ const entry = resolve(request);
1282
+ if (entry === void 0) return rejectUnknownSession();
1283
+ const stream = (0, _orkestrel_server.openStream)();
1284
+ stream.comment("open");
1285
+ const lastEventId = readLastEventId(request);
1286
+ if (lastEventId !== void 0) for (const e of entry.session.replay(lastEventId)) stream.write({
1287
+ id: e.id,
1288
+ data: JSON.stringify(e.message)
1289
+ });
1290
+ entry.session.attach(stream);
1291
+ if (request.signal.aborted) entry.session.detach(stream);
1292
+ else request.signal.addEventListener("abort", () => entry.session.detach(stream), { once: true });
1293
+ return stream.response;
1294
+ }
1295
+ if (context.method === "DELETE") {
1296
+ const id = readSessionHeader(request);
1297
+ if (id === void 0 || !store.has(id)) return rejectUnknownSession();
1298
+ store.delete(id);
1299
+ return new Response(null, { status: 204 });
1300
+ }
1301
+ const text = await request.text();
1302
+ let entry = resolve(request);
1303
+ if (entry === void 0) {
1304
+ let parsed;
1305
+ try {
1306
+ parsed = (0, _src_core.parseJSONRPCMessage)(JSON.parse(text));
1307
+ } catch {
1308
+ parsed = void 0;
1309
+ }
1310
+ if (parsed !== void 0 && (0, _src_core.isInitializeRequest)(parsed)) {
1311
+ const session = new MCPSession(crypto.randomUUID(), { capacity });
1312
+ entry = {
1313
+ session,
1314
+ touched: clock()
1315
+ };
1316
+ store.set(session.id, entry);
1317
+ } else return rejectUnknownSession();
1318
+ }
1319
+ context.state.session = entry.session;
1320
+ const response = await next(new Request(context.url, {
1321
+ method: "POST",
1322
+ headers: request.headers,
1323
+ body: text
1324
+ }));
1325
+ response.headers.set(MCP_SESSION_HEADER, entry.session.id);
1326
+ return response;
1327
+ };
1328
+ function resolve(request) {
1329
+ const id = readSessionHeader(request);
1330
+ if (id === void 0) return void 0;
1331
+ const entry = store.get(id);
1332
+ if (entry === void 0) return void 0;
1333
+ entry.touched = clock();
1334
+ return entry;
1335
+ }
1336
+ function sweep() {
1337
+ if (ttl === void 0) return;
1338
+ const cutoff = clock() - ttl;
1339
+ for (const [id, entry] of store) if (entry.touched <= cutoff) store.delete(id);
1340
+ }
1341
+ }
1342
+ //#endregion
1343
+ exports.DEFAULT_MCP_PATH = DEFAULT_MCP_PATH;
1344
+ exports.DEFAULT_MCP_SESSION_CAPACITY = DEFAULT_MCP_SESSION_CAPACITY;
1345
+ exports.DEFAULT_MCP_SESSION_TTL = DEFAULT_MCP_SESSION_TTL;
1346
+ exports.HTTPClientTransport = HTTPClientTransport;
1347
+ exports.MCPSession = MCPSession;
1348
+ exports.MCP_PROTOCOL_VERSION_HEADER = MCP_PROTOCOL_VERSION_HEADER;
1349
+ exports.MCP_SESSION_HEADER = MCP_SESSION_HEADER;
1350
+ exports.MCP_WEBSOCKET_SUBPROTOCOL = MCP_WEBSOCKET_SUBPROTOCOL;
1351
+ exports.StdioClientTransport = StdioClientTransport;
1352
+ exports.StdioServerTransport = StdioServerTransport;
1353
+ exports.WebSocketClientTransport = WebSocketClientTransport;
1354
+ exports.WebSocketServerTransport = WebSocketServerTransport;
1355
+ exports.acceptsEventStream = acceptsEventStream;
1356
+ exports.createHTTPClientTransport = createHTTPClientTransport;
1357
+ exports.createMCPRoutes = createMCPRoutes;
1358
+ exports.createMCPSession = createMCPSession;
1359
+ exports.createStdioClientTransport = createStdioClientTransport;
1360
+ exports.createStdioServer = createStdioServer;
1361
+ exports.createWebSocketClientTransport = createWebSocketClientTransport;
1362
+ exports.createWebSocketServer = createWebSocketServer;
1363
+ exports.decodeEvent = decodeEvent;
1364
+ exports.dispatchLines = dispatchLines;
1365
+ exports.extractLines = extractLines;
1366
+ exports.readEventStream = readEventStream;
1367
+ exports.readLastEventId = readLastEventId;
1368
+ exports.readSessionHeader = readSessionHeader;
1369
+ exports.rejectUnknownSession = rejectUnknownSession;
1370
+ exports.upgradeRequestPath = upgradeRequestPath;
1371
+
1372
+ //# sourceMappingURL=index.cjs.map