@orkestrel/mcp 0.0.19 → 0.0.21

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.
@@ -33,8 +33,8 @@ var DEFAULT_MCP_SERVER_NAME = "taverna";
33
33
  var DEFAULT_MCP_SERVER_VERSION = "1.0.0";
34
34
  /**
35
35
  * The WebSocket subprotocol `createWebSocketClientTransport` requests by default —
36
- * `'mcp'`, matching `createWebSocketServer`'s unconditional `Sec-WebSocket-Protocol:
37
- * mcp` echo. Per RFC 6455 §4.1 a client MUST fail the connection if the server returns
36
+ * `'mcp'`, which `createWebSocketServer` selects when the client offers it. Per RFC 6455
37
+ * §4.1 a client MUST fail the connection if the server returns
38
38
  * a subprotocol it did not request; Node ≥ 22 (undici) enforces this strictly, so the
39
39
  * default bakes the correct value in. Override `WebSocketClientTransportOptions.protocols`
40
40
  * only when connecting to a foreign server that speaks a different subprotocol (or `[]`
@@ -47,7 +47,7 @@ var MCP_WEBSOCKET_SUBPROTOCOL = "mcp";
47
47
  * The browser-face HTTP CLIENT transport for the Model Context Protocol — a
48
48
  * {@link MCPClientTransportInterface} that drives a REMOTE Streamable-HTTP MCP server
49
49
  * over the native `fetch`, the browser sibling of the Node face's
50
- * {@link import('@src/server').HTTPClientTransport}, honoring the SAME
50
+ * {@link import('@orkestrel/mcp/server').HTTPClientTransport}, honoring the SAME
51
51
  * `mcp-session-id` semantics so it interoperates with an `MCPSession`-based server
52
52
  * unchanged.
53
53
  *
@@ -55,12 +55,12 @@ var MCP_WEBSOCKET_SUBPROTOCOL = "mcp";
55
55
  * - **Request/response over `fetch`.** `send(message)` POSTs the JSON-serialized
56
56
  * message to `options.url` with `content-type: application/json` and an
57
57
  * `Accept` of BOTH `application/json` and `text/event-stream` (so the server may
58
- * answer with either framing) — plus any `options.headers` (e.g. an
58
+ * answer with either framing) — plus any `options.headers` (for example, an
59
59
  * `Authorization` bearer). It then decodes the reply and emits each decoded
60
60
  * {@link JSONRPCMessage} on the `message` event the
61
- * {@link import('@src/core').MCPClientInterface} subscribes to.
61
+ * {@link import('@orkestrel/mcp').MCPClientInterface} subscribes to.
62
62
  * - **Both reply framings.** A `200` with an `application/json` body is parsed with
63
- * `parseJSONRPCMessage`; a `200` with a `text/event-stream` body is decoded via the
63
+ * `parseJSONRPCMessage`; a `200` with a `text/event-stream` body is decoded with the
64
64
  * `@orkestrel/sse` {@link import('@orkestrel/sse').SSEParserInterface} (the browser
65
65
  * face's own `readEventStream`) — the inverse of the server's `openStream` seam, so
66
66
  * the wire round-trips. A `202` Accepted (a notification) carries no body and emits
@@ -78,10 +78,15 @@ var MCP_WEBSOCKET_SUBPROTOCOL = "mcp";
78
78
  * Before initialize returns, neither captured legacy header is sent.
79
79
  * `close()` clears the captured protocol so a reconnect's `initialize`
80
80
  * POST is headerless; the captured `session` persists across `close()`.
81
- * - **Total at the boundary (§14).** Every reply is narrowed (`parseJSONRPCMessage`,
81
+ * - **`close()` releases what is in flight.** Every `fetch` this transport still has open is
82
+ * ABORTED, which cancels the response body a `send` is reading — an SSE reply the server
83
+ * never ends would otherwise outlive the transport, with nothing left able to reach it. The
84
+ * aborted read surfaces on `error` and the `send` reporting it resolves. `close()` is
85
+ * idempotent (one `close` event per connected lifetime), and `start()` opens the next one.
86
+ * - **Total at the boundary.** Every reply is narrowed (`parseJSONRPCMessage`,
82
87
  * the SSE decoder) — a non-message reply is dropped, never asserted; a `fetch` /
83
88
  * decode failure surfaces on the `error` event rather than escaping `send`.
84
- * - **Observable (§13).** Owns the `emitter` ({@link MCPClientTransportEventMap}); fires
89
+ * - **Observable.** Owns the `emitter` ({@link MCPClientTransportEventMap}); fires
85
90
  * `message` per decoded reply, `error` on a fault, and `close` on `close()`.
86
91
  *
87
92
  * @example
@@ -97,8 +102,10 @@ var HTTPClientTransport = class {
97
102
  #headers;
98
103
  #fetch;
99
104
  #timeout;
105
+ #pending = /* @__PURE__ */ new Set();
100
106
  #session = void 0;
