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