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