@orkestrel/mcp 0.0.11 → 0.0.13
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 +47 -1
- package/dist/src/browser/index.d.ts +36 -24
- package/dist/src/browser/index.js +19 -13
- package/dist/src/browser/index.js.map +1 -1
- package/dist/src/core/index.cjs +4061 -983
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +4024 -1069
- package/dist/src/core/index.d.ts +4024 -1069
- package/dist/src/core/index.js +3982 -973
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +352 -135
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +254 -89
- package/dist/src/server/index.d.ts +254 -89
- package/dist/src/server/index.js +352 -138
- package/dist/src/server/index.js.map +1 -1
- package/package.json +10 -9
|
@@ -99,6 +99,55 @@ function createReadableStream(pull, cancel) {
|
|
|
99
99
|
});
|
|
100
100
|
}
|
|
101
101
|
/**
|
|
102
|
+
* Pump a controlled held-open exchange onto an open SSE stream — one `data:` event per
|
|
103
|
+
* notification in order, then the terminating response — and END the exchange however the
|
|
104
|
+
* pump leaves.
|
|
105
|
+
*
|
|
106
|
+
* @remarks
|
|
107
|
+
* The Streamable-HTTP twin of {@link import('@src/core').sendStream}, and it owns exactly what
|
|
108
|
+
* that owns. The `finally` releases the exchange on EVERY exit — the normal terminal, a
|
|
109
|
+
* producer that threw, a `write` that threw, and an abort alike — because nothing else will:
|
|
110
|
+
* a request whose client vanished cancels nothing by itself, so an exchange this pump walks
|
|
111
|
+
* away from keeps its producer, its request lifetime, and its live subscription slot forever.
|
|
112
|
+
* The exchange is released BEFORE the body ends, so the slot is already back when the response
|
|
113
|
+
* completes.
|
|
114
|
+
*
|
|
115
|
+
* Total (§14) — never throws and never rejects. A held-open SSE response has already sent its
|
|
116
|
+
* headers and part of its body, so there is no failure the transport could still convert into
|
|
117
|
+
* a different answer; the honest end of a broken stream is a closed one, and the fault itself
|
|
118
|
+
* is already legible on `server.emitter`'s `error` event, which is where a contained fault
|
|
119
|
+
* belongs.
|
|
120
|
+
*
|
|
121
|
+
* @param stream - The controlled held-open answer to write out and then end
|
|
122
|
+
* @param sse - The open SSE stream to write each serialized message onto
|
|
123
|
+
* @returns Resolves once the exchange has ended and the SSE body has been closed
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* ```ts
|
|
127
|
+
* const answer = await mcp.dispatch(invocation, { signal: disconnect.signal })
|
|
128
|
+
* if (answer !== undefined && Symbol.asyncIterator in answer) {
|
|
129
|
+
* const sse = openStream()
|
|
130
|
+
* queueMicrotask(() => void sendEventStream(answer, sse))
|
|
131
|
+
* }
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
134
|
+
async function sendEventStream(stream, sse) {
|
|
135
|
+
try {
|
|
136
|
+
try {
|
|
137
|
+
let next = await stream.next();
|
|
138
|
+
while (next.done !== true) {
|
|
139
|
+
sse.write({ data: JSON.stringify(next.value) });
|
|
140
|
+
next = await stream.next();
|
|
141
|
+
}
|
|
142
|
+
sse.write({ data: JSON.stringify(next.value) });
|
|
143
|
+
} finally {
|
|
144
|
+
await stream[Symbol.asyncDispose]();
|
|
145
|
+
}
|
|
146
|
+
} catch {} finally {
|
|
147
|
+
sse.end();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
102
151
|
* Whether the request's `Accept` header opts into a Server-Sent-Events response.
|
|
103
152
|
*
|
|
104
153
|
* @remarks
|
|
@@ -145,27 +194,6 @@ function allowsOrigin(request, options) {
|
|
|
145
194
|
return options?.origins?.includes(parsed.origin) ?? false;
|
|
146
195
|
}
|
|
147
196
|
/**
|
|
148
|
-
* Whether a modern HTTP request's required standard headers match its JSON-RPC body.
|
|
149
|
-
*
|
|
150
|
-
* @remarks
|
|
151
|
-
* Requires `MCP-Protocol-Version` to equal the reserved `_meta` version and `Mcp-Method`
|
|
152
|
-
* to equal `method`. `Mcp-Name` is required only for `tools/call`, where it must equal
|
|
153
|
-
* `params.name`; discovery and listing requests need no name because none is derivable.
|
|
154
|
-
* Legacy requests return `false` because this predicate models the modern contract only.
|
|
155
|
-
*
|
|
156
|
-
* @param request - The HTTP request carrying the headers
|
|
157
|
-
* @param message - The parsed JSON-RPC request body
|
|
158
|
-
* @returns `true` only when every method-applicable modern header matches
|
|
159
|
-
*/
|
|
160
|
-
function matchesModernHeaders(request, message) {
|
|
161
|
-
if (!(0, _src_core.isModernRequest)(message)) return false;
|
|
162
|
-
const version = ((0, _orkestrel_contract.isRecord)(message.params?.["_meta"]) ? message.params["_meta"] : void 0)?.[_src_core.MCP_META_VERSION];
|
|
163
|
-
if (!(0, _orkestrel_contract.isString)(version) || request.headers.get("mcp-protocol-version") !== version || request.headers.get("mcp-method") !== message.method) return false;
|
|
164
|
-
if (message.method !== "tools/call") return true;
|
|
165
|
-
const name = message.params?.["name"];
|
|
166
|
-
return (0, _orkestrel_contract.isString)(name) && request.headers.get("mcp-name") === name;
|
|
167
|
-
}
|
|
168
|
-
/**
|
|
169
197
|
* Read the request's `mcp-session-id` header — the session id a stateful transport
|
|
170
198
|
* validates, or `undefined` when absent.
|
|
171
199
|
*
|
|
@@ -206,9 +234,9 @@ function readLastEventId(request) {
|
|
|
206
234
|
* JSON-RPC error body.
|
|
207
235
|
*
|
|
208
236
|
* @remarks
|
|
209
|
-
* Returns `Response.json(buildJSONRPCError(
|
|
210
|
-
* { status: 404 })`, mirroring `createMCPRoutes`'s `400` transport-failure shape (a
|
|
211
|
-
* JSON-RPC error BODY with
|
|
237
|
+
* Returns `Response.json(buildJSONRPCError(undefined, JSONRPC_INVALID_REQUEST, 'Session not
|
|
238
|
+
* found'), { status: 404 })`, mirroring `createMCPRoutes`'s `400` transport-failure shape (a
|
|
239
|
+
* JSON-RPC error BODY with NO id) but at the session-not-found status. Shared by
|
|
212
240
|
* every {@link import('./middlewares.js').createMCPSession} validation site — the
|
|
213
241
|
* non-`initialize` `POST` path, the resumable `GET {path}` open, and the `DELETE {path}`
|
|
214
242
|
* session-end (each a missing / unknown / TTL-evicted id) — so the single `404` envelope
|
|
@@ -217,7 +245,7 @@ function readLastEventId(request) {
|
|
|
217
245
|
* @returns The `404` JSON-RPC error `Response`
|
|
218
246
|
*/
|
|
219
247
|
function rejectUnknownSession() {
|
|
220
|
-
return Response.json((0, _src_core.buildJSONRPCError)(
|
|
248
|
+
return Response.json((0, _src_core.buildJSONRPCError)(void 0, _src_core.JSONRPC_INVALID_REQUEST, "Session not found"), { status: 404 });
|
|
221
249
|
}
|
|
222
250
|
/**
|
|
223
251
|
* Decode a `fetch` Response's Server-Sent-Events body into the JSON-RPC messages it
|
|
@@ -324,7 +352,7 @@ function extractLines(buffer, chunk) {
|
|
|
324
352
|
}
|
|
325
353
|
/**
|
|
326
354
|
* Decode and deliver each complete newline-framed line onto a {@link
|
|
327
|
-
*
|
|
355
|
+
* MCPClientTransportEventMap} emitter — the shared per-chunk dispatch step both stdio
|
|
328
356
|
* transports (client and server) run their {@link extractLines} output through.
|
|
329
357
|
*
|
|
330
358
|
* @remarks
|
|
@@ -349,7 +377,7 @@ function dispatchLines(emitter, lines) {
|
|
|
349
377
|
}
|
|
350
378
|
}
|
|
351
379
|
/**
|
|
352
|
-
* Bridge a message-channel {@link
|
|
380
|
+
* Bridge a message-channel {@link MCPClientTransportInterface} (the shape the stdio and
|
|
353
381
|
* WebSocket SERVER transports already implement) into the environment-agnostic
|
|
354
382
|
* {@link import('@src/core').MCPTransportInterface} port — the adapter
|
|
355
383
|
* {@link import('./factories.js').createStdioServer} and {@link
|
|
@@ -361,11 +389,22 @@ function dispatchLines(emitter, lines) {
|
|
|
361
389
|
* `send` decodes the already-serialized reply string back to a {@link JSONRPCMessage}
|
|
362
390
|
* and writes it via `transport.send` (the SAME `JSON.stringify` the underlying
|
|
363
391
|
* transport already performs, so the wire bytes are unchanged). `listen` filters
|
|
364
|
-
* `transport`'s `message` event to
|
|
365
|
-
* exactly as the prior hand-rolled pumps did — and re-serializes each one
|
|
366
|
-
* string for `bindServer`. `closed` bridges `transport`'s `close` event. `close`
|
|
392
|
+
* `transport`'s `message` event to INVOCATIONS ONLY — requests and notifications, never a
|
|
393
|
+
* stray response, exactly as the prior hand-rolled pumps did — and re-serializes each one
|
|
394
|
+
* back to a string for `bindServer`. `closed` bridges `transport`'s `close` event. `close`
|
|
367
395
|
* closes the underlying `transport`.
|
|
368
396
|
*
|
|
397
|
+
* @remarks A message crossing this bridge is decoded and re-encoded TWICE, and that is
|
|
398
|
+
* ACCEPTED rather than accidental. Inbound: the carrier already parsed the frame into a
|
|
399
|
+
* {@link JSONRPCMessage}, and `listen` re-serializes it so `bindServer` can decode it again
|
|
400
|
+
* under the server's own `limit`. Outbound: `bindServer` serialized the reply, `send` parses
|
|
401
|
+
* it back, and the carrier stringifies it once more. The cost is two extra `JSON.parse` /
|
|
402
|
+
* `JSON.stringify` round trips per message, paid to keep ONE pump in the core binder instead
|
|
403
|
+
* of a hand-rolled one per carrier. It is BOUNDED rather than unbounded because the binder
|
|
404
|
+
* decodes within `server.limit.message`, so an oversized frame is refused before the second
|
|
405
|
+
* decode rather than after it. Removing the cost means giving `MCPTransportInterface` a
|
|
406
|
+
* message-shaped face beside its string one, which every transport would then carry.
|
|
407
|
+
*
|
|
369
408
|
* @remarks Per {@link import('@src/core').MCPTransportInterface}, `listen`/`closed`
|
|
370
409
|
* each hold THE SINGLE current handler (a second call REPLACES the first, never adds).
|
|
371
410
|
* Since the underlying `transport.emitter` is ADD-based (`on` subscribes, never
|
|
@@ -393,7 +432,7 @@ function bridgeMessageTransport(transport) {
|
|
|
393
432
|
let onMessage;
|
|
394
433
|
let onClosed;
|
|
395
434
|
transport.emitter.on("message", (message) => {
|
|
396
|
-
if (!(0, _src_core.
|
|
435
|
+
if (!(0, _src_core.isJSONRPCInvocation)(message)) return;
|
|
397
436
|
onMessage?.(JSON.stringify(message));
|
|
398
437
|
});
|
|
399
438
|
transport.emitter.on("close", () => {
|
|
@@ -419,13 +458,94 @@ function bridgeMessageTransport(transport) {
|
|
|
419
458
|
//#endregion
|
|
420
459
|
//#region src/server/inferers.ts
|
|
421
460
|
/**
|
|
461
|
+
* Infer the first required MCP HTTP header that is missing or mismatched.
|
|
462
|
+
*
|
|
463
|
+
* @remarks
|
|
464
|
+
* A modern request derives its protocol, method, and tools/call-only name expectations from
|
|
465
|
+
* the JSON-RPC body. A legacy request body requires a protocol header after initialization,
|
|
466
|
+
* while a supplied legacy session version additionally diagnoses a header that disagrees with
|
|
467
|
+
* the active session. Messages name the expected value but never echo the client-supplied one.
|
|
468
|
+
*
|
|
469
|
+
* @param request - The HTTP request carrying the headers
|
|
470
|
+
* @param reference - The parsed invocation body, or the active legacy session version
|
|
471
|
+
* @returns The first header issue, or `undefined` when the applicable headers agree
|
|
472
|
+
*
|
|
473
|
+
* @example
|
|
474
|
+
* ```ts
|
|
475
|
+
* const issue = inferHeaderIssue(request, rpcRequest)
|
|
476
|
+
* issue?.header // 'Mcp-Method' when that field is absent or mismatched
|
|
477
|
+
* ```
|
|
478
|
+
*/
|
|
479
|
+
function inferHeaderIssue(request, reference) {
|
|
480
|
+
const protocol = request.headers.get(MCP_PROTOCOL_VERSION_HEADER);
|
|
481
|
+
if ((0, _orkestrel_contract.isString)(reference)) {
|
|
482
|
+
if (protocol === null) return {
|
|
483
|
+
header: "MCP-Protocol-Version",
|
|
484
|
+
reason: "missing",
|
|
485
|
+
message: `Required MCP-Protocol-Version header is missing; the active session uses '${reference}'.`
|
|
486
|
+
};
|
|
487
|
+
if (protocol !== reference) return {
|
|
488
|
+
header: "MCP-Protocol-Version",
|
|
489
|
+
reason: "mismatched",
|
|
490
|
+
message: `MCP-Protocol-Version header does not match the active session version '${reference}'.`
|
|
491
|
+
};
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (!(0, _src_core.isModernRequest)(reference)) {
|
|
495
|
+
if ((0, _src_core.isInitializeRequest)(reference) || protocol !== null) return void 0;
|
|
496
|
+
return {
|
|
497
|
+
header: "MCP-Protocol-Version",
|
|
498
|
+
reason: "missing",
|
|
499
|
+
message: `Required MCP-Protocol-Version header is missing; this server offers '${_src_core.MCP_PROTOCOL_VERSION}'.`
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
const message = reference;
|
|
503
|
+
const version = ((0, _orkestrel_contract.isRecord)(message.params?.["_meta"]) ? message.params["_meta"] : void 0)?.[_src_core.MCP_META_VERSION];
|
|
504
|
+
if (!(0, _orkestrel_contract.isString)(version)) return void 0;
|
|
505
|
+
if (protocol === null) return {
|
|
506
|
+
header: "MCP-Protocol-Version",
|
|
507
|
+
reason: "missing",
|
|
508
|
+
message: `Required MCP-Protocol-Version header is missing; the request body version is '${version}'.`
|
|
509
|
+
};
|
|
510
|
+
if (protocol !== version) return {
|
|
511
|
+
header: "MCP-Protocol-Version",
|
|
512
|
+
reason: "mismatched",
|
|
513
|
+
message: `MCP-Protocol-Version header does not match the request body version '${version}'.`
|
|
514
|
+
};
|
|
515
|
+
const method = request.headers.get(MCP_METHOD_HEADER);
|
|
516
|
+
if (method === null) return {
|
|
517
|
+
header: "Mcp-Method",
|
|
518
|
+
reason: "missing",
|
|
519
|
+
message: `Required Mcp-Method header is missing; the request body method is '${message.method}'.`
|
|
520
|
+
};
|
|
521
|
+
if (method !== message.method) return {
|
|
522
|
+
header: "Mcp-Method",
|
|
523
|
+
reason: "mismatched",
|
|
524
|
+
message: `Mcp-Method header does not match the request body method '${message.method}'.`
|
|
525
|
+
};
|
|
526
|
+
if (message.method !== "tools/call") return void 0;
|
|
527
|
+
const name = message.params?.["name"];
|
|
528
|
+
if (!(0, _orkestrel_contract.isString)(name)) return void 0;
|
|
529
|
+
const header = request.headers.get(MCP_NAME_HEADER);
|
|
530
|
+
if (header === null) return {
|
|
531
|
+
header: "Mcp-Name",
|
|
532
|
+
reason: "missing",
|
|
533
|
+
message: `Required Mcp-Name header is missing; the request body tool name is '${name}'.`
|
|
534
|
+
};
|
|
535
|
+
if (header !== name) return {
|
|
536
|
+
header: "Mcp-Name",
|
|
537
|
+
reason: "mismatched",
|
|
538
|
+
message: `Mcp-Name header does not match the request body tool name '${name}'.`
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
422
542
|
* Infer the legacy revision an `initialize` request negotiates.
|
|
423
543
|
*
|
|
424
544
|
* @remarks
|
|
425
545
|
* A supported legacy request is pinned exactly. A modern, malformed, absent, or unsupported
|
|
426
546
|
* request selects the newest supported legacy revision, matching the core initialize result.
|
|
427
547
|
*
|
|
428
|
-
* @param request - The legacy initialize
|
|
548
|
+
* @param request - The legacy initialize invocation
|
|
429
549
|
* @returns The negotiated legacy protocol revision
|
|
430
550
|
*/
|
|
431
551
|
function inferLegacyVersion(request) {
|
|
@@ -457,35 +577,78 @@ function inferStatus(response, era) {
|
|
|
457
577
|
//#endregion
|
|
458
578
|
//#region src/server/transports/HTTPDisconnect.ts
|
|
459
579
|
/**
|
|
460
|
-
*
|
|
580
|
+
* Compose one incoming HTTP request lifetime with one MCP-owned SSE response lifetime.
|
|
461
581
|
*
|
|
462
582
|
* @remarks
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
* the
|
|
468
|
-
*
|
|
469
|
-
*
|
|
583
|
+
* The composed {@link signal} observes request abort and EVERY way this response can end
|
|
584
|
+
* without one: consumer cancellation of the bridged body, a forwarding failure mid-pump, and a
|
|
585
|
+
* keepalive tick that finds the SSE stream already closed. That last pair is the whole point of
|
|
586
|
+
* the composition — a client that vanishes mid-stream aborts nothing by itself, so unless this
|
|
587
|
+
* object raises the signal on its own failure paths, the handler, the controlled stream, and
|
|
588
|
+
* the producer behind them all keep running for a response that can no longer be written.
|
|
589
|
+
* Graceful upstream completion is the one terminal that does NOT abort: the body simply closes,
|
|
590
|
+
* because the exchange finished rather than ended.
|
|
591
|
+
*
|
|
592
|
+
* {@link bridge} preserves the source response status and headers, forwards its body bytes, and
|
|
593
|
+
* owns keepalive comments plus listener/timer cleanup until upstream completion, request abort,
|
|
594
|
+
* or consumer cancellation. This is a single-response lifecycle object, not a reusable bridge.
|
|
595
|
+
* It supplies no handler or session policy.
|
|
596
|
+
*
|
|
597
|
+
* The keepalive interval is a BUDGET, sanitized like every other numeric knob in this package:
|
|
598
|
+
* anything that is not a positive integer — `0`, a negative, a fractional value, `NaN`,
|
|
599
|
+
* `Infinity` — falls back to {@link DEFAULT_MCP_KEEPALIVE_INTERVAL}, and a larger value clamps
|
|
600
|
+
* to Node's `2_147_483_647` ms timer maximum. None may reach the platform's timer floor, where
|
|
601
|
+
* an idle-liveness tick becomes the polling this package forbids everywhere else.
|
|
602
|
+
*
|
|
603
|
+
* @example
|
|
604
|
+
* ```ts
|
|
605
|
+
* import { HTTPDisconnect } from '@orkestrel/mcp/server'
|
|
606
|
+
* import { openStream } from '@orkestrel/server'
|
|
607
|
+
*
|
|
608
|
+
* const disconnect = new HTTPDisconnect(request.signal, { interval: 15_000 })
|
|
609
|
+
* const stream = openStream()
|
|
610
|
+
* const response = disconnect.bridge(stream)
|
|
611
|
+
* ```
|
|
470
612
|
*/
|
|
471
613
|
var HTTPDisconnect = class {
|
|
472
|
-
#
|
|
614
|
+
#response = new AbortController();
|
|
473
615
|
#lifecycle = new AbortController();
|
|
474
616
|
#interval;
|
|
475
617
|
#signal;
|
|
476
618
|
#timer;
|
|
619
|
+
#pulling = false;
|
|
620
|
+
/**
|
|
621
|
+
* Create the lifecycle composition for one request and its future SSE response.
|
|
622
|
+
*
|
|
623
|
+
* @param signal - The incoming request signal
|
|
624
|
+
* @param options - Optional keepalive `interval` in milliseconds; an invalid value falls back
|
|
625
|
+
* to {@link DEFAULT_MCP_KEEPALIVE_INTERVAL}, and one above Node's timer maximum clamps to it
|
|
626
|
+
*/
|
|
477
627
|
constructor(signal, options) {
|
|
478
|
-
|
|
479
|
-
this.#
|
|
628
|
+
const interval = (0, _orkestrel_contract.sanitizeBudget)(options?.interval, DEFAULT_MCP_KEEPALIVE_INTERVAL);
|
|
629
|
+
this.#interval = interval > 0 ? Math.min(interval, 2147483647) : DEFAULT_MCP_KEEPALIVE_INTERVAL;
|
|
630
|
+
this.#signal = AbortSignal.any([signal, this.#response.signal]);
|
|
480
631
|
}
|
|
632
|
+
/**
|
|
633
|
+
* The signal aborted by the incoming request, or by any end of this response that is not
|
|
634
|
+
* its graceful completion.
|
|
635
|
+
*
|
|
636
|
+
* @returns The composed lifecycle signal
|
|
637
|
+
*/
|
|
481
638
|
get signal() {
|
|
482
639
|
return this.#signal;
|
|
483
640
|
}
|
|
484
641
|
/**
|
|
485
|
-
* Bridge
|
|
642
|
+
* Bridge one open SSE response through cancellation-aware byte forwarding and keepalives.
|
|
643
|
+
*
|
|
644
|
+
* Consumer cancellation, a read failure while forwarding, and a keepalive tick that finds the
|
|
645
|
+
* SSE stream already closed each abort {@link signal}; consumer cancellation also cancels the
|
|
646
|
+
* upstream reader. Upstream completion closes the returned body without inventing an abort.
|
|
647
|
+
* Every terminal path clears the keepalive timer and detaches the bridge-owned abort listener.
|
|
486
648
|
*
|
|
487
649
|
* @param stream - The open SSE stream whose response will be consumed by the HTTP writer
|
|
488
|
-
* @returns A response
|
|
650
|
+
* @returns A one-use response preserving status, status text, headers, and SSE body bytes
|
|
651
|
+
* @throws When the supplied SSE response has no body
|
|
489
652
|
*/
|
|
490
653
|
bridge(stream) {
|
|
491
654
|
const response = stream.response;
|
|
@@ -493,28 +656,32 @@ var HTTPDisconnect = class {
|
|
|
493
656
|
if (body === null) throw new Error("MCP SSE response has no body");
|
|
494
657
|
const reader = body.getReader();
|
|
495
658
|
this.#timer = setInterval(() => {
|
|
496
|
-
if (stream.closed)
|
|
497
|
-
|
|
659
|
+
if (stream.closed) {
|
|
660
|
+
if (!this.#pulling) this.#abort();
|
|
661
|
+
} else stream.comment(SSE_KEEPALIVE_COMMENT);
|
|
498
662
|
}, this.#interval);
|
|
499
|
-
this.#signal.addEventListener("abort", () => this.#
|
|
663
|
+
this.#signal.addEventListener("abort", () => this.#release(), {
|
|
500
664
|
once: true,
|
|
501
665
|
signal: this.#lifecycle.signal
|
|
502
666
|
});
|
|
503
|
-
if (this.#signal.aborted
|
|
667
|
+
if (this.#signal.aborted) this.#release();
|
|
668
|
+
else if (stream.closed) this.#abort();
|
|
504
669
|
return new Response(createReadableStream(async (controller) => {
|
|
670
|
+
this.#pulling = true;
|
|
505
671
|
try {
|
|
506
672
|
const chunk = await reader.read();
|
|
507
673
|
if (chunk.done) {
|
|
508
|
-
this.#
|
|
674
|
+
this.#release();
|
|
509
675
|
controller.close();
|
|
510
676
|
} else controller.enqueue(chunk.value);
|
|
511
677
|
} catch (error) {
|
|
512
|
-
this.#
|
|
678
|
+
this.#abort();
|
|
513
679
|
controller.error(error);
|
|
680
|
+
} finally {
|
|
681
|
+
this.#pulling = false;
|
|
514
682
|
}
|
|
515
683
|
}, async (reason) => {
|
|
516
|
-
this.#abort
|
|
517
|
-
this.#stop();
|
|
684
|
+
this.#abort();
|
|
518
685
|
await reader.cancel(reason);
|
|
519
686
|
}), {
|
|
520
687
|
status: response.status,
|
|
@@ -522,13 +689,17 @@ var HTTPDisconnect = class {
|
|
|
522
689
|
headers: response.headers
|
|
523
690
|
});
|
|
524
691
|
}
|
|
525
|
-
#
|
|
692
|
+
#release() {
|
|
526
693
|
if (this.#timer !== void 0) {
|
|
527
694
|
clearInterval(this.#timer);
|
|
528
695
|
this.#timer = void 0;
|
|
529
696
|
}
|
|
530
697
|
this.#lifecycle.abort();
|
|
531
698
|
}
|
|
699
|
+
#abort() {
|
|
700
|
+
this.#release();
|
|
701
|
+
this.#response.abort();
|
|
702
|
+
}
|
|
532
703
|
};
|
|
533
704
|
//#endregion
|
|
534
705
|
//#region src/server/handlers.ts
|
|
@@ -548,18 +719,18 @@ var HTTPDisconnect = class {
|
|
|
548
719
|
* defined value is added to `MCPDispatchOptions`, while `undefined` is omitted.
|
|
549
720
|
*
|
|
550
721
|
* @typeParam TState - The consumer's opaque per-request route state type
|
|
551
|
-
* @param mcp - The transport-agnostic MCP
|
|
722
|
+
* @param mcp - The transport-agnostic MCP dispatcher to dispatch through
|
|
552
723
|
* @param options - Optional streaming, origin-validation, SSE keepalive, and caller-extraction options
|
|
553
724
|
* @returns A request handler for the stateless MCP POST route
|
|
554
725
|
*
|
|
555
726
|
* @example
|
|
556
727
|
* ```ts
|
|
557
|
-
* import { createMCPServer } from '@orkestrel/mcp'
|
|
728
|
+
* import { createMCPLegacy, createMCPServer } from '@orkestrel/mcp'
|
|
558
729
|
* import { createMCPPostHandler } from '@orkestrel/mcp/server'
|
|
559
730
|
* import { createToolManager } from '@orkestrel/tool'
|
|
560
731
|
*
|
|
561
732
|
* const mcp = createMCPServer({ identity: { name: 'docs', version: '1.0.0' }, tools: createToolManager() })
|
|
562
|
-
* const handler = createMCPPostHandler(mcp, { streaming: true })
|
|
733
|
+
* const handler = createMCPPostHandler(createMCPLegacy(mcp), { streaming: true })
|
|
563
734
|
* await handler(new Request('http://localhost/mcp', {
|
|
564
735
|
* method: 'POST',
|
|
565
736
|
* body: '{"jsonrpc":"2.0","method":"ping","id":1}',
|
|
@@ -575,24 +746,25 @@ function createMCPPostHandler(mcp, options) {
|
|
|
575
746
|
try {
|
|
576
747
|
text = await request.text();
|
|
577
748
|
} catch {
|
|
578
|
-
return Response.json((0, _src_core.buildJSONRPCError)(
|
|
749
|
+
return Response.json((0, _src_core.buildJSONRPCError)(void 0, _src_core.JSONRPC_PARSE_ERROR, "Parse error"), { status: 400 });
|
|
579
750
|
}
|
|
580
751
|
let parsed;
|
|
581
752
|
try {
|
|
582
753
|
parsed = JSON.parse(text);
|
|
583
754
|
} catch {
|
|
584
|
-
return Response.json((0, _src_core.buildJSONRPCError)(
|
|
755
|
+
return Response.json((0, _src_core.buildJSONRPCError)(void 0, _src_core.JSONRPC_PARSE_ERROR, "Parse error"), { status: 400 });
|
|
585
756
|
}
|
|
586
|
-
const
|
|
587
|
-
if (
|
|
588
|
-
const era = (0, _src_core.isModernRequest)(
|
|
589
|
-
const id =
|
|
757
|
+
const invocation = (0, _src_core.parseJSONRPCMessage)(parsed);
|
|
758
|
+
if (invocation === void 0 || !("method" in invocation)) return Response.json((0, _src_core.buildJSONRPCError)(void 0, _src_core.JSONRPC_INVALID_REQUEST, "Invalid Request"), { status: 400 });
|
|
759
|
+
const era = (0, _src_core.isModernRequest)(invocation) ? "modern" : "legacy";
|
|
760
|
+
const id = invocation.id;
|
|
590
761
|
const protocol = request.headers.get(MCP_PROTOCOL_VERSION_HEADER);
|
|
591
762
|
if (era === "modern") {
|
|
592
|
-
if ((0, _src_core.parseRequestContext)(
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
763
|
+
if ((0, _src_core.parseRequestContext)(invocation) === void 0) return Response.json((0, _src_core.buildJSONRPCError)(id, _src_core.JSONRPC_INVALID_PARAMS, "Invalid params: malformed modern request metadata"), { status: 400 });
|
|
764
|
+
}
|
|
765
|
+
const issue = inferHeaderIssue(request, invocation);
|
|
766
|
+
if (issue !== void 0) return Response.json((0, _src_core.buildJSONRPCError)(id, _src_core.MCP_HEADER_MISMATCH, issue.message), { status: 400 });
|
|
767
|
+
if (era === "legacy") {
|
|
596
768
|
if (protocol !== null && !(0, _src_core.isMCPVersion)(protocol)) return Response.json((0, _src_core.buildJSONRPCError)(id, _src_core.MCP_UNSUPPORTED_VERSION, `Unsupported MCP protocol version '${protocol}'`, {
|
|
597
769
|
supported: _src_core.SUPPORTED_PROTOCOL_VERSIONS,
|
|
598
770
|
requested: protocol
|
|
@@ -600,25 +772,14 @@ function createMCPPostHandler(mcp, options) {
|
|
|
600
772
|
}
|
|
601
773
|
const disconnect = new HTTPDisconnect(request.signal, options?.keepalive);
|
|
602
774
|
const caller = options?.caller?.(request, context);
|
|
603
|
-
const response = await mcp.dispatch(
|
|
775
|
+
const response = await mcp.dispatch(invocation, {
|
|
604
776
|
signal: disconnect.signal,
|
|
605
777
|
...caller === void 0 ? {} : { caller }
|
|
606
778
|
});
|
|
607
779
|
if (response !== void 0 && Symbol.asyncIterator in response) {
|
|
608
780
|
const stream = (0, _orkestrel_server.openStream)();
|
|
609
781
|
stream.response.headers.set(SSE_BUFFERING_HEADER, "no");
|
|
610
|
-
queueMicrotask(
|
|
611
|
-
try {
|
|
612
|
-
let next = await response.next();
|
|
613
|
-
while (!next.done) {
|
|
614
|
-
stream.write({ data: JSON.stringify(next.value) });
|
|
615
|
-
next = await response.next();
|
|
616
|
-
}
|
|
617
|
-
stream.write({ data: JSON.stringify(next.value) });
|
|
618
|
-
} catch {} finally {
|
|
619
|
-
stream.end();
|
|
620
|
-
}
|
|
621
|
-
});
|
|
782
|
+
queueMicrotask(() => void sendEventStream(response, stream));
|
|
622
783
|
return disconnect.bridge(stream);
|
|
623
784
|
}
|
|
624
785
|
const status = inferStatus(response, era);
|
|
@@ -637,7 +798,7 @@ function createMCPPostHandler(mcp, options) {
|
|
|
637
798
|
//#region src/server/transports/HTTPClientTransport.ts
|
|
638
799
|
/**
|
|
639
800
|
* The HTTP CLIENT transport for the Model Context Protocol — a
|
|
640
|
-
* {@link
|
|
801
|
+
* {@link MCPClientTransportInterface} that drives a REMOTE Streamable-HTTP MCP server over
|
|
641
802
|
* `fetch`, the egress mirror of the server's `createMCPRoutes`.
|
|
642
803
|
*
|
|
643
804
|
* @remarks
|
|
@@ -670,7 +831,7 @@ function createMCPPostHandler(mcp, options) {
|
|
|
670
831
|
* - **Total at the boundary (§14).** Every reply is narrowed (`parseJSONRPCMessage`,
|
|
671
832
|
* the SSE decoder) — a non-message reply is dropped, never asserted; a `fetch` /
|
|
672
833
|
* decode failure surfaces on the `error` event rather than escaping `send`.
|
|
673
|
-
* - **Observable (§13).** Owns the `emitter` ({@link
|
|
834
|
+
* - **Observable (§13).** Owns the `emitter` ({@link MCPClientTransportEventMap}); fires
|
|
674
835
|
* `message` per decoded reply, `error` on a fault, and `close` on `close()`.
|
|
675
836
|
*
|
|
676
837
|
* @example
|
|
@@ -701,6 +862,9 @@ var HTTPClientTransport = class {
|
|
|
701
862
|
get session() {
|
|
702
863
|
return this.#session;
|
|
703
864
|
}
|
|
865
|
+
get duplex() {
|
|
866
|
+
return false;
|
|
867
|
+
}
|
|
704
868
|
async start() {}
|
|
705
869
|
async send(message) {
|
|
706
870
|
let response;
|
|
@@ -730,11 +894,11 @@ var HTTPClientTransport = class {
|
|
|
730
894
|
this.#emitter.emit("close");
|
|
731
895
|
}
|
|
732
896
|
#buildHeaders(message) {
|
|
733
|
-
if ((0, _src_core.
|
|
734
|
-
const version = (
|
|
897
|
+
if ((0, _src_core.isModernRequest)(message)) {
|
|
898
|
+
const version = (0, _src_core.inferRequestVersion)(message);
|
|
735
899
|
const name = message.params?.["name"];
|
|
736
900
|
return {
|
|
737
|
-
...
|
|
901
|
+
...version === void 0 ? {} : { [MCP_PROTOCOL_VERSION_HEADER]: version },
|
|
738
902
|
[MCP_METHOD_HEADER]: message.method,
|
|
739
903
|
...message.method === "tools/call" && (0, _orkestrel_contract.isString)(name) ? { [MCP_NAME_HEADER]: name } : {}
|
|
740
904
|
};
|
|
@@ -885,12 +1049,12 @@ var MCPSession = class {
|
|
|
885
1049
|
/**
|
|
886
1050
|
* The per-connection JSON-RPC-over-WebSocket SERVER bridge — wraps a
|
|
887
1051
|
* {@link NodeWebSocketInterface} (the RFC 6455 wire wrapper) as a
|
|
888
|
-
* {@link
|
|
1052
|
+
* {@link MCPClientTransportInterface}, the bidirectional JSON-RPC message channel
|
|
889
1053
|
* `createWebSocketServer` pumps `mcp.dispatch` over and the egress mirror's
|
|
890
1054
|
* {@link import('./WebSocketClientTransport.js').WebSocketClientTransport} reuses.
|
|
891
1055
|
*
|
|
892
1056
|
* @remarks
|
|
893
|
-
* - **Reuses `
|
|
1057
|
+
* - **Reuses `MCPClientTransportInterface` (§21).** It IS the same generic carrier the HTTP
|
|
894
1058
|
* client transport implements — `emitter` (`message` / `close` / `error`), `start`,
|
|
895
1059
|
* `send`, `close` — so the WebSocket server and client both speak ONE transport contract,
|
|
896
1060
|
* no near-duplicate sibling interface. `session` is `undefined` (the stateless v1; a
|
|
@@ -908,7 +1072,7 @@ var MCPSession = class {
|
|
|
908
1072
|
* - **`close()`** closes the underlying socket (the RFC 6455 close handshake) and fires the
|
|
909
1073
|
* transport's `close` event (idempotent — a second `close`, or a socket-driven close, emits
|
|
910
1074
|
* once).
|
|
911
|
-
* - **Observable (§13).** Owns the `emitter` ({@link
|
|
1075
|
+
* - **Observable (§13).** Owns the `emitter` ({@link MCPClientTransportEventMap}); the emitter
|
|
912
1076
|
* isolates a listener throw (a buggy observer never corrupts the bridge). `error` is a
|
|
913
1077
|
* DOMAIN event (a transport-level fault), distinct from the emitter's listener-error channel.
|
|
914
1078
|
*/
|
|
@@ -925,6 +1089,9 @@ var WebSocketServerTransport = class {
|
|
|
925
1089
|
return this.#emitter;
|
|
926
1090
|
}
|
|
927
1091
|
get session() {}
|
|
1092
|
+
get duplex() {
|
|
1093
|
+
return true;
|
|
1094
|
+
}
|
|
928
1095
|
async start() {
|
|
929
1096
|
if (this.#started || this.#closed) return;
|
|
930
1097
|
this.#started = true;
|
|
@@ -966,7 +1133,7 @@ var WebSocketServerTransport = class {
|
|
|
966
1133
|
//#region src/server/transports/WebSocketClientTransport.ts
|
|
967
1134
|
/**
|
|
968
1135
|
* The WebSocket CLIENT transport for the Model Context Protocol — a
|
|
969
|
-
* {@link
|
|
1136
|
+
* {@link MCPClientTransportInterface} that drives a REMOTE MCP server over a WebSocket, the
|
|
970
1137
|
* egress mirror of {@link import('./factories.js').createWebSocketServer} and the WebSocket
|
|
971
1138
|
* sibling of {@link import('./HTTPClientTransport.js').HTTPClientTransport}.
|
|
972
1139
|
*
|
|
@@ -979,6 +1146,12 @@ var WebSocketServerTransport = class {
|
|
|
979
1146
|
* — a mismatch (or a non-`101` response, or a request error) REJECTS `start()` and the socket
|
|
980
1147
|
* is destroyed. On success it wraps the raw upgraded socket in `createNodeWebSocket({ socket,
|
|
981
1148
|
* head })` (CLIENT mode — no key → frames are MASKED per §5.3) and bridges its `message`.
|
|
1149
|
+
* - **The arriving socket is RE-ASKED for, never assumed.** `start()` suspends across that
|
|
1150
|
+
* connect and upgrade, so it re-checks the transport's state before installing anything: a
|
|
1151
|
+
* concurrent `start()` that already installed a socket, or a {@link close} that ended the
|
|
1152
|
+
* transport while the handshake was on the wire, both WIN — the socket that arrives late is
|
|
1153
|
+
* DESTROYED and never bound, so no orphan is left re-emitting frames at nobody. Both
|
|
1154
|
+
* `start()` calls still resolve; exactly one socket is ever bound.
|
|
982
1155
|
* - **Inbound (`message`).** Each decoded text frame is `JSON.parse`d (guarded) and narrowed
|
|
983
1156
|
* with `parseJSONRPCMessage` — a {@link JSONRPCMessage} re-emits on this transport's `message`
|
|
984
1157
|
* event (the reply the {@link import('@src/core').MCPClientInterface} correlates by `id`); a
|
|
@@ -989,7 +1162,7 @@ var WebSocketServerTransport = class {
|
|
|
989
1162
|
* - **URL scheme.** `options.url` accepts a `ws://` / `wss://` URL or an `http://` / `https://`
|
|
990
1163
|
* one; a `ws(s)` scheme is converted to `http(s)` for the underlying upgrade request (`wss`
|
|
991
1164
|
* → TLS via `node:https`). Either reaches the same endpoint.
|
|
992
|
-
* - **Observable (§13).** Owns the `emitter` ({@link
|
|
1165
|
+
* - **Observable (§13).** Owns the `emitter` ({@link MCPClientTransportEventMap}); every emit
|
|
993
1166
|
* the emitter isolates a listener throw (a buggy observer never corrupts the transport);
|
|
994
1167
|
* `error` is a DOMAIN event (a transport-level fault).
|
|
995
1168
|
*
|
|
@@ -1015,6 +1188,9 @@ var WebSocketClientTransport = class {
|
|
|
1015
1188
|
return this.#emitter;
|
|
1016
1189
|
}
|
|
1017
1190
|
get session() {}
|
|
1191
|
+
get duplex() {
|
|
1192
|
+
return true;
|
|
1193
|
+
}
|
|
1018
1194
|
async start() {
|
|
1019
1195
|
if (this.#socket !== void 0) return;
|
|
1020
1196
|
this.#closed = false;
|
|
@@ -1043,6 +1219,11 @@ var WebSocketClientTransport = class {
|
|
|
1043
1219
|
reject(/* @__PURE__ */ new Error("WebSocket handshake failed: Sec-WebSocket-Accept mismatch"));
|
|
1044
1220
|
return;
|
|
1045
1221
|
}
|
|
1222
|
+
if (this.#closed || this.#socket !== void 0) {
|
|
1223
|
+
socket.destroy();
|
|
1224
|
+
resolve();
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1046
1227
|
const ws = (0, _orkestrel_websocket.createNodeWebSocket)({
|
|
1047
1228
|
socket,
|
|
1048
1229
|
head
|
|
@@ -1074,7 +1255,7 @@ var WebSocketClientTransport = class {
|
|
|
1074
1255
|
}
|
|
1075
1256
|
#bind(ws) {
|
|
1076
1257
|
ws.emitter.on("message", (text) => this.#receive(text));
|
|
1077
|
-
ws.emitter.on("close", () => this.#onClose());
|
|
1258
|
+
ws.emitter.on("close", () => this.#onClose(ws));
|
|
1078
1259
|
ws.emitter.on("error", (error) => this.#emitter.emit("error", error));
|
|
1079
1260
|
}
|
|
1080
1261
|
#receive(text) {
|
|
@@ -1092,8 +1273,8 @@ var WebSocketClientTransport = class {
|
|
|
1092
1273
|
}
|
|
1093
1274
|
this.#emitter.emit("message", message);
|
|
1094
1275
|
}
|
|
1095
|
-
#onClose() {
|
|
1096
|
-
if (this.#closed) return;
|
|
1276
|
+
#onClose(socket) {
|
|
1277
|
+
if (this.#closed || this.#socket !== socket) return;
|
|
1097
1278
|
this.#closed = true;
|
|
1098
1279
|
this.#socket = void 0;
|
|
1099
1280
|
this.#emitter.emit("close");
|
|
@@ -1110,7 +1291,7 @@ var WebSocketClientTransport = class {
|
|
|
1110
1291
|
//#region src/server/transports/StdioClientTransport.ts
|
|
1111
1292
|
/**
|
|
1112
1293
|
* The stdio CLIENT transport for the Model Context Protocol — a
|
|
1113
|
-
* {@link
|
|
1294
|
+
* {@link MCPClientTransportInterface} that drives a CHILD PROCESS MCP server over
|
|
1114
1295
|
* newline-delimited JSON-RPC on `stdin`/`stdout`, the stdio sibling of {@link
|
|
1115
1296
|
* import('./HTTPClientTransport.js').HTTPClientTransport} and {@link
|
|
1116
1297
|
* import('./WebSocketClientTransport.js').WebSocketClientTransport}.
|
|
@@ -1129,7 +1310,7 @@ var WebSocketClientTransport = class {
|
|
|
1129
1310
|
* - **Outbound (`send`).** `send(message)` writes one newline-terminated
|
|
1130
1311
|
* `JSON.stringify`d line to the child's `stdin`.
|
|
1131
1312
|
* - **`close()`** kills the child process and fires `close` (idempotent).
|
|
1132
|
-
* - **Observable (§13).** Owns the `emitter` ({@link
|
|
1313
|
+
* - **Observable (§13).** Owns the `emitter` ({@link MCPClientTransportEventMap}); the
|
|
1133
1314
|
* emitter isolates a listener throw; `error` is a DOMAIN event (a transport-level
|
|
1134
1315
|
* fault), distinct from the emitter's own listener-error channel.
|
|
1135
1316
|
*
|
|
@@ -1158,6 +1339,9 @@ var StdioClientTransport = class {
|
|
|
1158
1339
|
return this.#emitter;
|
|
1159
1340
|
}
|
|
1160
1341
|
get session() {}
|
|
1342
|
+
get duplex() {
|
|
1343
|
+
return true;
|
|
1344
|
+
}
|
|
1161
1345
|
async start() {
|
|
1162
1346
|
if (this.#child !== void 0) return;
|
|
1163
1347
|
this.#closed = false;
|
|
@@ -1172,7 +1356,7 @@ var StdioClientTransport = class {
|
|
|
1172
1356
|
});
|
|
1173
1357
|
this.#child = child;
|
|
1174
1358
|
child.stdout.on("data", (chunk) => this.#receive(chunk.toString()));
|
|
1175
|
-
child.on("close", () => this.#onClose());
|
|
1359
|
+
child.on("close", () => this.#onClose(child));
|
|
1176
1360
|
child.on("error", (error) => this.#emitter.emit("error", error));
|
|
1177
1361
|
}
|
|
1178
1362
|
async send(message) {
|
|
@@ -1193,8 +1377,8 @@ var StdioClientTransport = class {
|
|
|
1193
1377
|
this.#buffer = remainder;
|
|
1194
1378
|
dispatchLines(this.#emitter, lines);
|
|
1195
1379
|
}
|
|
1196
|
-
#onClose() {
|
|
1197
|
-
if (this.#closed) return;
|
|
1380
|
+
#onClose(child) {
|
|
1381
|
+
if (this.#closed || this.#child !== child) return;
|
|
1198
1382
|
this.#closed = true;
|
|
1199
1383
|
this.#child = void 0;
|
|
1200
1384
|
this.#emitter.emit("close");
|
|
@@ -1205,13 +1389,13 @@ var StdioClientTransport = class {
|
|
|
1205
1389
|
/**
|
|
1206
1390
|
* The stdio SERVER transport for the Model Context Protocol — wraps an injectable
|
|
1207
1391
|
* readable/writable stream pair (`process.stdin`/`process.stdout` in production, a
|
|
1208
|
-
* test double in tests) as a {@link
|
|
1392
|
+
* test double in tests) as a {@link MCPClientTransportInterface}, the newline-delimited
|
|
1209
1393
|
* JSON-RPC channel {@link import('../factories.js').createStdioServer} pumps
|
|
1210
1394
|
* `mcp.dispatch` over, the stdio mirror of {@link
|
|
1211
1395
|
* import('./WebSocketServerTransport.js').WebSocketServerTransport}.
|
|
1212
1396
|
*
|
|
1213
1397
|
* @remarks
|
|
1214
|
-
* - **Reuses `
|
|
1398
|
+
* - **Reuses `MCPClientTransportInterface` (§21).** The same generic carrier the HTTP
|
|
1215
1399
|
* and WebSocket server transports implement — `emitter` (`message` / `close` /
|
|
1216
1400
|
* `error`), `start`, `send`, `close`. `session` is `undefined` (the stateless v1).
|
|
1217
1401
|
* - **Inbound (`message`).** `start()` subscribes to `input`'s `data` event; each
|
|
@@ -1226,7 +1410,7 @@ var StdioClientTransport = class {
|
|
|
1226
1410
|
* - **`close()`** fires this transport's `close` (idempotent) — the injected streams
|
|
1227
1411
|
* are owned by the caller (typically `process.stdin`/`process.stdout`, which must
|
|
1228
1412
|
* never be closed out from under the process) and are not torn down here.
|
|
1229
|
-
* - **Observable (§13).** Owns the `emitter` ({@link
|
|
1413
|
+
* - **Observable (§13).** Owns the `emitter` ({@link MCPClientTransportEventMap}); the
|
|
1230
1414
|
* emitter isolates a listener throw; `error` is a DOMAIN event (a transport-level
|
|
1231
1415
|
* fault), distinct from the emitter's own listener-error channel.
|
|
1232
1416
|
*/
|
|
@@ -1246,6 +1430,9 @@ var StdioServerTransport = class {
|
|
|
1246
1430
|
return this.#emitter;
|
|
1247
1431
|
}
|
|
1248
1432
|
get session() {}
|
|
1433
|
+
get duplex() {
|
|
1434
|
+
return true;
|
|
1435
|
+
}
|
|
1249
1436
|
async start() {
|
|
1250
1437
|
if (this.#started || this.#closed) return;
|
|
1251
1438
|
this.#started = true;
|
|
@@ -1275,8 +1462,24 @@ var StdioServerTransport = class {
|
|
|
1275
1462
|
//#endregion
|
|
1276
1463
|
//#region src/server/factories.ts
|
|
1277
1464
|
/**
|
|
1465
|
+
* Adapt the installed server token primitives to the host-neutral MCP continuation port.
|
|
1466
|
+
*
|
|
1467
|
+
* @param secret - Current signing secret or `[current, ...older]` rotation list
|
|
1468
|
+
* @returns A continuation port that seals and opens opaque canonical state strings
|
|
1469
|
+
*/
|
|
1470
|
+
function createMCPContinuation(secret) {
|
|
1471
|
+
return {
|
|
1472
|
+
seal(value) {
|
|
1473
|
+
return (0, _orkestrel_server.signToken)(value, { secret });
|
|
1474
|
+
},
|
|
1475
|
+
open(value) {
|
|
1476
|
+
return (0, _orkestrel_server.verifyToken)(value, secret);
|
|
1477
|
+
}
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
/**
|
|
1278
1481
|
* Create the MCP Streamable-HTTP transport routes — mounts a transport-agnostic
|
|
1279
|
-
* {@link
|
|
1482
|
+
* {@link MCPDispatcherInterface} (the `@src/core` dispatch boundary) on the fetch-standard router
|
|
1280
1483
|
* spine, pumping each `POST` body through `mcp.dispatch`. Returns the {@link RouteInput}s to
|
|
1281
1484
|
* hand to `router.add(...)`.
|
|
1282
1485
|
*
|
|
@@ -1287,14 +1490,14 @@ var StdioServerTransport = class {
|
|
|
1287
1490
|
* DISPATCH-level outcomes:
|
|
1288
1491
|
*
|
|
1289
1492
|
* - A **transport** failure — a malformed JSON body, or a parsed value that is not a
|
|
1290
|
-
* JSON-RPC
|
|
1291
|
-
* error / `-32600` Invalid Request,
|
|
1493
|
+
* JSON-RPC INVOCATION — is an HTTP `400` carrying a JSON-RPC error BODY (`-32700` Parse
|
|
1494
|
+
* error / `-32600` Invalid Request), with the `id` it could not read OMITTED.
|
|
1292
1495
|
* - Modern protocol/method/name headers are validated against the body; a mismatch is
|
|
1293
1496
|
* HTTP `400` + `-32020`. Headerless initialize is accepted, a live legacy session supplies
|
|
1294
1497
|
* its pinned revision, and every other headerless request is rejected.
|
|
1295
1498
|
* - Legacy dispatch errors stay IN-BAND at HTTP `200`; modern errors map to `400` for
|
|
1296
1499
|
* `-32020` / `-32021` / `-32022` / `-32602`, `404` for `-32601`, and `200` otherwise.
|
|
1297
|
-
* - A **notification** (
|
|
1500
|
+
* - A **notification** (an invocation with no `id`, which `dispatch` resolves to
|
|
1298
1501
|
* `undefined`) is a `202 Accepted` with no body.
|
|
1299
1502
|
*
|
|
1300
1503
|
* When `streaming` is enabled (the default) and the client `Accept`s `text/event-stream`,
|
|
@@ -1313,7 +1516,7 @@ var StdioServerTransport = class {
|
|
|
1313
1516
|
* allowlist or explicitly delegates validation to an upstream layer.
|
|
1314
1517
|
*
|
|
1315
1518
|
* @typeParam TState - The consumer's opaque per-request state type
|
|
1316
|
-
* @param mcp - The transport-agnostic {@link
|
|
1519
|
+
* @param mcp - The transport-agnostic {@link MCPDispatcherInterface} to expose over HTTP
|
|
1317
1520
|
* @param options - Optional `path` (default {@link DEFAULT_MCP_PATH}) and `streaming`
|
|
1318
1521
|
* (default `true`), plus shared origin, keepalive, and synchronous caller-extraction options; see
|
|
1319
1522
|
* {@link HTTPTransportOptions}
|
|
@@ -1321,11 +1524,11 @@ var StdioServerTransport = class {
|
|
|
1321
1524
|
*
|
|
1322
1525
|
* @example
|
|
1323
1526
|
* ```ts
|
|
1324
|
-
* import { createMCPServer, createToolManager } from '@src/core'
|
|
1527
|
+
* import { createMCPLegacy, createMCPServer, createToolManager } from '@src/core'
|
|
1325
1528
|
* import { createMCPRoutes } from '@src/server'
|
|
1326
1529
|
*
|
|
1327
1530
|
* const mcp = createMCPServer({ identity: { name: 'docs', version: '1.0.0' }, tools: createToolManager() })
|
|
1328
|
-
* const routes = createMCPRoutes(mcp) //
|
|
1531
|
+
* const routes = createMCPRoutes(createMCPLegacy(mcp)) // both eras; pass `mcp` for modern only
|
|
1329
1532
|
* ```
|
|
1330
1533
|
*/
|
|
1331
1534
|
function createMCPRoutes(mcp, options) {
|
|
@@ -1338,7 +1541,7 @@ function createMCPRoutes(mcp, options) {
|
|
|
1338
1541
|
}
|
|
1339
1542
|
/**
|
|
1340
1543
|
* Create the HTTP CLIENT transport for an {@link import('@src/core').MCPClientInterface}
|
|
1341
|
-
* — a {@link
|
|
1544
|
+
* — a {@link MCPClientTransportInterface} that drives a REMOTE Streamable-HTTP MCP server
|
|
1342
1545
|
* over `fetch`. The egress mirror of {@link createMCPRoutes}.
|
|
1343
1546
|
*
|
|
1344
1547
|
* @remarks
|
|
@@ -1357,7 +1560,7 @@ function createMCPRoutes(mcp, options) {
|
|
|
1357
1560
|
* @param options - `url` (the remote endpoint; REQUIRED), optional `headers` merged onto
|
|
1358
1561
|
* every request, optional `fetch` (default `globalThis.fetch`), and optional `timeout`
|
|
1359
1562
|
* (ms, applied via `AbortSignal.timeout`); see {@link HTTPClientTransportOptions}
|
|
1360
|
-
* @returns A working {@link
|
|
1563
|
+
* @returns A working {@link MCPClientTransportInterface} over `fetch`
|
|
1361
1564
|
*
|
|
1362
1565
|
* @example
|
|
1363
1566
|
* ```ts
|
|
@@ -1376,7 +1579,7 @@ function createHTTPClientTransport(options) {
|
|
|
1376
1579
|
}
|
|
1377
1580
|
/**
|
|
1378
1581
|
* Create the MCP WebSocket transport INGRESS — an {@link UpgradeHandler} that exposes a
|
|
1379
|
-
* transport-agnostic {@link
|
|
1582
|
+
* transport-agnostic {@link MCPDispatcherInterface} over a WebSocket, the WebSocket mirror of
|
|
1380
1583
|
* {@link createMCPRoutes}. Register it on the spine's upgrade seam.
|
|
1381
1584
|
*
|
|
1382
1585
|
* @remarks
|
|
@@ -1404,7 +1607,7 @@ function createHTTPClientTransport(options) {
|
|
|
1404
1607
|
* handler BEFORE this one — that handler can claim (decline + destroy) an unauthenticated
|
|
1405
1608
|
* upgrade so it never reaches this pump.
|
|
1406
1609
|
*
|
|
1407
|
-
* @param mcp - The transport-agnostic {@link
|
|
1610
|
+
* @param mcp - The transport-agnostic {@link MCPDispatcherInterface} to expose over WebSocket
|
|
1408
1611
|
* @param options - Optional `path` (default {@link DEFAULT_MCP_PATH}) and `subprotocol`
|
|
1409
1612
|
* (default {@link MCP_WEBSOCKET_SUBPROTOCOL}); see {@link WebSocketServerOptions}
|
|
1410
1613
|
* @returns An {@link UpgradeHandler} to register with the spine's `upgrade` seam
|
|
@@ -1442,7 +1645,7 @@ function createWebSocketServer(mcp, options) {
|
|
|
1442
1645
|
}
|
|
1443
1646
|
/**
|
|
1444
1647
|
* Create the WebSocket CLIENT transport for an {@link import('@src/core').MCPClientInterface}
|
|
1445
|
-
* — a {@link
|
|
1648
|
+
* — a {@link MCPClientTransportInterface} that drives a REMOTE MCP server over a WebSocket. The
|
|
1446
1649
|
* egress mirror of {@link createWebSocketServer} and the WebSocket sibling of {@link
|
|
1447
1650
|
* createHTTPClientTransport}.
|
|
1448
1651
|
*
|
|
@@ -1458,7 +1661,7 @@ function createWebSocketServer(mcp, options) {
|
|
|
1458
1661
|
*
|
|
1459
1662
|
* @param options - `url` (the remote WebSocket endpoint; REQUIRED) and optional `headers`
|
|
1460
1663
|
* merged onto the upgrade request; see {@link WebSocketClientTransportOptions}
|
|
1461
|
-
* @returns A working {@link
|
|
1664
|
+
* @returns A working {@link MCPClientTransportInterface} over a WebSocket
|
|
1462
1665
|
*
|
|
1463
1666
|
* @example
|
|
1464
1667
|
* ```ts
|
|
@@ -1477,7 +1680,7 @@ function createWebSocketClientTransport(options) {
|
|
|
1477
1680
|
}
|
|
1478
1681
|
/**
|
|
1479
1682
|
* Create the stdio CLIENT transport for an {@link import('@src/core').MCPClientInterface}
|
|
1480
|
-
* — a {@link
|
|
1683
|
+
* — a {@link MCPClientTransportInterface} that spawns and drives a CHILD PROCESS MCP server
|
|
1481
1684
|
* over newline-delimited JSON-RPC on `stdin`/`stdout`, the stdio sibling of {@link
|
|
1482
1685
|
* createHTTPClientTransport} and {@link createWebSocketClientTransport}.
|
|
1483
1686
|
*
|
|
@@ -1492,7 +1695,7 @@ function createWebSocketClientTransport(options) {
|
|
|
1492
1695
|
*
|
|
1493
1696
|
* @param options - `command` (the executable to spawn; REQUIRED), optional `args`,
|
|
1494
1697
|
* and optional `env`; see {@link StdioClientTransportOptions}
|
|
1495
|
-
* @returns A working {@link
|
|
1698
|
+
* @returns A working {@link MCPClientTransportInterface} over a child process's stdio
|
|
1496
1699
|
*
|
|
1497
1700
|
* @example
|
|
1498
1701
|
* ```ts
|
|
@@ -1511,7 +1714,7 @@ function createStdioClientTransport(options) {
|
|
|
1511
1714
|
}
|
|
1512
1715
|
/**
|
|
1513
1716
|
* Create the MCP stdio transport INGRESS — pumps a transport-agnostic {@link
|
|
1514
|
-
*
|
|
1717
|
+
* MCPDispatcherInterface} over newline-delimited JSON-RPC on `stdin`/`stdout` (or an
|
|
1515
1718
|
* injected stream pair), the stdio mirror of {@link createWebSocketServer}.
|
|
1516
1719
|
*
|
|
1517
1720
|
* @remarks
|
|
@@ -1525,7 +1728,7 @@ function createStdioClientTransport(options) {
|
|
|
1525
1728
|
* surfaces on `mcp.emitter`'s `error` event rather than escaping the (async) message
|
|
1526
1729
|
* pump.
|
|
1527
1730
|
*
|
|
1528
|
-
* @param mcp - The transport-agnostic {@link
|
|
1731
|
+
* @param mcp - The transport-agnostic {@link MCPDispatcherInterface} to expose over stdio
|
|
1529
1732
|
* @param options - Optional injectable `input` / `output` streams; see
|
|
1530
1733
|
* {@link StdioServerOptions}
|
|
1531
1734
|
* @returns A `{ start(): void; stop(): void }` handle to arm / tear down the pump
|
|
@@ -1578,7 +1781,10 @@ function createStdioServer(mcp, options) {
|
|
|
1578
1781
|
* live-session request. It then
|
|
1579
1782
|
* FORWARDS a fresh `Request` carrying the buffered `text` (`next(forwarded)`) — never the
|
|
1580
1783
|
* already-consumed original — so the route re-reads the same body, and stamps the response
|
|
1581
|
-
* with {@link MCP_SESSION_HEADER}.
|
|
1784
|
+
* with {@link MCP_SESSION_HEADER}. The entry's `touched` instant is read AFTER that
|
|
1785
|
+
* downstream response, because it means the LAST ACCESS: a request slower than `ttl` would
|
|
1786
|
+
* otherwise store a session that is already expired, and the write-back RE-ASKS the store, so
|
|
1787
|
+
* a `DELETE` arriving while the request was suspended is not undone.
|
|
1582
1788
|
* - **`GET {path}`.** Resolves the session the same way (no mint — only `initialize` mints);
|
|
1583
1789
|
* an invalid / unknown id is the same `404`. A valid session opens the resumable
|
|
1584
1790
|
* server→client stream via `@orkestrel/server`'s {@link import('@orkestrel/server').openStream}:
|
|
@@ -1680,22 +1886,24 @@ function createMCPSession(options) {
|
|
|
1680
1886
|
}
|
|
1681
1887
|
if (context.method !== "POST" || text === void 0) return next();
|
|
1682
1888
|
let created;
|
|
1683
|
-
if (entry === void 0)
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1889
|
+
if (entry === void 0) {
|
|
1890
|
+
if (parsed !== void 0 && (0, _src_core.isInitializeRequest)(parsed)) {
|
|
1891
|
+
created = {
|
|
1892
|
+
session: new MCPSession(crypto.randomUUID(), capacity !== void 0 ? { capacity } : {}),
|
|
1893
|
+
touched: clock(),
|
|
1894
|
+
version: inferLegacyVersion(parsed)
|
|
1895
|
+
};
|
|
1896
|
+
entry = created;
|
|
1897
|
+
} else return rejectUnknownSession();
|
|
1898
|
+
}
|
|
1691
1899
|
if (!Reflect.set(context.state, "session", entry.session)) throw new Error("MCP session state is not writable");
|
|
1692
1900
|
const headers = new Headers(request.headers);
|
|
1693
1901
|
if (parsed === void 0 || !(0, _src_core.isInitializeRequest)(parsed)) {
|
|
1694
|
-
const
|
|
1695
|
-
if (
|
|
1696
|
-
else if (
|
|
1697
|
-
const requestId = parsed !== void 0 && "method" in parsed ? parsed.id
|
|
1698
|
-
return Response.json((0, _src_core.buildJSONRPCError)(requestId, _src_core.MCP_HEADER_MISMATCH,
|
|
1902
|
+
const issue = inferHeaderIssue(request, entry.version);
|
|
1903
|
+
if (issue?.reason === "missing") headers.set(MCP_PROTOCOL_VERSION_HEADER, entry.version);
|
|
1904
|
+
else if (issue !== void 0) {
|
|
1905
|
+
const requestId = parsed !== void 0 && "method" in parsed ? parsed.id : void 0;
|
|
1906
|
+
return Response.json((0, _src_core.buildJSONRPCError)(requestId, _src_core.MCP_HEADER_MISMATCH, issue.message), { status: 400 });
|
|
1699
1907
|
}
|
|
1700
1908
|
}
|
|
1701
1909
|
const response = await next(new Request(context.url, {
|
|
@@ -1706,8 +1914,14 @@ function createMCPSession(options) {
|
|
|
1706
1914
|
}));
|
|
1707
1915
|
if (created !== void 0) {
|
|
1708
1916
|
if (!response.ok) return response;
|
|
1709
|
-
store.set(created.session.id,
|
|
1710
|
-
|
|
1917
|
+
store.set(created.session.id, {
|
|
1918
|
+
...created,
|
|
1919
|
+
touched: clock()
|
|
1920
|
+
});
|
|
1921
|
+
} else if (store.get(entry.session.id) === entry) store.set(entry.session.id, {
|
|
1922
|
+
...entry,
|
|
1923
|
+
touched: clock()
|
|
1924
|
+
});
|
|
1711
1925
|
response.headers.set(MCP_SESSION_HEADER, entry.session.id);
|
|
1712
1926
|
return response;
|
|
1713
1927
|
};
|
|
@@ -1718,6 +1932,7 @@ exports.DEFAULT_MCP_PATH = DEFAULT_MCP_PATH;
|
|
|
1718
1932
|
exports.DEFAULT_MCP_SESSION_CAPACITY = DEFAULT_MCP_SESSION_CAPACITY;
|
|
1719
1933
|
exports.DEFAULT_MCP_SESSION_TTL = DEFAULT_MCP_SESSION_TTL;
|
|
1720
1934
|
exports.HTTPClientTransport = HTTPClientTransport;
|
|
1935
|
+
exports.HTTPDisconnect = HTTPDisconnect;
|
|
1721
1936
|
exports.MCPSession = MCPSession;
|
|
1722
1937
|
exports.MCP_METHOD_HEADER = MCP_METHOD_HEADER;
|
|
1723
1938
|
exports.MCP_NAME_HEADER = MCP_NAME_HEADER;
|
|
@@ -1735,6 +1950,7 @@ exports.acceptsEventStream = acceptsEventStream;
|
|
|
1735
1950
|
exports.allowsOrigin = allowsOrigin;
|
|
1736
1951
|
exports.bridgeMessageTransport = bridgeMessageTransport;
|
|
1737
1952
|
exports.createHTTPClientTransport = createHTTPClientTransport;
|
|
1953
|
+
exports.createMCPContinuation = createMCPContinuation;
|
|
1738
1954
|
exports.createMCPPostHandler = createMCPPostHandler;
|
|
1739
1955
|
exports.createMCPRoutes = createMCPRoutes;
|
|
1740
1956
|
exports.createMCPSession = createMCPSession;
|
|
@@ -1746,13 +1962,14 @@ exports.createWebSocketServer = createWebSocketServer;
|
|
|
1746
1962
|
exports.decodeEvent = decodeEvent;
|
|
1747
1963
|
exports.dispatchLines = dispatchLines;
|
|
1748
1964
|
exports.extractLines = extractLines;
|
|
1965
|
+
exports.inferHeaderIssue = inferHeaderIssue;
|
|
1749
1966
|
exports.inferLegacyVersion = inferLegacyVersion;
|
|
1750
1967
|
exports.inferStatus = inferStatus;
|
|
1751
|
-
exports.matchesModernHeaders = matchesModernHeaders;
|
|
1752
1968
|
exports.readEventStream = readEventStream;
|
|
1753
1969
|
exports.readLastEventId = readLastEventId;
|
|
1754
1970
|
exports.readSessionHeader = readSessionHeader;
|
|
1755
1971
|
exports.rejectUnknownSession = rejectUnknownSession;
|
|
1972
|
+
exports.sendEventStream = sendEventStream;
|
|
1756
1973
|
exports.upgradeRequestPath = upgradeRequestPath;
|
|
1757
1974
|
|
|
1758
1975
|
//# sourceMappingURL=index.cjs.map
|