@orkestrel/mcp 0.0.4 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,755 @@
1
+ import { bindServer, createMCPServer, parseJSONRPCMessage } from "../core/index.js";
2
+ import { isString } from "@orkestrel/contract";
3
+ import { createSSEParser } from "@orkestrel/sse";
4
+ import { Emitter } from "@orkestrel/emitter";
5
+ //#region src/browser/constants.ts
6
+ /**
7
+ * The Streamable-HTTP transport header that carries the MCP session id. The browser
8
+ * face's {@link import('./transports/HTTPClientTransport.js').HTTPClientTransport}
9
+ * ECHOES this header exactly like the Node face's `HTTPClientTransport`
10
+ * (`src/server`), so the same client interoperates with an `MCPSession`-based
11
+ * server unchanged.
12
+ */
13
+ var MCP_SESSION_HEADER = "mcp-session-id";
14
+ /** The default server name `serveMCPScope` reports (`initialize`'s `serverInfo.name`) when `options.name` is omitted. */
15
+ var DEFAULT_MCP_SERVER_NAME = "taverna";
16
+ /** The default server version `serveMCPScope` reports (`initialize`'s `serverInfo.version`) when `options.version` is omitted. */
17
+ var DEFAULT_MCP_SERVER_VERSION = "1.0.0";
18
+ /**
19
+ * The WebSocket subprotocol `createWebSocketClientTransport` requests by default —
20
+ * `'mcp'`, matching `createWebSocketServer`'s unconditional `Sec-WebSocket-Protocol:
21
+ * mcp` echo. Per RFC 6455 §4.1 a client MUST fail the connection if the server returns
22
+ * a subprotocol it did not request; Node ≥ 22 (undici) enforces this strictly, so the
23
+ * default bakes the correct value in. Override `WebSocketClientTransportOptions.protocols`
24
+ * only when connecting to a foreign server that speaks a different subprotocol (or `[]`
25
+ * for no subprotocol negotiation at all).
26
+ */
27
+ var MCP_WEBSOCKET_SUBPROTOCOL = "mcp";
28
+ //#endregion
29
+ //#region src/browser/transports/MessagePortTransport.ts
30
+ /**
31
+ * The browser-face `MessagePort` transport for the Model Context Protocol — a
32
+ * {@link MCPTransportInterface} over a native `MessagePort`, the genuinely new
33
+ * capability this face adds: MCP over `postMessage`.
34
+ *
35
+ * @remarks
36
+ * - **Symmetric.** Unlike {@link import('./WebSocketClientTransport.js').WebSocketClientTransport}
37
+ * / {@link import('./HTTPClientTransport.js').HTTPClientTransport} (CLIENT-only
38
+ * carriers of `@src/core`'s `ClientTransportInterface`), a `MessagePort` is a
39
+ * plain duplex channel — the SAME class implements `@src/core`'s
40
+ * `MCPTransportInterface` and is handed to EITHER `bindServer` or
41
+ * `bindClient`/`createDuplexClientTransport`; which role it plays comes entirely
42
+ * from the binder it is given to, not from anything this class decides.
43
+ * - **`start()` at construction — bind synchronously.** `MessagePort.start()` is only
44
+ * REQUIRED when listening via `addEventListener` (as opposed to the `onmessage`
45
+ * setter, which implies it) — this transport uses `addEventListener`, and
46
+ * `MCPTransportInterface` has no separate open/connect step for the caller to hook
47
+ * a start into, so the constructor calls `port.start()` immediately: the port
48
+ * begins dispatching QUEUED messages the moment the transport exists. This is safe
49
+ * inside `serveMCP`'s flow (the transport is synchronously handed to `bindServer`
50
+ * before control returns to the event loop), but is a **footgun for direct use**:
51
+ * if you construct `new MessagePortTransport({ port })` and then `await` anything
52
+ * before calling `listen`, messages that arrived in the gap are DROPPED. **Bind
53
+ * synchronously after construction** — do not interleave an `await` between
54
+ * `new MessagePortTransport(…)` and `bindServer` / `listen`.
55
+ * - **String payloads only.** `send` posts the message string as-is (`postMessage`
56
+ * structured-clones it — a string clones to an identical string, so the wire stays
57
+ * plain JSON-RPC text like every other transport in this package). Inbound: a
58
+ * non-string `event.data` (a host or a misbehaving peer posting a structured
59
+ * object) is IGNORED — dropped silently, never forwarded, never thrown (§14) —
60
+ * because `MCPTransportInterface` carries no `error` channel for this port to
61
+ * surface a non-string frame on (unlike `ClientTransportInterface`'s `emitter`);
62
+ * silently ignoring is the total, contract-shaped choice.
63
+ * - **`messageerror` is IGNORED, not routed to `closed`.** A `messageerror` event
64
+ * (the structured-clone deserialization of an inbound message threw) reports one
65
+ * BAD FRAME, not a dead channel — the port itself keeps working and later, well-
66
+ * formed messages still arrive. Routing it to `closed` would tear down the
67
+ * `bindServer`/`bindClient` wiring (and, transitively, every session it carries)
68
+ * over a single malformed frame, which is far more destructive than dropping that
69
+ * one frame — so this transport registers a `messageerror` listener that does
70
+ * nothing, deliberately.
71
+ * - **`close()`** is idempotent: it closes the underlying `port` (`MessagePort.close()`
72
+ * disconnects it — further `postMessage` calls on EITHER end are silently
73
+ * undelivered, per the platform contract) and fires the registered `closed`
74
+ * handler exactly once, whether the caller closes it once or twice. There is no
75
+ * native "peer closed" signal for a `MessagePort` (unlike a WebSocket's `close`
76
+ * event) — `closed` fires ONLY from this transport's own `close()`.
77
+ * - **Single-handler-replace (the port contract, `@src/core`'s `MCPTransportInterface`
78
+ * doc).** `listen`/`closed` each hold the ONE currently registered handler; a
79
+ * second call REPLACES the first rather than adding a second subscriber.
80
+ *
81
+ * @example
82
+ * ```ts
83
+ * const { port1, port2 } = new MessageChannel()
84
+ * const serverTransport = new MessagePortTransport({ port: port1 })
85
+ * bindServer(server, serverTransport) // port1 side dispatches inbound requests
86
+ *
87
+ * const clientTransport = new MessagePortTransport({ port: port2 })
88
+ * const client = createMCPClient({ transport: createDuplexClientTransport(clientTransport) })
89
+ * bindClient(client, clientTransport) // port2 side is the client's carrier
90
+ * ```
91
+ */
92
+ var MessagePortTransport = class {
93
+ #port;
94
+ #onMessage = void 0;
95
+ #onClosed = void 0;
96
+ #closed = false;
97
+ constructor(options) {
98
+ this.#port = options.port;
99
+ this.#port.addEventListener("message", (event) => this.#receive(event.data));
100
+ this.#port.addEventListener("messageerror", () => {});
101
+ this.#port.start();
102
+ }
103
+ send(message) {
104
+ if (this.#closed) return;
105
+ this.#port.postMessage(message);
106
+ }
107
+ listen(handler) {
108
+ this.#onMessage = handler;
109
+ }
110
+ closed(handler) {
111
+ this.#onClosed = handler;
112
+ }
113
+ close() {
114
+ if (this.#closed) return;
115
+ this.#closed = true;
116
+ this.#port.close();
117
+ this.#onClosed?.();
118
+ }
119
+ #receive(data) {
120
+ if (!isString(data)) return;
121
+ this.#onMessage?.(data);
122
+ }
123
+ };
124
+ //#endregion
125
+ //#region src/browser/helpers.ts
126
+ /**
127
+ * Decode one SSE event's `data` string into a {@link JSONRPCMessage}, or `undefined`
128
+ * when it is not one — the per-event step {@link readEventStream} folds over.
129
+ *
130
+ * @remarks
131
+ * `JSON.parse`s the `data` (the server serializes the JSON-RPC envelope as the
132
+ * event's `data`) inside a try/catch and narrows the parsed value with
133
+ * `parseJSONRPCMessage`. Total (§14): malformed JSON or a non-message value yields
134
+ * `undefined`, never throws.
135
+ *
136
+ * @param data - One SSE event's `data` payload
137
+ * @returns The decoded {@link JSONRPCMessage}, or `undefined`
138
+ */
139
+ function decodeEvent(data) {
140
+ try {
141
+ return parseJSONRPCMessage(JSON.parse(data));
142
+ } catch {
143
+ return;
144
+ }
145
+ }
146
+ /**
147
+ * Decode a `fetch` Response's Server-Sent-Events body into the JSON-RPC messages it
148
+ * carried — the CLIENT-side inverse of the server's Streamable-HTTP SSE response.
149
+ *
150
+ * @remarks
151
+ * Reads the whole `response.body` stream chunk-by-chunk through a `TextDecoder({
152
+ * stream: true })` (handling a multi-byte char split across reads) and
153
+ * `@orkestrel/sse`'s {@link SSEParserInterface} (handling a partial line / in-progress
154
+ * event split across reads), then narrows each dispatched event's `data` to a
155
+ * {@link JSONRPCMessage} via {@link decodeEvent} (so a non-message / non-JSON `data:`
156
+ * event is DROPPED, never thrown — total, §14). A `null` body (no stream) yields no
157
+ * messages; {@link import('./transports/HTTPClientTransport.js').HTTPClientTransport}
158
+ * reads a request/response SSE reply (the server sends one `data:` event then ends),
159
+ * so this drains to completion.
160
+ *
161
+ * @param response - The SSE `fetch` Response to decode (its `body` is read to completion)
162
+ * @returns Every {@link JSONRPCMessage} the stream carried, in order
163
+ */
164
+ async function readEventStream(response) {
165
+ const body = response.body;
166
+ if (body === null) return [];
167
+ const reader = body.getReader();
168
+ const decoder = new TextDecoder();
169
+ const parser = createSSEParser();
170
+ const messages = [];
171
+ try {
172
+ for (;;) {
173
+ const { done, value } = await reader.read();
174
+ if (done) break;
175
+ for (const event of parser.parse(decoder.decode(value, { stream: true }))) {
176
+ const message = decodeEvent(event.data);
177
+ if (message !== void 0) messages.push(message);
178
+ }
179
+ }
180
+ } finally {
181
+ reader.releaseLock();
182
+ }
183
+ return messages;
184
+ }
185
+ /**
186
+ * Build `serveMCPScope`'s (`serve.ts`) `message`-event listener — the unified
187
+ * dispatcher that routes EVERY inbound event on a hostable scope, portless or
188
+ * port-bearing, to the right binding.
189
+ *
190
+ * @remarks
191
+ * Port-bearing events (`event.ports.length > 0`) are gated by `options.accept` FIRST
192
+ * — when the gate returns `false` the event is dropped entirely (no binding, no reply).
193
+ * Accepted events spawn a fresh `MessagePortTransport` over `event.ports[0]`,
194
+ * `bindServer` `server` onto it, and record a teardown (`unbind` then `transport.close()`)
195
+ * into `teardowns`. A port that was already seen is IGNORED — repeated delivery of the
196
+ * same `MessagePort` would create duplicate bindings over one port (→ duplicated replies),
197
+ * so the listener tracks seen ports and silently drops repeats.
198
+ *
199
+ * This branch fires on EITHER a Service-Worker-shaped scope (its normal per-client
200
+ * channel) or a dedicated-worker-shaped one that happens to receive a port-bearing event
201
+ * (the unified design's deliberate cross-case, needing no upfront shape flag). An event
202
+ * with NO ports and a STRING `data` is pushed onto `scopeTransport.deliver` (the
203
+ * implicit, already-bound scope channel); any other event (no ports, non-string data)
204
+ * is silently dropped — total (§14), never throws.
205
+ *
206
+ * @param server - The `MCPServerInterface` every spawned/implicit binding dispatches over
207
+ * @param scopeTransport - The implicit scope channel (already `bindServer`-bound) portless events deliver onto
208
+ * @param teardowns - The shared teardown set `serveMCPScope`'s dispose drains; each port-bearing event adds one entry
209
+ * @param options - The `ServeMCPOptions` (for `options.accept`)
210
+ * @returns The `message`-event listener to register (and later remove) on the scope
211
+ *
212
+ * @example
213
+ * ```ts
214
+ * const teardowns = new Set<() => void>()
215
+ * const scopeTransport = createScopeTransport(scope)
216
+ * bindServer(server, scopeTransport)
217
+ * const onMessage = createScopeMessageListener(server, scopeTransport, teardowns, options)
218
+ * scope.addEventListener('message', onMessage)
219
+ * ```
220
+ */
221
+ function createScopeMessageListener(server, scopeTransport, teardowns, options) {
222
+ const seen = /* @__PURE__ */ new Set();
223
+ return (event) => {
224
+ const ports = event.ports;
225
+ if (ports.length > 0) {
226
+ if (options.accept !== void 0 && !options.accept(event)) return;
227
+ const port = ports[0];
228
+ if (seen.has(port)) return;
229
+ seen.add(port);
230
+ const transport = new MessagePortTransport({ port });
231
+ const unbind = bindServer(server, transport);
232
+ teardowns.add(() => {
233
+ unbind();
234
+ transport.close();
235
+ });
236
+ return;
237
+ }
238
+ if (isString(event.data)) scopeTransport.deliver(event.data);
239
+ };
240
+ }
241
+ //#endregion
242
+ //#region src/browser/transports/HTTPClientTransport.ts
243
+ /**
244
+ * The browser-face HTTP CLIENT transport for the Model Context Protocol — a
245
+ * {@link ClientTransportInterface} that drives a REMOTE Streamable-HTTP MCP server
246
+ * over the native `fetch`, the browser sibling of the Node face's
247
+ * {@link import('@src/server').HTTPClientTransport}, honoring the SAME
248
+ * `mcp-session-id` semantics so it interoperates with an `MCPSession`-based server
249
+ * unchanged.
250
+ *
251
+ * @remarks
252
+ * - **Request/response over `fetch`.** `send(message)` POSTs the JSON-serialized
253
+ * message (or batch) to `options.url` with `content-type: application/json` and an
254
+ * `Accept` of BOTH `application/json` and `text/event-stream` (so the server may
255
+ * answer with either framing) — plus any `options.headers` (e.g. an
256
+ * `Authorization` bearer). It then decodes the reply and emits each decoded
257
+ * {@link JSONRPCMessage} on the `message` event the
258
+ * {@link import('@src/core').MCPClientInterface} subscribes to.
259
+ * - **Both reply framings.** A `200` with an `application/json` body is parsed with
260
+ * `parseJSONRPCMessage`; a `200` with a `text/event-stream` body is decoded via the
261
+ * `@orkestrel/sse` {@link import('@orkestrel/sse').SSEParserInterface} (the browser
262
+ * face's own `readEventStream`) — the inverse of the server's `openStream` seam, so
263
+ * the wire round-trips. A `202` Accepted (a notification) carries no body and emits
264
+ * nothing.
265
+ * - **Session echo.** `start()` / `close()` are no-ops (a request/response transport
266
+ * holds no long-lived connection). The `mcp-session-id` response header, when a
267
+ * STATEFUL server sends one (on `initialize`), is captured into `session` and then
268
+ * ECHOED as the `mcp-session-id` request header on every SUBSEQUENT request — so an
269
+ * `MCPClient` passes a stateful server's session validation. Before `initialize`
270
+ * returns an id, `session` is `undefined` and no header is sent (safe against a
271
+ * stateless server, which neither sends nor expects one).
272
+ * - **Total at the boundary (§14).** Every reply is narrowed (`parseJSONRPCMessage`,
273
+ * the SSE decoder) — a non-message reply is dropped, never asserted; a `fetch` /
274
+ * decode failure surfaces on the `error` event rather than escaping `send`.
275
+ * - **Observable (§13).** Owns the `emitter` ({@link ClientTransportEventMap}); fires
276
+ * `message` per decoded reply, `error` on a fault, and `close` on `close()`.
277
+ *
278
+ * @example
279
+ * ```ts
280
+ * const transport = new HTTPClientTransport({ url: 'http://localhost:3000/mcp' })
281
+ * const client = new MCPClient({ transport })
282
+ * await client.connect()
283
+ * ```
284
+ */
285
+ var HTTPClientTransport = class {
286
+ #emitter;
287
+ #url;
288
+ #headers;
289
+ #fetch;
290
+ #timeout;
291
+ #session = void 0;
292
+ constructor(options) {
293
+ this.#emitter = new Emitter();
294
+ this.#url = options.url;
295
+ this.#headers = options.headers ?? {};
296
+ this.#fetch = options.fetch ?? globalThis.fetch;
297
+ this.#timeout = options.timeout;
298
+ }
299
+ get emitter() {
300
+ return this.#emitter;
301
+ }
302
+ get session() {
303
+ return this.#session;
304
+ }
305
+ async start() {}
306
+ async send(message) {
307
+ let response;
308
+ try {
309
+ response = await this.#fetch(this.#url, {
310
+ method: "POST",
311
+ headers: {
312
+ "content-type": "application/json",
313
+ accept: "application/json, text/event-stream",
314
+ ...this.#session === void 0 ? {} : { [MCP_SESSION_HEADER]: this.#session },
315
+ ...this.#headers
316
+ },
317
+ body: JSON.stringify(message),
318
+ ...this.#timeout === void 0 ? {} : { signal: AbortSignal.timeout(this.#timeout) }
319
+ });
320
+ } catch (error) {
321
+ this.#emitter.emit("error", error);
322
+ return;
323
+ }
324
+ const session = response.headers.get(MCP_SESSION_HEADER);
325
+ if (session !== null) this.#session = session;
326
+ await this.#deliver(response);
327
+ }
328
+ async close() {
329
+ this.#emitter.emit("close");
330
+ }
331
+ async #deliver(response) {
332
+ if (response.status === 202) return;
333
+ const type = response.headers.get("content-type") ?? "";
334
+ try {
335
+ if (type.includes("text/event-stream")) {
336
+ for (const message of await readEventStream(response)) this.#emitter.emit("message", message);
337
+ return;
338
+ }
339
+ if (type.includes("application/json")) {
340
+ const message = parseJSONRPCMessage(await response.json());
341
+ if (message !== void 0) this.#emitter.emit("message", message);
342
+ }
343
+ } catch (error) {
344
+ this.#emitter.emit("error", error);
345
+ }
346
+ }
347
+ };
348
+ //#endregion
349
+ //#region src/browser/transports/WebSocketClientTransport.ts
350
+ /**
351
+ * The browser-face WebSocket CLIENT transport for the Model Context Protocol — a
352
+ * {@link ClientTransportInterface} that drives a REMOTE MCP server over the native
353
+ * `WebSocket` global, the browser sibling of the Node face's
354
+ * {@link import('@src/server').WebSocketClientTransport}.
355
+ *
356
+ * @remarks
357
+ * - **Host-performed handshake.** `start()` opens `new WebSocket(url, protocols)` and
358
+ * waits for the native `'open'` event — the RFC 6455 handshake itself is entirely
359
+ * the host's concern, so this transport carries none of the Node client's
360
+ * `node:crypto` / `node:http(s)` machinery. A connection failure (the native
361
+ * `'error'` event while not yet `OPEN`) REJECTS `start()`.
362
+ * - **Queued sends.** `send` writes each message as one text frame immediately once
363
+ * the socket is `OPEN`; a `send` issued before `'open'` fires (or before `start()`
364
+ * is even called) is QUEUED and flushed, IN ORDER, the moment the socket opens —
365
+ * so a caller need not await `start()` before calling `send`.
366
+ * - **Inbound (`message`).** Each decoded text frame is `JSON.parse`d (guarded) and
367
+ * narrowed with `parseJSONRPCMessage` — a well-formed {@link JSONRPCMessage}
368
+ * re-emits on this transport's `message` event; a non-text (binary) frame or a
369
+ * non-JSON / non-message text frame surfaces on `error` and is DROPPED (§14 — never
370
+ * throws on adversarial wire input).
371
+ * - **`close()`** closes the underlying socket and fires `close` (idempotent); the
372
+ * socket's native `close` event (a server-initiated close) fires the SAME `close`
373
+ * exactly once total — `close()` first flips the guard, so the native event never
374
+ * double-emits. **This transport is not reusable after `close()`** — a `send` issued
375
+ * after `close()` is silently dropped (not queued, not delivered even on a later
376
+ * `start()`).
377
+ * - **Observable (§13).** Owns the `emitter` ({@link ClientTransportEventMap}); every
378
+ * emit the emitter isolates a listener throw; `error` is a DOMAIN event (a
379
+ * transport-level fault).
380
+ *
381
+ * @example
382
+ * ```ts
383
+ * const transport = new WebSocketClientTransport({ url: 'ws://localhost:3000/mcp' })
384
+ * const client = new MCPClient({ transport })
385
+ * await client.connect() // the browser handshakes, then the MCP initialize runs over WS frames
386
+ * ```
387
+ */
388
+ var WebSocketClientTransport = class {
389
+ #emitter;
390
+ #url;
391
+ #protocols;
392
+ #socket = void 0;
393
+ #queue = [];
394
+ #closed = false;
395
+ constructor(options) {
396
+ this.#emitter = new Emitter();
397
+ this.#url = options.url;
398
+ const protocols = options.protocols;
399
+ this.#protocols = typeof protocols === "string" ? protocols : protocols === void 0 ? "mcp" : protocols.length === 0 ? void 0 : [...protocols];
400
+ }
401
+ get emitter() {
402
+ return this.#emitter;
403
+ }
404
+ get session() {}
405
+ async start() {
406
+ if (this.#socket !== void 0) return;
407
+ this.#closed = false;
408
+ const socket = new WebSocket(this.#url, this.#protocols);
409
+ this.#socket = socket;
410
+ this.#bind(socket);
411
+ await new Promise((resolve, reject) => {
412
+ socket.addEventListener("open", () => {
413
+ this.#flush(socket);
414
+ resolve();
415
+ }, { once: true });
416
+ socket.addEventListener("error", () => {
417
+ if (socket.readyState !== WebSocket.OPEN) {
418
+ this.#socket = void 0;
419
+ reject(/* @__PURE__ */ new Error("WebSocket connection failed"));
420
+ }
421
+ }, { once: true });
422
+ });
423
+ }
424
+ async send(message) {
425
+ if (this.#closed) return;
426
+ const messages = Array.isArray(message) ? message : [message];
427
+ for (const one of messages) {
428
+ const text = JSON.stringify(one);
429
+ const socket = this.#socket;
430
+ if (socket !== void 0 && socket.readyState === WebSocket.OPEN) socket.send(text);
431
+ else this.#queue.push(text);
432
+ }
433
+ }
434
+ async close() {
435
+ if (this.#closed) return;
436
+ this.#closed = true;
437
+ const socket = this.#socket;
438
+ this.#socket = void 0;
439
+ if (socket !== void 0) socket.close();
440
+ this.#emitter.emit("close");
441
+ }
442
+ #bind(socket) {
443
+ socket.addEventListener("message", (event) => this.#receive(event.data));
444
+ socket.addEventListener("close", () => this.#onClose());
445
+ socket.addEventListener("error", (event) => this.#emitter.emit("error", event));
446
+ }
447
+ #flush(socket) {
448
+ for (const text of this.#queue.splice(0)) socket.send(text);
449
+ }
450
+ #receive(data) {
451
+ if (!isString(data)) {
452
+ this.#emitter.emit("error", /* @__PURE__ */ new Error("non-text WebSocket frame"));
453
+ return;
454
+ }
455
+ let parsed;
456
+ try {
457
+ parsed = JSON.parse(data);
458
+ } catch (error) {
459
+ this.#emitter.emit("error", error);
460
+ return;
461
+ }
462
+ const message = parseJSONRPCMessage(parsed);
463
+ if (message === void 0) {
464
+ this.#emitter.emit("error", /* @__PURE__ */ new Error("non-JSON-RPC WebSocket frame"));
465
+ return;
466
+ }
467
+ this.#emitter.emit("message", message);
468
+ }
469
+ #onClose() {
470
+ if (this.#closed) return;
471
+ this.#closed = true;
472
+ this.#socket = void 0;
473
+ this.#emitter.emit("close");
474
+ }
475
+ };
476
+ //#endregion
477
+ //#region src/browser/factories.ts
478
+ /**
479
+ * Create the browser-face WebSocket CLIENT transport for an
480
+ * {@link import('@src/core').MCPClientInterface} — a {@link ClientTransportInterface}
481
+ * that drives a REMOTE MCP server over the native `WebSocket` global, the browser
482
+ * sibling of the Node face's `createWebSocketClientTransport` (`@src/server`).
483
+ *
484
+ * @remarks
485
+ * Hand it to `createMCPClient({ transport })`: `start()` (run by `client.connect()`)
486
+ * opens `new WebSocket(options.url, options.protocols)` and awaits the native
487
+ * `'open'` event — the RFC 6455 handshake itself is the browser's concern. Each
488
+ * JSON-RPC message the client `send`s before the socket opens is QUEUED and flushed,
489
+ * in order, once it does; each decoded reply is surfaced on the transport's
490
+ * `message` event for the client's id correlation.
491
+ *
492
+ * @param options - `url` (the remote WebSocket endpoint; REQUIRED) and optional
493
+ * `protocols` (the WebSocket subprotocol(s) to request); see
494
+ * {@link WebSocketClientTransportOptions}
495
+ * @returns A working {@link ClientTransportInterface} over the native `WebSocket`
496
+ *
497
+ * @example
498
+ * ```ts
499
+ * import { createMCPClient } from '@orkestrel/mcp'
500
+ * import { createWebSocketClientTransport } from '@orkestrel/mcp/browser'
501
+ *
502
+ * const client = createMCPClient({
503
+ * transport: createWebSocketClientTransport({ url: 'ws://localhost:3000/mcp' }),
504
+ * })
505
+ * await client.connect()
506
+ * const tools = await client.tools()
507
+ * ```
508
+ */
509
+ function createWebSocketClientTransport(options) {
510
+ return new WebSocketClientTransport(options);
511
+ }
512
+ /**
513
+ * Create the browser-face HTTP CLIENT transport for an
514
+ * {@link import('@src/core').MCPClientInterface} — a {@link ClientTransportInterface}
515
+ * that drives a REMOTE Streamable-HTTP MCP server over the native `fetch`, the
516
+ * browser sibling of the Node face's `createHTTPClientTransport` (`@src/server`).
517
+ *
518
+ * @remarks
519
+ * Hand it to `createMCPClient({ transport })`: each JSON-RPC message the client
520
+ * sends is `POST`ed to `options.url` with `content-type: application/json` and an
521
+ * `Accept` of both `application/json` and `text/event-stream` (the server answers
522
+ * with EITHER — a plain JSON envelope or a Streamable-HTTP SSE `data:` event,
523
+ * decoded via `@orkestrel/sse`), and the reply is surfaced on the transport's
524
+ * `message` event for the client's id correlation. Add `options.headers` (e.g. an
525
+ * `Authorization` bearer) to reach a guarded server. `start` / `close` hold no
526
+ * connection; against a STATEFUL server it captures the `mcp-session-id` from
527
+ * `initialize` and echoes it on later requests, so the same `MCPClient` passes
528
+ * session validation (a stateless server sends none).
529
+ *
530
+ * @param options - `url` (the remote endpoint; REQUIRED), optional `headers` merged
531
+ * onto every request, optional `fetch` (default `globalThis.fetch`), and optional
532
+ * `timeout` (ms, applied via `AbortSignal.timeout`); see
533
+ * {@link HTTPClientTransportOptions}
534
+ * @returns A working {@link ClientTransportInterface} over the native `fetch`
535
+ *
536
+ * @example
537
+ * ```ts
538
+ * import { createMCPClient } from '@orkestrel/mcp'
539
+ * import { createHTTPClientTransport } from '@orkestrel/mcp/browser'
540
+ *
541
+ * const client = createMCPClient({
542
+ * transport: createHTTPClientTransport({ url: 'http://localhost:3000/mcp' }),
543
+ * })
544
+ * await client.connect()
545
+ * const tools = await client.tools()
546
+ * ```
547
+ */
548
+ function createHTTPClientTransport(options) {
549
+ return new HTTPClientTransport(options);
550
+ }
551
+ /**
552
+ * Create the browser-face `MessagePort` transport — a
553
+ * {@link import('@src/core').MCPTransportInterface} over a native `MessagePort`, the
554
+ * SYMMETRIC carrier that works as either a server or a client transport depending on
555
+ * which binder ({@link import('@src/core').bindServer} or
556
+ * {@link import('@src/core').bindClient}) it is handed to.
557
+ *
558
+ * @remarks
559
+ * `port.start()` runs at construction (see {@link MessagePortTransport}'s doc for
560
+ * why); inbound payloads are string-only (a non-string `postMessage` payload is
561
+ * dropped, never thrown); `messageerror` is ignored (one bad frame does not close the
562
+ * channel); `close()` closes the port and fires `closed` exactly once.
563
+ *
564
+ * @param options - `port` (the `MessagePort` half to drive; REQUIRED); see
565
+ * {@link MessagePortTransportOptions}
566
+ * @returns A working {@link import('@src/core').MCPTransportInterface} over the port
567
+ *
568
+ * @example
569
+ * ```ts
570
+ * import { bindServer, createMCPServer } from '@orkestrel/mcp'
571
+ * import { createMessagePortTransport } from '@orkestrel/mcp/browser'
572
+ *
573
+ * const { port1, port2 } = new MessageChannel()
574
+ * bindServer(createMCPServer({ name: 's', version: '1.0.0', tools }), createMessagePortTransport({ port: port1 }))
575
+ * ```
576
+ */
577
+ function createMessagePortTransport(options) {
578
+ return new MessagePortTransport(options);
579
+ }
580
+ /**
581
+ * Adapt a hostable {@link ServeMCPScopeInterface} (`self` in a dedicated Web Worker,
582
+ * or any structurally matching double) into a {@link ScopeTransportInterface} — the
583
+ * implicit, portless message channel `serveMCPScope` (`serve.ts`) binds for the
584
+ * dedicated-worker shape.
585
+ *
586
+ * @remarks
587
+ * `send` writes each outbound string via `scope.postMessage`. `listen`/`closed`
588
+ * register the SINGLE handler `deliver` / the underlying close path route through —
589
+ * `serveMCPScope`'s own `scope` `message`-event listener calls `deliver(event.data)`
590
+ * for every portless, string-payload event (there is no native registration point on
591
+ * the scope itself for `serveMCPScope` to hand a `listen` handler to, so `deliver` is
592
+ * the bridge). `close()` fires the registered `closed` handler — a scope has nothing
593
+ * physically closable, so this is the only teardown signal available.
594
+ *
595
+ * @param scope - The hostable scope to adapt (structurally, `self` / `globalThis`
596
+ * inside a dedicated Web Worker)
597
+ * @returns A {@link ScopeTransportInterface} `serveMCPScope` binds and drives via `deliver`
598
+ *
599
+ * @example
600
+ * ```ts
601
+ * const scopeTransport = createScopeTransport(self)
602
+ * const unbind = bindServer(server, scopeTransport)
603
+ * ```
604
+ */
605
+ function createScopeTransport(scope) {
606
+ let onMessage;
607
+ let onClosed;
608
+ return {
609
+ send(message) {
610
+ scope.postMessage(message);
611
+ },
612
+ listen(handler) {
613
+ onMessage = handler;
614
+ },
615
+ closed(handler) {
616
+ onClosed = handler;
617
+ },
618
+ close() {
619
+ onClosed?.();
620
+ },
621
+ deliver(message) {
622
+ onMessage?.(message);
623
+ }
624
+ };
625
+ }
626
+ //#endregion
627
+ //#region src/browser/serve.ts
628
+ /**
629
+ * Boot an `MCPServer` inside a hostable scope (a dedicated Web Worker's `self`, or a
630
+ * Service Worker's `self`) and wire its message events to it.
631
+ *
632
+ * @remarks
633
+ * **Trust boundary — mechanism, not policy.** `serveMCPScope` exposes the ENTIRE
634
+ * supplied `tools` registry to EVERY client the scope accepts a port from, with NO
635
+ * built-in origin or identity check. In a Service Worker that means every same-origin
636
+ * context the SW controls (any window, worker, or iframe can
637
+ * `controller.postMessage(msg, [port])` and get a fully-bound server with complete
638
+ * tool-call access). Origin allow-listing, handshake tokens, and any other gating are
639
+ * the embedding application's responsibility — compose a guard in front. Use the
640
+ * `accept` option to gate port-bearing events before binding: return `false` to drop
641
+ * the event entirely (no binding, no reply).
642
+ *
643
+ * **Lifetime / per-client binding accumulation.** Each accepted port-bearing event
644
+ * creates a fresh `MessagePortTransport` + `bindServer` binding that lives for the
645
+ * scope's lifetime — there is NO per-client reaping, because `MessagePort` provides
646
+ * no "peer closed" signal. For bounded, long-lived client sets this is fine; embedders
647
+ * with high client churn must track and invoke the dispose function themselves to
648
+ * avoid unbounded accumulation.
649
+ *
650
+ * **Portless events and the implicit scope channel.** A portless `message` event
651
+ * (e.g. `controller.postMessage('<json-rpc>')` in a Service Worker) delivers its
652
+ * string directly to the implicit scope transport — **the tool EXECUTES** — even
653
+ * though no reply can reach the caller. In a `ServiceWorkerGlobalScope` the reply
654
+ * path (`scopeTransport.send` → `scope.postMessage`) throws (no `self.postMessage`),
655
+ * and `bindServer` routes the throw to the server emitter's `error` event (see
656
+ * `@src/core bindServer`), so the un-repliable reply is dropped. The net effect is
657
+ * **blind side-effecting ingress**: the tool runs but the caller gets no result.
658
+ * Crucially, **`accept` does NOT gate this channel** — it is consulted only for
659
+ * port-bearing events. In a Service Worker, if `accept` is your sole guard, ensure
660
+ * all clients connect through transferred `MessagePort`s (port-bearing messages), or
661
+ * restrict the exposed tools to side-effect-free operations, or validate a token
662
+ * inside the tools themselves.
663
+ *
664
+ * Binds the implicit scope channel EAGERLY (at call time, not lazily on first use) —
665
+ * `bindServer` is called once against a {@link import('./types.js').ScopeTransportInterface}
666
+ * wrapping `scope` for the whole lifetime of the returned dispose, so a dedicated
667
+ * worker's very first portless message is served with no first-use setup cost or
668
+ * ordering hazard.
669
+ *
670
+ * Every inbound `message` event is inspected structurally: `event.ports.length > 0`
671
+ * spawns a fresh {@link import('./factories.js').createMessagePortTransport} +
672
+ * `bindServer` for THAT port (tracked for teardown) — this holds even on a
673
+ * dedicated-worker-shaped scope, the unified design's deliberate cross-case. An
674
+ * event with NO ports and a STRING `event.data` is delivered onto the implicit scope
675
+ * channel; any other event (no ports, non-string data) is dropped.
676
+ *
677
+ * @param scope - The hostable scope to wire (structurally, `self` inside a worker)
678
+ * @param options - `tools` (the live registry to expose; REQUIRED), optional
679
+ * `name`/`version` (default {@link import('./constants.js').DEFAULT_MCP_SERVER_NAME} /
680
+ * {@link import('./constants.js').DEFAULT_MCP_SERVER_VERSION}), optional `accept`
681
+ * (origin/identity gate for port-bearing events); see {@link ServeMCPOptions}
682
+ * @returns A dispose function — unbinds every binding, closes every accepted
683
+ * `MessagePort`, and removes the scope's `message` listener. Idempotent.
684
+ *
685
+ * @example
686
+ * ```ts
687
+ * const scope = { postMessage() {}, addEventListener() {}, removeEventListener() {} }
688
+ * const dispose = serveMCPScope(scope, {
689
+ * tools: createToolManager(),
690
+ * // Prefer token-in-data — event.origin is empty for same-origin worker messages.
691
+ * accept: (event) => event.data === 'my-secret-token',
692
+ * })
693
+ * // ... later:
694
+ * dispose()
695
+ * ```
696
+ */
697
+ function serveMCPScope(scope, options) {
698
+ const server = createMCPServer({
699
+ tools: options.tools,
700
+ name: options.name ?? "taverna",
701
+ version: options.version ?? "1.0.0"
702
+ });
703
+ const scopeTransport = createScopeTransport(scope);
704
+ const unbindScope = bindServer(server, scopeTransport);
705
+ const teardowns = /* @__PURE__ */ new Set();
706
+ const onMessage = createScopeMessageListener(server, scopeTransport, teardowns, options);
707
+ scope.addEventListener("message", onMessage);
708
+ let disposed = false;
709
+ return () => {
710
+ if (disposed) return;
711
+ disposed = true;
712
+ scope.removeEventListener("message", onMessage);
713
+ unbindScope();
714
+ for (const teardown of teardowns) teardown();
715
+ teardowns.clear();
716
+ };
717
+ }
718
+ /**
719
+ * Boot an `MCPServer` inside the CURRENT hostable scope (`globalThis` — a dedicated
720
+ * Web Worker or a Service Worker) and wire its message events to it.
721
+ *
722
+ * @remarks
723
+ * A one-liner over {@link serveMCPScope}: `serveMCP(options)` is exactly
724
+ * `serveMCPScope(globalThis, options)`. Kept as its own export so the scope-facing
725
+ * wiring stays independently testable (AGENTS §5) — drive {@link serveMCPScope}
726
+ * directly with a scope double for a test, and this thin wrapper for real deploys.
727
+ *
728
+ * **Trust boundary and lifecycle** — see {@link serveMCPScope}'s `@remarks`. The same
729
+ * considerations apply: ENTIRE tool registry exposed to every accepted port-bearing
730
+ * event; use `accept` to gate; per-client bindings accumulate for the scope's lifetime.
731
+ *
732
+ * @param options - `tools` (the live registry to expose; REQUIRED), optional
733
+ * `name`/`version`, optional `accept` (origin/identity gate); see {@link ServeMCPOptions}
734
+ * @returns A dispose function — see {@link serveMCPScope}
735
+ *
736
+ * @example
737
+ * ```ts
738
+ * // Inside a dedicated Web Worker's entry module:
739
+ * import { serveMCP } from '@orkestrel/mcp/browser'
740
+ * import { createToolManager, createTool } from '@orkestrel/agent'
741
+ *
742
+ * const tools = createToolManager()
743
+ * tools.add(createTool({ name: 'add', execute: (a) => Number(a.x) + Number(a.y) }))
744
+ * const dispose = serveMCP({ tools, name: 'worker-mcp', version: '1.0.0' })
745
+ * // ... later, on teardown:
746
+ * dispose()
747
+ * ```
748
+ */
749
+ function serveMCP(options) {
750
+ return serveMCPScope(globalThis, options);
751
+ }
752
+ //#endregion
753
+ export { DEFAULT_MCP_SERVER_NAME, DEFAULT_MCP_SERVER_VERSION, HTTPClientTransport, MCP_SESSION_HEADER, MCP_WEBSOCKET_SUBPROTOCOL, MessagePortTransport, WebSocketClientTransport, createHTTPClientTransport, createMessagePortTransport, createScopeMessageListener, createScopeTransport, createWebSocketClientTransport, decodeEvent, readEventStream, serveMCP, serveMCPScope };
754
+
755
+ //# sourceMappingURL=index.js.map