@orkestrel/mcp 0.0.14 → 0.0.15

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
  *