@orkestrel/mcp 0.0.14 → 0.0.16

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.
package/README.md CHANGED
@@ -81,9 +81,8 @@ package's source and runs
81
81
  `@modelcontextprotocol/conformance@0.2.0-alpha.10` against MCP revision
82
82
  `2026-07-28`. The recorded result is **23 passed / 0 failed**. That is a
83
83
  genuine foreign MCP client driving this server end to end, and it is
84
- evidence about the wire. It fetches the runner from the npm registry, so it
85
- is a live-service project of its own and stays outside the hermetic
86
- `npm test`.
84
+ evidence about the wire. It resolves the runner from `node_modules` and
85
+ drives a loopback socket, so the run is offline and `npm test` gates it.
87
86
 
88
87
  **IDE integration is not claimed.** No IDE, editor, or agent host has driven
89
88
  this server. A claim about an external client stays unproven here until one
@@ -42,220 +42,6 @@ var DEFAULT_MCP_SERVER_VERSION = "1.0.0";
42
42
  */
43
43
  var MCP_WEBSOCKET_SUBPROTOCOL = "mcp";
44
44
  //#endregion
45
- //#region src/browser/transports/MessagePortTransport.ts
46
- /**
47
- * The browser-face `MessagePort` transport for the Model Context Protocol — a
48
- * {@link MCPTransportInterface} over a native `MessagePort`, the genuinely new
49
- * capability this face adds: MCP over `postMessage`.
50
- *
51
- * @remarks
52
- * - **Symmetric.** Unlike {@link import('./WebSocketClientTransport.js').WebSocketClientTransport}
53
- * / {@link import('./HTTPClientTransport.js').HTTPClientTransport} (CLIENT-only
54
- * carriers of `@src/core`'s `MCPClientTransportInterface`), a `MessagePort` is a
55
- * plain duplex channel — the SAME class implements `@src/core`'s
56
- * `MCPTransportInterface` and is handed to EITHER `bindServer` or
57
- * `bindClient`/`createDuplexClientTransport`; which role it plays comes entirely
58
- * from the binder it is given to, not from anything this class decides.
59
- * - **`start()` at construction — bind synchronously.** `MessagePort.start()` is only
60
- * REQUIRED when listening via `addEventListener` (as opposed to the `onmessage`
61
- * setter, which implies it) — this transport uses `addEventListener`, and
62
- * `MCPTransportInterface` has no separate open/connect step for the caller to hook
63
- * a start into, so the constructor calls `port.start()` immediately: the port
64
- * begins dispatching QUEUED messages the moment the transport exists. This is safe
65
- * inside `serveMCP`'s flow (the transport is synchronously handed to `bindServer`
66
- * before control returns to the event loop), but is a **footgun for direct use**:
67
- * if you construct `new MessagePortTransport({ port })` and then `await` anything
68
- * before calling `listen`, messages that arrived in the gap are DROPPED. **Bind
69
- * synchronously after construction** — do not interleave an `await` between
70
- * `new MessagePortTransport(…)` and `bindServer` / `listen`.
71
- * - **String payloads only.** `send` posts the message string as-is (`postMessage`
72
- * structured-clones it — a string clones to an identical string, so the wire stays
73
- * plain JSON-RPC text like every other transport in this package). Inbound: a
74
- * non-string `event.data` (a host or a misbehaving peer posting a structured
75
- * object) is IGNORED — dropped silently, never forwarded, never thrown (§14) —
76
- * because `MCPTransportInterface` carries no `error` channel for this port to
77
- * surface a non-string frame on (unlike `MCPClientTransportInterface`'s `emitter`);
78
- * silently ignoring is the total, contract-shaped choice.
79
- * - **`messageerror` is IGNORED, not routed to `closed`.** A `messageerror` event
80
- * (the structured-clone deserialization of an inbound message threw) reports one
81
- * BAD FRAME, not a dead channel — the port itself keeps working and later, well-
82
- * formed messages still arrive. Routing it to `closed` would tear down the
83
- * `bindServer`/`bindClient` wiring (and, transitively, every session it carries)
84
- * over a single malformed frame, which is far more destructive than dropping that
85
- * one frame — so this transport registers a `messageerror` listener that does
86
- * nothing, deliberately.
87
- * - **`close()`** is idempotent: it closes the underlying `port` (`MessagePort.close()`
88
- * disconnects it — further `postMessage` calls on EITHER end are silently
89
- * undelivered, per the platform contract) and fires the registered `closed`
90
- * handler exactly once, whether the caller closes it once or twice. There is no
91
- * native "peer closed" signal for a `MessagePort` (unlike a WebSocket's `close`
92
- * event) — `closed` fires ONLY from this transport's own `close()`.
93
- * - **Single-handler-replace (the port contract, `@src/core`'s `MCPTransportInterface`
94
- * doc).** `listen`/`closed` each hold the ONE currently registered handler; a
95
- * second call REPLACES the first rather than adding a second subscriber.
96
- *
97
- * @example
98
- * ```ts
99
- * const { port1, port2 } = new MessageChannel()
100
- * const serverTransport = new MessagePortTransport({ port: port1 })
101
- * bindServer(server, serverTransport) // port1 side dispatches inbound requests
102
- *
103
- * const clientTransport = new MessagePortTransport({ port: port2 })
104
- * const client = createMCPClient({ transport: createDuplexClientTransport(clientTransport) })
105
- * bindClient(client, clientTransport) // port2 side is the client's carrier
106
- * ```
107
- */
108
- var MessagePortTransport = class {
109
- #port;
110
- #onMessage = void 0;
111
- #onClosed = void 0;
112
- #closed = false;
113
- constructor(options) {
114
- this.#port = options.port;
115
- this.#port.addEventListener("message", (event) => this.#receive(event.data));
116
- this.#port.addEventListener("messageerror", () => {});
117
- this.#port.start();
118
- }
119
- send(message) {
120
- if (this.#closed) return;
121
- this.#port.postMessage(message);
122
- }
123
- listen(handler) {
124
- this.#onMessage = handler;
125
- }
126
- closed(handler) {
127
- this.#onClosed = handler;
128
- }
129
- close() {
130
- if (this.#closed) return;
131
- this.#closed = true;
132
- this.#port.close();
133
- this.#onClosed?.();
134
- }
135
- #receive(data) {
136
- if (!isString(data)) return;
137
- this.#onMessage?.(data);
138
- }
139
- };
140
- //#endregion
141
- //#region src/browser/helpers.ts
142
- /**
143
- * Decode one SSE event's `data` string into a {@link JSONRPCMessage}, or `undefined`
144
- * when it is not one — the per-event step {@link readEventStream} folds over.
145
- *
146
- * @remarks
147
- * `JSON.parse`s the `data` (the server serializes the JSON-RPC envelope as the
148
- * event's `data`) inside a try/catch and narrows the parsed value with
149
- * `parseJSONRPCMessage`. Total (§14): malformed JSON or a non-message value yields
150
- * `undefined`, never throws.
151
- *
152
- * @param data - One SSE event's `data` payload
153
- * @returns The decoded {@link JSONRPCMessage}, or `undefined`
154
- */
155
- function decodeEvent(data) {
156
- try {
157
- return parseJSONRPCMessage(JSON.parse(data));
158
- } catch {
159
- return;
160
- }
161
- }
162
- /**
163
- * Decode a `fetch` Response's Server-Sent-Events body into the JSON-RPC messages it
164
- * carried — the CLIENT-side inverse of the server's Streamable-HTTP SSE response.
165
- *
166
- * @remarks
167
- * Reads the whole `response.body` stream chunk-by-chunk through a `TextDecoder({
168
- * stream: true })` (handling a multi-byte char split across reads) and
169
- * `@orkestrel/sse`'s {@link SSEParserInterface} (handling a partial line / in-progress
170
- * event split across reads), then narrows each dispatched event's `data` to a
171
- * {@link JSONRPCMessage} via {@link decodeEvent} (so a non-message / non-JSON `data:`
172
- * event is DROPPED, never thrown — total, §14). A `null` body (no stream) yields no
173
- * messages; {@link import('./transports/HTTPClientTransport.js').HTTPClientTransport}
174
- * reads a request/response SSE reply (the server sends one `data:` event then ends),
175
- * so this drains to completion.
176
- *
177
- * @param response - The SSE `fetch` Response to decode (its `body` is read to completion)
178
- * @returns Every {@link JSONRPCMessage} the stream carried, in order
179
- */
180
- async function readEventStream(response) {
181
- const body = response.body;
182
- if (body === null) return [];
183
- const reader = body.getReader();
184
- const decoder = new TextDecoder();
185
- const parser = createSSEParser();
186
- const messages = [];
187
- try {
188
- for (;;) {
189
- const { done, value } = await reader.read();
190
- if (done) break;
191
- for (const event of parser.parse(decoder.decode(value, { stream: true }))) {
192
- const message = decodeEvent(event.data);
193
- if (message !== void 0) messages.push(message);
194
- }
195
- }
196
- } finally {
197
- reader.releaseLock();
198
- }
199
- return messages;
200
- }
201
- /**
202
- * Build `serveMCPScope`'s `message`-event listener — the unified
203
- * dispatcher that routes EVERY inbound event on a hostable scope, portless or
204
- * port-bearing, to the right binding.
205
- *
206
- * @remarks
207
- * Port-bearing events (`event.ports.length > 0`) are gated by `options.accept` FIRST
208
- * — when the gate returns `false` the event is dropped entirely (no binding, no reply).
209
- * Accepted events spawn a fresh `MessagePortTransport` over `event.ports[0]`,
210
- * `bindServer` `server` onto it, and record a teardown (`unbind` then `transport.close()`)
211
- * into `teardowns`. A port that was already seen is IGNORED — repeated delivery of the
212
- * same `MessagePort` would create duplicate bindings over one port (→ duplicated replies),
213
- * so the listener tracks seen ports and silently drops repeats.
214
- *
215
- * This branch fires on EITHER a Service-Worker-shaped scope (its normal per-client
216
- * channel) or a dedicated-worker-shaped one that happens to receive a port-bearing event
217
- * (the unified design's deliberate cross-case, needing no upfront shape flag). An event
218
- * with NO ports and a STRING `data` is pushed onto `scopeTransport.deliver` (the
219
- * implicit, already-bound scope channel); any other event (no ports, non-string data)
220
- * is silently dropped — total (§14), never throws.
221
- *
222
- * @param server - The `MCPServerInterface` every spawned/implicit binding dispatches over
223
- * @param scopeTransport - The implicit scope channel (already `bindServer`-bound) portless events deliver onto
224
- * @param teardowns - The shared teardown set `serveMCPScope`'s dispose drains; each port-bearing event adds one entry
225
- * @param options - The `ServeMCPOptions` (for `options.accept`)
226
- * @returns The `message`-event listener to register (and later remove) on the scope
227
- *
228
- * @example
229
- * ```ts
230
- * const teardowns = new Set<() => void>()
231
- * const scopeTransport = createScopeTransport(scope)
232
- * bindServer(server, scopeTransport)
233
- * const onMessage = createScopeMessageListener(server, scopeTransport, teardowns, options)
234
- * scope.addEventListener('message', onMessage)
235
- * ```
236
- */
237
- function createScopeMessageListener(server, scopeTransport, teardowns, options) {
238
- const seen = /* @__PURE__ */ new Set();
239
- return (event) => {
240
- const ports = event.ports;
241
- if (ports.length > 0) {
242
- if (options.accept !== void 0 && !options.accept(event)) return;
243
- const port = ports[0];
244
- if (port === void 0) return;
245
- if (seen.has(port)) return;
246
- seen.add(port);
247
- const transport = new MessagePortTransport({ port });
248
- const unbind = bindServer(server, transport);
249
- teardowns.add(() => {
250
- unbind();
251
- transport.close();
252
- });
253
- return;
254
- }
255
- if (isString(event.data)) scopeTransport.deliver(event.data);
256
- };
257
- }
258
- //#endregion
259
45
  //#region src/browser/transports/HTTPClientTransport.ts
260
46
  /**
261
47
  * The browser-face HTTP CLIENT transport for the Model Context Protocol — a
@@ -391,6 +177,102 @@ var HTTPClientTransport = class {
391
177
  }
392
178
  };
393
179
  //#endregion
180
+ //#region src/browser/transports/MessagePortTransport.ts
181
+ /**
182
+ * The browser-face `MessagePort` transport for the Model Context Protocol — a
183
+ * {@link MCPTransportInterface} over a native `MessagePort`, the genuinely new
184
+ * capability this face adds: MCP over `postMessage`.
185
+ *
186
+ * @remarks
187
+ * - **Symmetric.** Unlike {@link import('./WebSocketClientTransport.js').WebSocketClientTransport}
188
+ * / {@link import('./HTTPClientTransport.js').HTTPClientTransport} (CLIENT-only
189
+ * carriers of `@src/core`'s `MCPClientTransportInterface`), a `MessagePort` is a
190
+ * plain duplex channel — the SAME class implements `@src/core`'s
191
+ * `MCPTransportInterface` and is handed to EITHER `bindServer` or
192
+ * `bindClient`/`createDuplexClientTransport`; which role it plays comes entirely
193
+ * from the binder it is given to, not from anything this class decides.
194
+ * - **`start()` at construction — bind synchronously.** `MessagePort.start()` is only
195
+ * REQUIRED when listening via `addEventListener` (as opposed to the `onmessage`
196
+ * setter, which implies it) — this transport uses `addEventListener`, and
197
+ * `MCPTransportInterface` has no separate open/connect step for the caller to hook
198
+ * a start into, so the constructor calls `port.start()` immediately: the port
199
+ * begins dispatching QUEUED messages the moment the transport exists. This is safe
200
+ * inside `serveMCP`'s flow (the transport is synchronously handed to `bindServer`
201
+ * before control returns to the event loop), but is a **footgun for direct use**:
202
+ * if you construct `new MessagePortTransport({ port })` and then `await` anything
203
+ * before calling `listen`, messages that arrived in the gap are DROPPED. **Bind
204
+ * synchronously after construction** — do not interleave an `await` between
205
+ * `new MessagePortTransport(…)` and `bindServer` / `listen`.
206
+ * - **String payloads only.** `send` posts the message string as-is (`postMessage`
207
+ * structured-clones it — a string clones to an identical string, so the wire stays
208
+ * plain JSON-RPC text like every other transport in this package). Inbound: a
209
+ * non-string `event.data` (a host or a misbehaving peer posting a structured
210
+ * object) is IGNORED — dropped silently, never forwarded, never thrown (§14) —
211
+ * because `MCPTransportInterface` carries no `error` channel for this port to
212
+ * surface a non-string frame on (unlike `MCPClientTransportInterface`'s `emitter`);
213
+ * silently ignoring is the total, contract-shaped choice.
214
+ * - **`messageerror` is IGNORED, not routed to `closed`.** A `messageerror` event
215
+ * (the structured-clone deserialization of an inbound message threw) reports one
216
+ * BAD FRAME, not a dead channel — the port itself keeps working and later, well-
217
+ * formed messages still arrive. Routing it to `closed` would tear down the
218
+ * `bindServer`/`bindClient` wiring (and, transitively, every session it carries)
219
+ * over a single malformed frame, which is far more destructive than dropping that
220
+ * one frame — so this transport registers a `messageerror` listener that does
221
+ * nothing, deliberately.
222
+ * - **`close()`** is idempotent: it closes the underlying `port` (`MessagePort.close()`
223
+ * disconnects it — further `postMessage` calls on EITHER end are silently
224
+ * undelivered, per the platform contract) and fires the registered `closed`
225
+ * handler exactly once, whether the caller closes it once or twice. There is no
226
+ * native "peer closed" signal for a `MessagePort` (unlike a WebSocket's `close`
227
+ * event) — `closed` fires ONLY from this transport's own `close()`.
228
+ * - **Single-handler-replace (the port contract, `@src/core`'s `MCPTransportInterface`
229
+ * doc).** `listen`/`closed` each hold the ONE currently registered handler; a
230
+ * second call REPLACES the first rather than adding a second subscriber.
231
+ *
232
+ * @example
233
+ * ```ts
234
+ * const { port1, port2 } = new MessageChannel()
235
+ * const serverTransport = new MessagePortTransport({ port: port1 })
236
+ * bindServer(server, serverTransport) // port1 side dispatches inbound requests
237
+ *
238
+ * const clientTransport = new MessagePortTransport({ port: port2 })
239
+ * const client = createMCPClient({ transport: createDuplexClientTransport(clientTransport) })
240
+ * bindClient(client, clientTransport) // port2 side is the client's carrier
241
+ * ```
242
+ */
243
+ var MessagePortTransport = class {
244
+ #port;
245
+ #onMessage = void 0;
246
+ #onClosed = void 0;
247
+ #closed = false;
248
+ constructor(options) {
249
+ this.#port = options.port;
250
+ this.#port.addEventListener("message", (event) => this.#receive(event.data));
251
+ this.#port.addEventListener("messageerror", () => {});
252
+ this.#port.start();
253
+ }
254
+ send(message) {
255
+ if (this.#closed) return;
256
+ this.#port.postMessage(message);
257
+ }
258
+ listen(handler) {
259
+ this.#onMessage = handler;
260
+ }
261
+ closed(handler) {
262
+ this.#onClosed = handler;
263
+ }
264
+ close() {
265
+ if (this.#closed) return;
266
+ this.#closed = true;
267
+ this.#port.close();
268
+ this.#onClosed?.();
269
+ }
270
+ #receive(data) {
271
+ if (!isString(data)) return;
272
+ this.#onMessage?.(data);
273
+ }
274
+ };
275
+ //#endregion
394
276
  //#region src/browser/transports/WebSocketClientTransport.ts
395
277
  /**
396
278
  * The browser-face WebSocket CLIENT transport for the Model Context Protocol — a
@@ -671,6 +553,124 @@ function createScopeTransport(scope) {
671
553
  }
672
554
  };
673
555
  }
556
+ //#endregion
557
+ //#region src/browser/helpers.ts
558
+ /**
559
+ * Decode one SSE event's `data` string into a {@link JSONRPCMessage}, or `undefined`
560
+ * when it is not one — the per-event step {@link readEventStream} folds over.
561
+ *
562
+ * @remarks
563
+ * `JSON.parse`s the `data` (the server serializes the JSON-RPC envelope as the
564
+ * event's `data`) inside a try/catch and narrows the parsed value with
565
+ * `parseJSONRPCMessage`. Total (§14): malformed JSON or a non-message value yields
566
+ * `undefined`, never throws.
567
+ *
568
+ * @param data - One SSE event's `data` payload
569
+ * @returns The decoded {@link JSONRPCMessage}, or `undefined`
570
+ */
571
+ function decodeEvent(data) {
572
+ try {
573
+ return parseJSONRPCMessage(JSON.parse(data));
574
+ } catch {
575
+ return;
576
+ }
577
+ }
578
+ /**
579
+ * Decode a `fetch` Response's Server-Sent-Events body into the JSON-RPC messages it
580
+ * carried — the CLIENT-side inverse of the server's Streamable-HTTP SSE response.
581
+ *
582
+ * @remarks
583
+ * Reads the whole `response.body` stream chunk-by-chunk through a `TextDecoder({
584
+ * stream: true })` (handling a multi-byte char split across reads) and
585
+ * `@orkestrel/sse`'s {@link SSEParserInterface} (handling a partial line / in-progress
586
+ * event split across reads), then narrows each dispatched event's `data` to a
587
+ * {@link JSONRPCMessage} via {@link decodeEvent} (so a non-message / non-JSON `data:`
588
+ * event is DROPPED, never thrown — total, §14). A `null` body (no stream) yields no
589
+ * messages; {@link import('./transports/HTTPClientTransport.js').HTTPClientTransport}
590
+ * reads a request/response SSE reply (the server sends one `data:` event then ends),
591
+ * so this drains to completion.
592
+ *
593
+ * @param response - The SSE `fetch` Response to decode (its `body` is read to completion)
594
+ * @returns Every {@link JSONRPCMessage} the stream carried, in order
595
+ */
596
+ async function readEventStream(response) {
597
+ const body = response.body;
598
+ if (body === null) return [];
599
+ const reader = body.getReader();
600
+ const decoder = new TextDecoder();
601
+ const parser = createSSEParser();
602
+ const messages = [];
603
+ try {
604
+ for (;;) {
605
+ const { done, value } = await reader.read();
606
+ if (done) break;
607
+ for (const event of parser.parse(decoder.decode(value, { stream: true }))) {
608
+ const message = decodeEvent(event.data);
609
+ if (message !== void 0) messages.push(message);
610
+ }
611
+ }
612
+ } finally {
613
+ reader.releaseLock();
614
+ }
615
+ return messages;
616
+ }
617
+ /**
618
+ * Build `serveMCPScope`'s `message`-event listener — the unified
619
+ * dispatcher that routes EVERY inbound event on a hostable scope, portless or
620
+ * port-bearing, to the right binding.
621
+ *
622
+ * @remarks
623
+ * Port-bearing events (`event.ports.length > 0`) are gated by `options.accept` FIRST
624
+ * — when the gate returns `false` the event is dropped entirely (no binding, no reply).
625
+ * Accepted events spawn a fresh `MessagePortTransport` over `event.ports[0]`,
626
+ * `bindServer` `server` onto it, and record a teardown (`unbind` then `transport.close()`)
627
+ * into `teardowns`. A port that was already seen is IGNORED — repeated delivery of the
628
+ * same `MessagePort` would create duplicate bindings over one port (→ duplicated replies),
629
+ * so the listener tracks seen ports and silently drops repeats.
630
+ *
631
+ * This branch fires on EITHER a Service-Worker-shaped scope (its normal per-client
632
+ * channel) or a dedicated-worker-shaped one that happens to receive a port-bearing event
633
+ * (the unified design's deliberate cross-case, needing no upfront shape flag). An event
634
+ * with NO ports and a STRING `data` is pushed onto `scopeTransport.deliver` (the
635
+ * implicit, already-bound scope channel); any other event (no ports, non-string data)
636
+ * is silently dropped — total (§14), never throws.
637
+ *
638
+ * @param server - The `MCPServerInterface` every spawned/implicit binding dispatches over
639
+ * @param scopeTransport - The implicit scope channel (already `bindServer`-bound) portless events deliver onto
640
+ * @param teardowns - The shared teardown set `serveMCPScope`'s dispose drains; each port-bearing event adds one entry
641
+ * @param options - The `ServeMCPOptions` (for `options.accept`)
642
+ * @returns The `message`-event listener to register (and later remove) on the scope
643
+ *
644
+ * @example
645
+ * ```ts
646
+ * const teardowns = new Set<() => void>()
647
+ * const scopeTransport = createScopeTransport(scope)
648
+ * bindServer(server, scopeTransport)
649
+ * const onMessage = createScopeMessageListener(server, scopeTransport, teardowns, options)
650
+ * scope.addEventListener('message', onMessage)
651
+ * ```
652
+ */
653
+ function createScopeMessageListener(server, scopeTransport, teardowns, options) {
654
+ const seen = /* @__PURE__ */ new Set();
655
+ return (event) => {
656
+ const ports = event.ports;
657
+ if (ports.length > 0) {
658
+ if (options.accept !== void 0 && !options.accept(event)) return;
659
+ const port = ports[0];
660
+ if (port === void 0) return;
661
+ if (seen.has(port)) return;
662
+ seen.add(port);
663
+ const transport = new MessagePortTransport({ port });
664
+ const unbind = bindServer(server, transport);
665
+ teardowns.add(() => {
666
+ unbind();
667
+ transport.close();
668
+ });
669
+ return;
670
+ }
671
+ if (isString(event.data)) scopeTransport.deliver(event.data);
672
+ };
673
+ }
674
674
  /**
675
675
  * Boot an `MCPServer` inside a hostable worker scope and wire its message events to it.
676
676
  *
@@ -1602,13 +1602,21 @@ function createHTTPClientTransport(options) {
1602
1602
  * as a frame — a NOTIFICATION sends nothing, and a non-request message (a stray response) is
1603
1603
  * ignored. A `dispatch` / `send` fault surfaces on `mcp.emitter`'s `error` event rather than
1604
1604
  * escaping the (async) message pump.
1605
+ * - **Closes on the spine's `stop`.** It holds every socket it claimed and, on `options.emitter`'s
1606
+ * `stop` event, closes each one with the RFC 6455 close handshake, so the spine's drain settles
1607
+ * at once and each client reads a clean goodbye. Node detaches an upgraded socket from the
1608
+ * connection set the spine's own close walks, so the claimant is the only thing that can end
1609
+ * it: an ingress that held its sockets open would cost `stop()` the whole `drain` budget and
1610
+ * then have the connection cut mid-protocol. A socket the peer already dropped is gone from
1611
+ * the set (its transport's `close` removes it), and closing a dead one is a no-op either way.
1605
1612
  *
1606
1613
  * It is MECHANISM, not policy: compose an auth guard IN FRONT by registering an upgrade
1607
1614
  * handler BEFORE this one — that handler can claim (decline + destroy) an unauthenticated
1608
1615
  * upgrade so it never reaches this pump.
1609
1616
  *
1610
1617
  * @param mcp - The transport-agnostic {@link MCPDispatcherInterface} to expose over WebSocket
1611
- * @param options - Optional `path` (default {@link DEFAULT_MCP_PATH}) and `subprotocol`
1618
+ * @param options - The spine's `emitter` (REQUIRED the `stop` event this ingress closes its
1619
+ * sockets on), plus optional `path` (default {@link DEFAULT_MCP_PATH}) and `subprotocol`
1612
1620
  * (default {@link MCP_WEBSOCKET_SUBPROTOCOL}); see {@link WebSocketServerOptions}
1613
1621
  * @returns An {@link UpgradeHandler} to register with the spine's `upgrade` seam
1614
1622
  *
@@ -1618,12 +1626,16 @@ function createHTTPClientTransport(options) {
1618
1626
  * import { createWebSocketServer } from '@src/server'
1619
1627
  *
1620
1628
  * const mcp = createMCPServer({ identity: { name: 'docs', version: '1.0.0' }, tools: createToolManager() })
1621
- * server.upgrade(createWebSocketServer(mcp)) // an MCP client now connects over ws://…/mcp
1629
+ * server.upgrade(createWebSocketServer(mcp, { emitter: server.emitter })) // ws://…/mcp
1622
1630
  * ```
1623
1631
  */
1624
1632
  function createWebSocketServer(mcp, options) {
1625
- const path = options?.path ?? "/mcp";
1626
- const subprotocol = options?.subprotocol ?? "mcp";
1633
+ const path = options.path ?? "/mcp";
1634
+ const subprotocol = options.subprotocol ?? "mcp";
1635
+ const live = /* @__PURE__ */ new Set();
1636
+ options.emitter.on("stop", () => {
1637
+ for (const transport of live) transport.close();
1638
+ });
1627
1639
  return (request, socket, head) => {
1628
1640
  const upgrade = request.headers["upgrade"];
1629
1641
  if (!(0, _orkestrel_contract.isString)(upgrade) || upgrade.toLowerCase() !== "websocket") return false;
@@ -1638,6 +1650,8 @@ function createWebSocketServer(mcp, options) {
1638
1650
  head,
1639
1651
  protocol: subprotocol
1640
1652
  }));
1653
+ live.add(transport);
1654
+ transport.emitter.on("close", () => live.delete(transport));
1641
1655
  (0, _src_core.bindServer)(mcp, bridgeMessageTransport(transport));
1642
1656
  transport.start();
1643
1657
  return true;
@@ -18,6 +18,7 @@ import { MiddlewareHandler } from '@orkestrel/server';
18
18
  import { NodeWebSocketInterface } from '@orkestrel/websocket';
19
19
  import { RouteContext } from '@orkestrel/router';
20
20
  import { RouteInput } from '@orkestrel/router';
21
+ import { ServerEventMap } from '@orkestrel/server';
21
22
  import { StreamInterface } from '@orkestrel/server';
22
23
  import { TokenSecret } from '@orkestrel/server';
23
24
  import { UpgradeHandler } from '@orkestrel/server';
@@ -445,13 +446,21 @@ export declare function createWebSocketClientTransport(options: WebSocketClientT
445
446
  * as a frame — a NOTIFICATION sends nothing, and a non-request message (a stray response) is
446
447
  * ignored. A `dispatch` / `send` fault surfaces on `mcp.emitter`'s `error` event rather than
447
448
  * escaping the (async) message pump.
449
+ * - **Closes on the spine's `stop`.** It holds every socket it claimed and, on `options.emitter`'s
450
+ * `stop` event, closes each one with the RFC 6455 close handshake, so the spine's drain settles
451
+ * at once and each client reads a clean goodbye. Node detaches an upgraded socket from the
452
+ * connection set the spine's own close walks, so the claimant is the only thing that can end
453
+ * it: an ingress that held its sockets open would cost `stop()` the whole `drain` budget and
454
+ * then have the connection cut mid-protocol. A socket the peer already dropped is gone from
455
+ * the set (its transport's `close` removes it), and closing a dead one is a no-op either way.
448
456
  *
449
457
  * It is MECHANISM, not policy: compose an auth guard IN FRONT by registering an upgrade
450
458
  * handler BEFORE this one — that handler can claim (decline + destroy) an unauthenticated
451
459
  * upgrade so it never reaches this pump.
452
460
  *
453
461
  * @param mcp - The transport-agnostic {@link MCPDispatcherInterface} to expose over WebSocket
454
- * @param options - Optional `path` (default {@link DEFAULT_MCP_PATH}) and `subprotocol`
462
+ * @param options - The spine's `emitter` (REQUIRED the `stop` event this ingress closes its
463
+ * sockets on), plus optional `path` (default {@link DEFAULT_MCP_PATH}) and `subprotocol`
455
464
  * (default {@link MCP_WEBSOCKET_SUBPROTOCOL}); see {@link WebSocketServerOptions}
456
465
  * @returns An {@link UpgradeHandler} to register with the spine's `upgrade` seam
457
466
  *
@@ -461,10 +470,10 @@ export declare function createWebSocketClientTransport(options: WebSocketClientT
461
470
  * import { createWebSocketServer } from '@src/server'
462
471
  *
463
472
  * const mcp = createMCPServer({ identity: { name: 'docs', version: '1.0.0' }, tools: createToolManager() })
464
- * server.upgrade(createWebSocketServer(mcp)) // an MCP client now connects over ws://…/mcp
473
+ * server.upgrade(createWebSocketServer(mcp, { emitter: server.emitter })) // ws://…/mcp
465
474
  * ```
466
475
  */
467
- export declare function createWebSocketServer(mcp: MCPDispatcherInterface, options?: WebSocketServerOptions): UpgradeHandler;
476
+ export declare function createWebSocketServer(mcp: MCPDispatcherInterface, options: WebSocketServerOptions): UpgradeHandler;
468
477
 
469
478
  /**
470
479
  * Decode one SSE event's `data` string into a {@link JSONRPCMessage}, or `undefined`
@@ -1427,10 +1436,16 @@ export declare interface WebSocketClientTransportOptions {
1427
1436
  }
1428
1437
 
1429
1438
  /**
1430
- * Options for `createWebSocketServer` — where the WebSocket upgrade is accepted and the
1431
- * subprotocol negotiated.
1439
+ * Options for `createWebSocketServer` — the spine lifecycle the ingress follows, plus where
1440
+ * the WebSocket upgrade is accepted and the subprotocol negotiated.
1432
1441
  *
1433
1442
  * @remarks
1443
+ * - `emitter` — the emitter of the `@orkestrel/server` spine this handler is registered on
1444
+ * (`server.emitter`). REQUIRED: on its `stop` event the handler closes every socket it
1445
+ * still owns with the RFC 6455 close handshake, so the spine's drain settles at once. An
1446
+ * upgraded socket is detached from the connection set the spine's own close walks, so
1447
+ * nothing but the claimant can end it — leave it open and `stop()` spends its whole
1448
+ * `drain` budget and then cuts the connection mid-protocol.
1434
1449
  * - `path` — the request path the upgrade handler CLAIMS; defaults to
1435
1450
  * {@link import('./constants.js').DEFAULT_MCP_PATH} (`'/mcp'`, the same path the HTTP
1436
1451
  * transport mounts at). A protocol-upgrade request to any OTHER path is DECLINED
@@ -1445,6 +1460,7 @@ export declare interface WebSocketClientTransportOptions {
1445
1460
  * before this one can decline an unauthenticated upgrade).
1446
1461
  */
1447
1462
  export declare interface WebSocketServerOptions {
1463
+ readonly emitter: EmitterInterface<ServerEventMap>;
1448
1464
  readonly path?: string;
1449
1465
  readonly subprotocol?: string;
1450
1466
  }
@@ -18,6 +18,7 @@ import { MiddlewareHandler } from '@orkestrel/server';
18
18
  import { NodeWebSocketInterface } from '@orkestrel/websocket';
19
19
  import { RouteContext } from '@orkestrel/router';
20
20
  import { RouteInput } from '@orkestrel/router';
21
+ import { ServerEventMap } from '@orkestrel/server';
21
22
  import { StreamInterface } from '@orkestrel/server';
22
23
  import { TokenSecret } from '@orkestrel/server';
23
24
  import { UpgradeHandler } from '@orkestrel/server';
@@ -445,13 +446,21 @@ export declare function createWebSocketClientTransport(options: WebSocketClientT
445
446
  * as a frame — a NOTIFICATION sends nothing, and a non-request message (a stray response) is
446
447
  * ignored. A `dispatch` / `send` fault surfaces on `mcp.emitter`'s `error` event rather than
447
448
  * escaping the (async) message pump.
449
+ * - **Closes on the spine's `stop`.** It holds every socket it claimed and, on `options.emitter`'s
450
+ * `stop` event, closes each one with the RFC 6455 close handshake, so the spine's drain settles
451
+ * at once and each client reads a clean goodbye. Node detaches an upgraded socket from the
452
+ * connection set the spine's own close walks, so the claimant is the only thing that can end
453
+ * it: an ingress that held its sockets open would cost `stop()` the whole `drain` budget and
454
+ * then have the connection cut mid-protocol. A socket the peer already dropped is gone from
455
+ * the set (its transport's `close` removes it), and closing a dead one is a no-op either way.
448
456
  *
449
457
  * It is MECHANISM, not policy: compose an auth guard IN FRONT by registering an upgrade
450
458
  * handler BEFORE this one — that handler can claim (decline + destroy) an unauthenticated
451
459
  * upgrade so it never reaches this pump.
452
460
  *
453
461
  * @param mcp - The transport-agnostic {@link MCPDispatcherInterface} to expose over WebSocket
454
- * @param options - Optional `path` (default {@link DEFAULT_MCP_PATH}) and `subprotocol`
462
+ * @param options - The spine's `emitter` (REQUIRED the `stop` event this ingress closes its
463
+ * sockets on), plus optional `path` (default {@link DEFAULT_MCP_PATH}) and `subprotocol`
455
464
  * (default {@link MCP_WEBSOCKET_SUBPROTOCOL}); see {@link WebSocketServerOptions}
456
465
  * @returns An {@link UpgradeHandler} to register with the spine's `upgrade` seam
457
466
  *
@@ -461,10 +470,10 @@ export declare function createWebSocketClientTransport(options: WebSocketClientT
461
470
  * import { createWebSocketServer } from '@src/server'
462
471
  *
463
472
  * const mcp = createMCPServer({ identity: { name: 'docs', version: '1.0.0' }, tools: createToolManager() })
464
- * server.upgrade(createWebSocketServer(mcp)) // an MCP client now connects over ws://…/mcp
473
+ * server.upgrade(createWebSocketServer(mcp, { emitter: server.emitter })) // ws://…/mcp
465
474
  * ```
466
475
  */
467
- export declare function createWebSocketServer(mcp: MCPDispatcherInterface, options?: WebSocketServerOptions): UpgradeHandler;
476
+ export declare function createWebSocketServer(mcp: MCPDispatcherInterface, options: WebSocketServerOptions): UpgradeHandler;
468
477
 
469
478
  /**
470
479
  * Decode one SSE event's `data` string into a {@link JSONRPCMessage}, or `undefined`
@@ -1427,10 +1436,16 @@ export declare interface WebSocketClientTransportOptions {
1427
1436
  }
1428
1437
 
1429
1438
  /**
1430
- * Options for `createWebSocketServer` — where the WebSocket upgrade is accepted and the
1431
- * subprotocol negotiated.
1439
+ * Options for `createWebSocketServer` — the spine lifecycle the ingress follows, plus where
1440
+ * the WebSocket upgrade is accepted and the subprotocol negotiated.
1432
1441
  *
1433
1442
  * @remarks
1443
+ * - `emitter` — the emitter of the `@orkestrel/server` spine this handler is registered on
1444
+ * (`server.emitter`). REQUIRED: on its `stop` event the handler closes every socket it
1445
+ * still owns with the RFC 6455 close handshake, so the spine's drain settles at once. An
1446
+ * upgraded socket is detached from the connection set the spine's own close walks, so
1447
+ * nothing but the claimant can end it — leave it open and `stop()` spends its whole
1448
+ * `drain` budget and then cuts the connection mid-protocol.
1434
1449
  * - `path` — the request path the upgrade handler CLAIMS; defaults to
1435
1450
  * {@link import('./constants.js').DEFAULT_MCP_PATH} (`'/mcp'`, the same path the HTTP
1436
1451
  * transport mounts at). A protocol-upgrade request to any OTHER path is DECLINED
@@ -1445,6 +1460,7 @@ export declare interface WebSocketClientTransportOptions {
1445
1460
  * before this one can decline an unauthenticated upgrade).
1446
1461
  */
1447
1462
  export declare interface WebSocketServerOptions {
1463
+ readonly emitter: EmitterInterface<ServerEventMap>;
1448
1464
  readonly path?: string;
1449
1465
  readonly subprotocol?: string;
1450
1466
  }