101
107
  #protocol = void 0;
108
+ #closed = false;
102
109
  constructor(options) {
103
110
  this.#emitter = new Emitter();
104
111
  this.#url = options.url;
@@ -115,8 +122,19 @@ var HTTPClientTransport = class {
115
122
  get duplex() {
116
123
  return false;
117
124
  }
118
- async start() {}
125
+ async start() {
126
+ this.#closed = false;
127
+ }
119
128
  async send(message) {
129
+ const request = new AbortController();
130
+ this.#pending.add(request);
131
+ try {
132
+ await this.#exchange(message, request.signal);
133
+ } finally {
134
+ this.#pending.delete(request);
135
+ }
136
+ }
137
+ async #exchange(message, signal) {
120
138
  let response;
121
139
  try {
122
140
  response = await this.#fetch(this.#url, {
@@ -129,7 +147,7 @@ var HTTPClientTransport = class {
129
147
  ...this.#headers
130
148
  },
131
149
  body: JSON.stringify(message),
132
- ...this.#timeout === void 0 ? {} : { signal: AbortSignal.timeout(this.#timeout) }
150
+ signal: this.#timeout === void 0 ? signal : AbortSignal.any([signal, AbortSignal.timeout(this.#timeout)])
133
151
  });
134
152
  } catch (error) {
135
153
  this.#emitter.emit("error", error);
@@ -140,6 +158,10 @@ var HTTPClientTransport = class {
140
158
  await this.#deliver(response);
141
159
  }
142
160
  async close() {
161
+ if (this.#closed) return;
162
+ this.#closed = true;
163
+ for (const request of this.#pending) request.abort();
164
+ this.#pending.clear();
143
165
  this.#protocol = void 0;
144
166
  this.#emitter.emit("close");
145
167
  }
@@ -186,13 +208,13 @@ var HTTPClientTransport = class {
186
208
  * @remarks
187
209
  * - **Symmetric.** Unlike {@link import('./WebSocketClientTransport.js').WebSocketClientTransport}
188
210
  * / {@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
211
+ * carriers of `@orkestrel/mcp`'s `MCPClientTransportInterface`), a `MessagePort` is a
212
+ * plain duplex channel — the SAME class implements `@orkestrel/mcp`'s
191
213
  * `MCPTransportInterface` and is handed to EITHER `bindServer` or
192
214
  * `bindClient`/`createDuplexClientTransport`; which role it plays comes entirely
193
215
  * from the binder it is given to, not from anything this class decides.
194
216
  * - **`start()` at construction — bind synchronously.** `MessagePort.start()` is only
195
- * REQUIRED when listening via `addEventListener` (as opposed to the `onmessage`
217
+ * REQUIRED when listening with `addEventListener` (as opposed to the `onmessage`
196
218
  * setter, which implies it) — this transport uses `addEventListener`, and
197
219
  * `MCPTransportInterface` has no separate open/connect step for the caller to hook
198
220
  * a start into, so the constructor calls `port.start()` immediately: the port
@@ -207,7 +229,7 @@ var HTTPClientTransport = class {
207
229
  * structured-clones it — a string clones to an identical string, so the wire stays
208
230
  * plain JSON-RPC text like every other transport in this package). Inbound: a
209
231
  * non-string `event.data` (a host or a misbehaving peer posting a structured
210
- * object) is IGNORED — dropped silently, never forwarded, never thrown (§14)
232
+ * object) is IGNORED — dropped silently, never forwarded, never thrown —
211
233
  * because `MCPTransportInterface` carries no `error` channel for this port to
212
234
  * surface a non-string frame on (unlike `MCPClientTransportInterface`'s `emitter`);
213
235
  * silently ignoring is the total, contract-shaped choice.
@@ -225,8 +247,8 @@ var HTTPClientTransport = class {
225
247
  * handler exactly once, whether the caller closes it once or twice. There is no
226
248
  * native "peer closed" signal for a `MessagePort` (unlike a WebSocket's `close`
227
249
  * 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
250
+ * - **Single-handler-replace (the port contract, `@orkestrel/mcp`'s `MCPTransportInterface`
251
+ * doc).** `listen`/`closed` each hold the one active handler; a
230
252
  * second call REPLACES the first rather than adding a second subscriber.
231
253
  *
232
254
  * @example
@@ -242,13 +264,15 @@ var HTTPClientTransport = class {
242
264
  */
243
265
  var MessagePortTransport = class {
244
266
  #port;
267
+ #message = (event) => this.#receive(event.data);
268
+ #malformed = () => {};
245
269
  #onMessage = void 0;
246
270
  #onClosed = void 0;
247
271
  #closed = false;
248
272
  constructor(options) {
249
273
  this.#port = options.port;
250
- this.#port.addEventListener("message", (event) => this.#receive(event.data));
251
- this.#port.addEventListener("messageerror", () => {});
274
+ this.#port.addEventListener("message", this.#message);
275
+ this.#port.addEventListener("messageerror", this.#malformed);
252
276
  this.#port.start();
253
277
  }
254
278
  send(message) {
@@ -264,8 +288,13 @@ var MessagePortTransport = class {
264
288
  close() {
265
289
  if (this.#closed) return;
266
290
  this.#closed = true;
291
+ const onClosed = this.#onClosed;
292
+ this.#onMessage = void 0;
293
+ this.#onClosed = void 0;
294
+ this.#port.removeEventListener("message", this.#message);
295
+ this.#port.removeEventListener("messageerror", this.#malformed);
267
296
  this.#port.close();
268
- this.#onClosed?.();
297
+ onClosed?.();
269
298
  }
270
299
  #receive(data) {
271
300
  if (!isString(data)) return;
@@ -278,7 +307,7 @@ var MessagePortTransport = class {
278
307
  * The browser-face WebSocket CLIENT transport for the Model Context Protocol — a
279
308
  * {@link MCPClientTransportInterface} that drives a REMOTE MCP server over the native
280
309
  * `WebSocket` global, the browser sibling of the Node face's
281
- * {@link import('@src/server').WebSocketClientTransport}.
310
+ * {@link import('@orkestrel/mcp/server').WebSocketClientTransport}.
282
311
  *
283
312
  * @remarks
284
313
  * - **Host-performed handshake.** `start()` opens `new WebSocket(url, protocols)` and
@@ -293,15 +322,16 @@ var MessagePortTransport = class {
293
322
  * - **Inbound (`message`).** Each decoded text frame is `JSON.parse`d (guarded) and
294
323
  * narrowed with `parseJSONRPCMessage` — a well-formed {@link JSONRPCMessage}
295
324
  * re-emits on this transport's `message` event; a non-text (binary) frame or a
296
- * non-JSON / non-message text frame surfaces on `error` and is DROPPED (§14 — never
325
+ * non-JSON / non-message text frame surfaces on `error` and is DROPPED (never
297
326
  * throws on adversarial wire input).
298
- * - **`close()`** closes the underlying socket and fires `close` (idempotent); the
299
- * socket's native `close` event (a server-initiated close) fires the SAME `close`
300
- * exactly once total — `close()` first flips the guard, so the native event never
301
- * double-emits. **This transport is not reusable after `close()`** a `send` issued
302
- * after `close()` is silently dropped (not queued, not delivered even on a later
303
- * `start()`).
304
- * - **Observable (§13).** Owns the `emitter` ({@link MCPClientTransportEventMap}); every
327
+ * - **`close()`** unsubscribes from the underlying socket, closes it, and fires `close`
328
+ * (idempotent); the socket's native `close` event (a server-initiated close) fires the
329
+ * SAME `close` exactly once total — `close()` first flips the guard, so the native event
330
+ * never double-emits, and the released socket reports its own close to nobody. Closing before
331
+ * the socket opens resolves the pending `start()` rather than leaving it pending, matching the
332
+ * Node face. A `send` issued after `close()` is silently dropped (not queued), so a closed
333
+ * transport delivers nothing until a `start()` opens a new connection.
334
+ * - **Observable.** Owns the `emitter` ({@link MCPClientTransportEventMap}); every
305
335
  * emit the emitter isolates a listener throw; `error` is a DOMAIN event (a
306
336
  * transport-level fault).
307
337
  *
@@ -316,7 +346,15 @@ var WebSocketClientTransport = class {
316
346
  #emitter;
317
347
  #url;
318
348
  #protocols;
349
+ #frame = (event) => this.#receive(event.data);
350
+ #ending = () => this.#onClose();
351
+ #failure = (event) => this.#emitter.emit("error", event);
352
+ #opening = () => this.#onOpen();
353
+ #rejection = () => this.#onHandshakeError();
319
354
  #socket = void 0;
355
+ #handshake = void 0;
356
+ #resolve = void 0;
357
+ #reject = void 0;
320
358
  #queue = [];
321
359
  #closed = false;
322
360
  constructor(options) {
@@ -339,16 +377,11 @@ var WebSocketClientTransport = class {
339
377
  this.#socket = socket;
340
378
  this.#bind(socket);
341
379
  await new Promise((resolve, reject) => {
342
- socket.addEventListener("open", () => {
343
- this.#flush(socket);
344
- resolve();
345
- }, { once: true });
346
- socket.addEventListener("error", () => {
347
- if (socket.readyState !== WebSocket.OPEN) {
348
- this.#socket = void 0;
349
- reject(/* @__PURE__ */ new Error("WebSocket connection failed"));
350
- }
351
- }, { once: true });
380
+ this.#handshake = socket;
381
+ this.#resolve = resolve;
382
+ this.#reject = reject;
383
+ socket.addEventListener("open", this.#opening);
384
+ socket.addEventListener("error", this.#rejection);
352
385
  });
353
386
  }
354
387
  async send(message) {
@@ -362,18 +395,55 @@ var WebSocketClientTransport = class {
362
395
  if (this.#closed) return;
363
396
  this.#closed = true;
364
397
  const socket = this.#socket;
398
+ const resolve = this.#resolve;
399
+ this.#releaseHandshake();
400
+ this.#release();
365
401
  this.#socket = void 0;
366
402
  if (socket !== void 0) socket.close();
367
403
  this.#emitter.emit("close");
404
+ resolve?.();
368
405
  }
369
406
  #bind(socket) {
370
- socket.addEventListener("message", (event) => this.#receive(event.data));
371
- socket.addEventListener("close", () => this.#onClose());
372
- socket.addEventListener("error", (event) => this.#emitter.emit("error", event));
407
+ socket.addEventListener("message", this.#frame);
408
+ socket.addEventListener("close", this.#ending);
409
+ socket.addEventListener("error", this.#failure);
410
+ }
411
+ #release() {
412
+ const socket = this.#socket;
413
+ if (socket === void 0) return;
414
+ socket.removeEventListener("message", this.#frame);
415
+ socket.removeEventListener("close", this.#ending);
416
+ socket.removeEventListener("error", this.#failure);
417
+ }
418
+ #releaseHandshake() {
419
+ const socket = this.#handshake;
420
+ if (socket === void 0) return;
421
+ socket.removeEventListener("open", this.#opening);
422
+ socket.removeEventListener("error", this.#rejection);
423
+ this.#handshake = void 0;
424
+ this.#resolve = void 0;
425
+ this.#reject = void 0;
373
426
  }
374
427
  #flush(socket) {
375
428
  for (const text of this.#queue.splice(0)) socket.send(text);
376
429
  }
430
+ #onOpen() {
431
+ const socket = this.#handshake;
432
+ const resolve = this.#resolve;
433
+ if (socket === void 0 || resolve === void 0) return;
434
+ this.#releaseHandshake();
435
+ this.#flush(socket);
436
+ resolve();
437
+ }
438
+ #onHandshakeError() {
439
+ const socket = this.#handshake;
440
+ const reject = this.#reject;
441
+ if (socket === void 0 || reject === void 0 || socket.readyState === WebSocket.OPEN) return;
442
+ this.#releaseHandshake();
443
+ this.#release();
444
+ this.#socket = void 0;
445
+ reject(/* @__PURE__ */ new Error("WebSocket connection failed"));
446
+ }
377
447
  #receive(data) {
378
448
  if (!isString(data)) {
379
449
  this.#emitter.emit("error", /* @__PURE__ */ new Error("non-text WebSocket frame"));
@@ -396,6 +466,7 @@ var WebSocketClientTransport = class {
396
466
  #onClose() {
397
467
  if (this.#closed) return;
398
468
  this.#closed = true;
469
+ this.#release();
399
470
  this.#socket = void 0;
400
471
  this.#emitter.emit("close");
401
472
  }
@@ -403,10 +474,10 @@ var WebSocketClientTransport = class {
403
474
  //#endregion
404
475
  //#region src/browser/factories.ts
405
476
  /**
406
- * Create the browser-face WebSocket CLIENT transport for an
407
- * {@link import('@src/core').MCPClientInterface} — a {@link MCPClientTransportInterface}
477
+ * Creates the browser-face WebSocket CLIENT transport for an
478
+ * {@link import('@orkestrel/mcp').MCPClientInterface} — a {@link MCPClientTransportInterface}
408
479
  * that drives a REMOTE MCP server over the native `WebSocket` global, the browser
409
- * sibling of the Node face's `createWebSocketClientTransport` (`@src/server`).
480
+ * sibling of the Node face's `createWebSocketClientTransport` (`@orkestrel/mcp/server`).
410
481
  *
411
482
  * @remarks
412
483
  * Hand it to `createMCPClient({ transport })`: `start()` (run by `client.connect()`)
@@ -437,18 +508,18 @@ function createWebSocketClientTransport(options) {
437
508
  return new WebSocketClientTransport(options);
438
509
  }
439
510
  /**
440
- * Create the browser-face HTTP CLIENT transport for an
441
- * {@link import('@src/core').MCPClientInterface} — a {@link MCPClientTransportInterface}
511
+ * Creates the browser-face HTTP CLIENT transport for an
512
+ * {@link import('@orkestrel/mcp').MCPClientInterface} — a {@link MCPClientTransportInterface}
442
513
  * that drives a REMOTE Streamable-HTTP MCP server over the native `fetch`, the
443
- * browser sibling of the Node face's `createHTTPClientTransport` (`@src/server`).
514
+ * browser sibling of the Node face's `createHTTPClientTransport` (`@orkestrel/mcp/server`).
444
515
  *
445
516
  * @remarks
446
517
  * Hand it to `createMCPClient({ transport })`: each JSON-RPC message the client
447
518
  * sends is `POST`ed to `options.url` with `content-type: application/json` and an
448
519
  * `Accept` of both `application/json` and `text/event-stream` (the server answers
449
520
  * with EITHER — a plain JSON envelope or a Streamable-HTTP SSE `data:` event,
450
- * decoded via `@orkestrel/sse`), and the reply is surfaced on the transport's
451
- * `message` event for the client's id correlation. Add `options.headers` (e.g. an
521
+ * decoded with `@orkestrel/sse`), and the reply is surfaced on the transport's
522
+ * `message` event for the client's id correlation. Add `options.headers` (for example, an
452
523
  * `Authorization` bearer) to reach a guarded server. `start` / `close` hold no
453
524
  * connection; against a STATEFUL server it captures the `mcp-session-id` from
454
525
  * `initialize` and echoes it on later requests. It also captures the initialize
@@ -459,7 +530,7 @@ function createWebSocketClientTransport(options) {
459
530
  *
460
531
  * @param options - `url` (the remote endpoint; REQUIRED), optional `headers` merged
461
532
  * onto every request, optional `fetch` (default `globalThis.fetch`), and optional
462
- * `timeout` (ms, applied via `AbortSignal.timeout`); see
533
+ * `timeout` (ms, applied with `AbortSignal.timeout`); see
463
534
  * {@link HTTPClientTransportOptions}
464
535
  * @returns A working {@link MCPClientTransportInterface} over the native `fetch`
465
536
  *
@@ -479,11 +550,11 @@ function createHTTPClientTransport(options) {
479
550
  return new HTTPClientTransport(options);
480
551
  }
481
552
  /**
482
- * Create the browser-face `MessagePort` transport — a
483
- * {@link import('@src/core').MCPTransportInterface} over a native `MessagePort`, the
553
+ * Creates the browser-face `MessagePort` transport — a
554
+ * {@link import('@orkestrel/mcp').MCPTransportInterface} over a native `MessagePort`, the
484
555
  * SYMMETRIC carrier that works as either a server or a client transport depending on
485
- * which binder ({@link import('@src/core').bindServer} or
486
- * {@link import('@src/core').bindClient}) it is handed to.
556
+ * which binder ({@link import('@orkestrel/mcp').bindServer} or
557
+ * {@link import('@orkestrel/mcp').bindClient}) it is handed to.
487
558
  *
488
559
  * @remarks
489
560
  * `port.start()` runs at construction (see {@link MessagePortTransport}'s doc for
@@ -493,28 +564,29 @@ function createHTTPClientTransport(options) {
493
564
  *
494
565
  * @param options - `port` (the `MessagePort` half to drive; REQUIRED); see
495
566
  * {@link MessagePortTransportOptions}
496
- * @returns A working {@link import('@src/core').MCPTransportInterface} over the port
567
+ * @returns A working {@link import('@orkestrel/mcp').MCPTransportInterface} over the port
497
568
  *
498
569
  * @example
499
570
  * ```ts
500
- * import { bindServer, createMCPServer } from '@orkestrel/mcp'
571
+ * import { bindServer, createMCPLegacy, createMCPServer } from '@orkestrel/mcp'
501
572
  * import { createMessagePortTransport } from '@orkestrel/mcp/browser'
502
573
  *
503
574
  * const { port1, port2 } = new MessageChannel()
504
- * bindServer(createMCPServer({ identity: { name: 's', version: '1.0.0' }, tools }), createMessagePortTransport({ port: port1 }))
575
+ * const mcp = createMCPServer({ identity: { name: 's', version: '1.0.0' }, tools })
576
+ * bindServer(createMCPLegacy(mcp), createMessagePortTransport({ port: port1 })) // answers `initialize` too; pass `mcp` alone for modern-only
505
577
  * ```
506
578
  */
507
579
  function createMessagePortTransport(options) {
508
580
  return new MessagePortTransport(options);
509
581
  }
510
582
  /**
511
- * Adapt a hostable {@link ServeMCPScopeInterface} (`self` in a dedicated Web Worker,
583
+ * Adapts a hostable {@link ServeMCPScopeInterface} (`self` in a dedicated Web Worker,
512
584
  * or any structurally matching double) into a {@link ScopeTransportInterface} — the
513
585
  * implicit, portless message channel `serveMCPScope` binds for the
514
586
  * dedicated-worker shape.
515
587
  *
516
588
  * @remarks
517
- * `send` writes each outbound string via `scope.postMessage`. `listen`/`closed`
589
+ * `send` writes each outbound string through `scope.postMessage`. `listen`/`closed`
518
590
  * register the SINGLE handler `deliver` / the underlying close path route through —
519
591
  * `serveMCPScope`'s own `scope` `message`-event listener calls `deliver(event.data)`
520
592
  * for every portless, string-payload event (there is no native registration point on
@@ -524,7 +596,7 @@ function createMessagePortTransport(options) {
524
596
  *
525
597
  * @param scope - The hostable scope to adapt (structurally, `self` / `globalThis`
526
598
  * inside a dedicated Web Worker)
527
- * @returns A {@link ScopeTransportInterface} `serveMCPScope` binds and drives via `deliver`
599
+ * @returns A {@link ScopeTransportInterface} `serveMCPScope` binds and drives through `deliver`
528
600
  *
529
601
  * @example
530
602
  * ```ts
@@ -556,13 +628,13 @@ function createScopeTransport(scope) {
556
628
  //#endregion
557
629
  //#region src/browser/helpers.ts
558
630
  /**
559
- * Decode one SSE event's `data` string into a {@link JSONRPCMessage}, or `undefined`
631
+ * Decodes one SSE event's `data` string into a {@link JSONRPCMessage}, or `undefined`
560
632
  * when it is not one — the per-event step {@link readEventStream} folds over.
561
633
  *
562
634
  * @remarks
563
635
  * `JSON.parse`s the `data` (the server serializes the JSON-RPC envelope as the
564
636
  * 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
637
+ * `parseJSONRPCMessage`. Total: malformed JSON or a non-message value yields
566
638
  * `undefined`, never throws.
567
639
  *
568
640
  * @param data - One SSE event's `data` payload
@@ -576,7 +648,7 @@ function decodeEvent(data) {
576
648
  }
577
649
  }
578
650
  /**
579
- * Decode a `fetch` Response's Server-Sent-Events body into the JSON-RPC messages it
651
+ * Decodes a `fetch` Response's Server-Sent-Events body into the JSON-RPC messages it
580
652
  * carried — the CLIENT-side inverse of the server's Streamable-HTTP SSE response.
581
653
  *
582
654
  * @remarks
@@ -584,8 +656,8 @@ function decodeEvent(data) {
584
656
  * stream: true })` (handling a multi-byte char split across reads) and
585
657
  * `@orkestrel/sse`'s {@link SSEParserInterface} (handling a partial line / in-progress
586
658
  * 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
659
+ * {@link JSONRPCMessage} through {@link decodeEvent} (so a non-message / non-JSON `data:`
660
+ * event is DROPPED, never thrown — total). A `null` body (no stream) yields no
589
661
  * messages; {@link import('./transports/HTTPClientTransport.js').HTTPClientTransport}
590
662
  * reads a request/response SSE reply (the server sends one `data:` event then ends),
591
663
  * so this drains to completion.
@@ -615,7 +687,7 @@ async function readEventStream(response) {
615
687
  return messages;
616
688
  }
617
689
  /**
618
- * Build `serveMCPScope`'s `message`-event listener — the unified
690
+ * Builds `serveMCPScope`'s `message`-event listener — the unified
619
691
  * dispatcher that routes EVERY inbound event on a hostable scope, portless or
620
692
  * port-bearing, to the right binding.
621
693
  *
@@ -624,26 +696,32 @@ async function readEventStream(response) {
624
696
  * — when the gate returns `false` the event is dropped entirely (no binding, no reply).
625
697
  * Accepted events spawn a fresh `MessagePortTransport` over `event.ports[0]`,
626
698
  * `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.
699
+ * into `teardowns` KEYED BY THAT PORT. A port already present is IGNORED — repeated delivery
700
+ * of the same `MessagePort` would create duplicate bindings over one port (→ duplicated
701
+ * replies), so a repeat is silently dropped.
702
+ *
703
+ * The key is what makes `teardowns` the ONLY place an accepted port is remembered. A separate
704
+ * seen-port set would be a second collection over the same lifetime, and the caller's disposer
705
+ * would have to remember to empty both — so a long-lived scope such as a Service Worker would
706
+ * retain every port it ever accepted, closed and unbound ones included. Membership answers
707
+ * "already bound?" and `clear()` drops the binding and the dedup together.
630
708
  *
631
709
  * This branch fires on EITHER a Service-Worker-shaped scope (its normal per-client
632
710
  * channel) or a dedicated-worker-shaped one that happens to receive a port-bearing event
633
711
  * (the unified design's deliberate cross-case, needing no upfront shape flag). An event
634
712
  * with NO ports and a STRING `data` is pushed onto `scopeTransport.deliver` (the
635
713
  * implicit, already-bound scope channel); any other event (no ports, non-string data)
636
- * is silently dropped — total (§14), never throws.
714
+ * is silently dropped — total, never throws.
637
715
  *
638
716
  * @param server - The `MCPServerInterface` every spawned/implicit binding dispatches over
639
717
  * @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
718
+ * @param teardowns - The shared teardown map `serveMCPScope`'s dispose drains and clears, keyed by the accepted port; each port-bearing event adds one entry
641
719
  * @param options - The `ServeMCPOptions` (for `options.accept`)
642
720
  * @returns The `message`-event listener to register (and later remove) on the scope
643
721
  *
644
722
  * @example
645
723
  * ```ts
646
- * const teardowns = new Set<() => void>()
724
+ * const teardowns = new Map<MessagePort, () => void>()
647
725
  * const scopeTransport = createScopeTransport(scope)
648
726
  * bindServer(server, scopeTransport)
649
727
  * const onMessage = createScopeMessageListener(server, scopeTransport, teardowns, options)
@@ -651,18 +729,16 @@ async function readEventStream(response) {
651
729
  * ```
652
730
  */
653
731
  function createScopeMessageListener(server, scopeTransport, teardowns, options) {
654
- const seen = /* @__PURE__ */ new Set();
655
732
  return (event) => {
656
733
  const ports = event.ports;
657
734
  if (ports.length > 0) {
658
735
  if (options.accept !== void 0 && !options.accept(event)) return;
659
736
  const port = ports[0];
660
737
  if (port === void 0) return;
661
- if (seen.has(port)) return;
662
- seen.add(port);
738
+ if (teardowns.has(port)) return;
663
739
  const transport = new MessagePortTransport({ port });
664
740
  const unbind = bindServer(server, transport);
665
- teardowns.add(() => {
741
+ teardowns.set(port, () => {
666
742
  unbind();
667
743
  transport.close();
668
744
  });
@@ -672,13 +748,16 @@ function createScopeMessageListener(server, scopeTransport, teardowns, options)
672
748
  };
673
749
  }
674
750
  /**
675
- * Boot an `MCPServer` inside a hostable worker scope and wire its message events to it.
751
+ * Boots an `MCPServer` inside a hostable worker scope and wires its message events to it.
676
752
  *
677
753
  * @remarks
678
754
  * Port-bearing events are gated by `options.accept`, deduplicated by port, and receive
679
755
  * their own `MessagePortTransport` binding. Portless string events use the scope's
680
756
  * implicit channel. The returned disposer removes the listener, unbinds the implicit
681
- * channel, and closes every accepted port binding.
757
+ * channel, closes every accepted port binding, and drops the ports themselves — the
758
+ * bindings are held in one map keyed by port, so nothing survives the clear. The served
759
+ * endpoint is modern-only: it answers a legacy `initialize` with `-32601`. A dual-era
760
+ * worker composes `bindServer(createMCPLegacy(mcp), …)` instead of this function.
682
761
  *
683
762
  * @param scope - The hostable worker scope to wire
684
763
  * @param options - The tools, optional identity, and optional port-event gate
@@ -694,7 +773,7 @@ function serveMCPScope(scope, options) {
694
773
  });
695
774
  const scopeTransport = createScopeTransport(scope);
696
775
  const unbindScope = bindServer(server, scopeTransport);
697
- const teardowns = /* @__PURE__ */ new Set();
776
+ const teardowns = /* @__PURE__ */ new Map();
698
777
  const onMessage = createScopeMessageListener(server, scopeTransport, teardowns, options);
699
778
  scope.addEventListener("message", onMessage);
700
779
  let disposed = false;
@@ -703,12 +782,16 @@ function serveMCPScope(scope, options) {
703
782
  disposed = true;
704
783
  scope.removeEventListener("message", onMessage);
705
784
  unbindScope();
706
- for (const teardown of teardowns) teardown();
785
+ for (const teardown of teardowns.values()) teardown();
707
786
  teardowns.clear();
708
787
  };
709
788
  }
710
789
  /**
711
- * Boot an `MCPServer` inside the current hostable worker scope.
790
+ * Boots an `MCPServer` inside the current hostable worker scope.
791
+ *
792
+ * @remarks
793
+ * The served endpoint is modern-only: it answers a legacy `initialize` with `-32601`. A
794
+ * dual-era worker composes `bindServer(createMCPLegacy(mcp), …)` instead of this function.
712
795
  *
713
796
  * @param options - The tools, optional identity, and optional port-event gate
714
797
  * @returns The disposer returned by {@link serveMCPScope}