@cyanmycelium/mcp-broker 1.2.0 → 1.3.0
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/.mcp-broker.example/CONFIGURATION-EN.md +300 -44
- package/.mcp-broker.example/CONFIGURATION-FR.md +313 -44
- package/.mcp-broker.example/README.md +73 -2
- package/.mcp-broker.example/config.json +18 -9
- package/.mcp-broker.example/config.stdio-bridge.json +16 -0
- package/README.md +407 -27
- package/dist/bin.js +215 -20
- package/dist/bin.js.map +1 -1
- package/dist/chunk-BZUZYXVA.js +5955 -0
- package/dist/chunk-BZUZYXVA.js.map +1 -0
- package/dist/grammars/claude/en.json +12 -0
- package/dist/grammars/claude/fr.json +12 -0
- package/dist/grammars/default/en.json +40 -0
- package/dist/grammars/default/fr.json +40 -0
- package/dist/grammars/default/zh.json +40 -0
- package/dist/index.d.ts +991 -25
- package/dist/index.js +1 -1
- package/package.json +3 -3
- package/src/auth/index.ts +3 -1
- package/src/auth/provider.auth.ts +126 -8
- package/src/authorization/policy.engine.ts +11 -2
- package/src/authorization/policy.types.ts +25 -1
- package/src/bin.ts +325 -28
- package/src/broker/adapters/broker.adapter.diagnose.ts +45 -0
- package/src/broker/adapters/broker.adapter.guide.ts +108 -0
- package/src/broker/aggregate/aggregate.server.ts +82 -15
- package/src/broker/aggregate/provider.client.session.ts +85 -11
- package/src/broker/behaviors/broker.behavior.diagnose.ts +47 -0
- package/src/broker/behaviors/broker.behavior.guide.ts +79 -0
- package/src/broker/broker.context.ts +65 -0
- package/src/broker/broker.diagnostics.ts +495 -0
- package/src/broker/broker.guides.ts +1029 -0
- package/src/broker/broker.server.ts +23 -7
- package/src/broker/broker.slots.ts +36 -0
- package/src/broker/grammars/claude/en.json +12 -0
- package/src/broker/grammars/claude/fr.json +12 -0
- package/src/broker/grammars/default/en.json +40 -0
- package/src/broker/grammars/default/fr.json +40 -0
- package/src/broker/grammars/default/zh.json +40 -0
- package/src/config.ts +191 -4
- package/src/index.ts +38 -3
- package/src/remote.transports.ts +127 -10
- package/src/remote.upstream.ts +4 -1
- package/src/ws/ws.interfaces.ts +148 -3
- package/src/ws/ws.tunnel.builder.ts +63 -1
- package/src/ws/ws.tunnel.ts +1150 -173
- package/web/README.md +31 -4
- package/dist/chunk-FTDKH2C4.js +0 -3670
- package/dist/chunk-FTDKH2C4.js.map +0 -1
package/src/ws/ws.tunnel.ts
CHANGED
|
@@ -10,12 +10,16 @@ import { StdioTransport, StreamableHttpEndpoint } from "@cyanmycelium/mcp-core/n
|
|
|
10
10
|
// The broker is itself a provider: it publishes its own `_broker` and `_all`
|
|
11
11
|
// slots: so sharing the provider package's wire contract is the natural way to
|
|
12
12
|
// keep one definition of the envelope rather than two that drift.
|
|
13
|
-
import { decodeEnvelope, encodeEnvelope, encodeErrorEnvelope, envelopeFrame, TunnelErrorCodes } from "@cyanmycelium/mcp-broker-provider/protocol";
|
|
13
|
+
import { decodeEnvelope, encodeEnvelope, encodeErrorEnvelope, envelopeFrame, TUNNEL_REGISTER_METHOD, TunnelErrorCodes } from "@cyanmycelium/mcp-broker-provider/protocol";
|
|
14
14
|
import { StdioUpstream } from "../stdio.upstream";
|
|
15
15
|
import { RemoteUpstream } from "../remote.upstream";
|
|
16
16
|
import type { IUpstream } from "../upstream";
|
|
17
17
|
import { startBrokerServer, BROKER_PROVIDER_NAME } from "../broker/index";
|
|
18
18
|
import type { IBrokerContext, IBrokerProviderInfo, BrokerProviderTransport } from "../broker/index";
|
|
19
|
+
// Imported from the defining module rather than the barrel: these two are the
|
|
20
|
+
// optional `IBrokerContext` extensions `broker_diagnose` reads, and the barrel
|
|
21
|
+
// does not re-export them yet.
|
|
22
|
+
import type { IBrokerSecurityInfo } from "../broker/broker.context";
|
|
19
23
|
import { AggregateServer } from "../broker/aggregate/aggregate.server";
|
|
20
24
|
import {
|
|
21
25
|
HttpAuthGuard,
|
|
@@ -38,7 +42,18 @@ import {
|
|
|
38
42
|
type ISlotResourceResolver,
|
|
39
43
|
} from "../authorization/index";
|
|
40
44
|
import { VERSION, PACKAGE_NAME } from "../version";
|
|
41
|
-
import type {
|
|
45
|
+
import type {
|
|
46
|
+
AllowedOrigins,
|
|
47
|
+
IHttpSession,
|
|
48
|
+
IInternalClient,
|
|
49
|
+
IProviderState,
|
|
50
|
+
IWsTunnelOptions,
|
|
51
|
+
McpEndpointKind,
|
|
52
|
+
ProviderTakeoverMode,
|
|
53
|
+
ResponseSink,
|
|
54
|
+
WsConnectRole,
|
|
55
|
+
WsRouteClassification,
|
|
56
|
+
} from "./ws.interfaces";
|
|
42
57
|
|
|
43
58
|
// ---------------------------------------------------------------------------
|
|
44
59
|
// Static-file helpers
|
|
@@ -80,6 +95,89 @@ function requestIdOf(frame: string): string | number | undefined {
|
|
|
80
95
|
return undefined;
|
|
81
96
|
}
|
|
82
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Prefix of every broker-assigned JSON-RPC id.
|
|
100
|
+
*
|
|
101
|
+
* Requests are re-numbered on their way to a provider so two clients on one
|
|
102
|
+
* slot cannot collide (see {@link WsTunnel._trackRequest}). The prefix is
|
|
103
|
+
* cosmetic but deliberate: it makes a stray id recognizable as the broker's in
|
|
104
|
+
* a provider's own logs, instead of looking like a client's.
|
|
105
|
+
*/
|
|
106
|
+
const BROKER_REQUEST_ID_PREFIX = "brk-";
|
|
107
|
+
|
|
108
|
+
/** Default heartbeat period, in ms, for `providerHeartbeatIntervalMs`. */
|
|
109
|
+
const DEFAULT_PROVIDER_HEARTBEAT_MS = 30_000;
|
|
110
|
+
|
|
111
|
+
/** Default per-request deadline, in ms, for `providerRequestTimeoutMs`. */
|
|
112
|
+
const DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS = 60_000;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* RFC 6455 caps a close reason at 123 bytes and `ws` throws a `RangeError`
|
|
116
|
+
* rather than truncating, which would turn a diagnostic into a crash inside a
|
|
117
|
+
* connection handler. Every close reason goes through {@link truncateReason}.
|
|
118
|
+
*/
|
|
119
|
+
const MAX_CLOSE_REASON_BYTES = 123;
|
|
120
|
+
|
|
121
|
+
/** Shortens a close reason to what RFC 6455 allows, without splitting a character. */
|
|
122
|
+
function truncateReason(reason: string): string {
|
|
123
|
+
if (Buffer.byteLength(reason, "utf8") <= MAX_CLOSE_REASON_BYTES) return reason;
|
|
124
|
+
let cut = reason;
|
|
125
|
+
while (Buffer.byteLength(cut + "...", "utf8") > MAX_CLOSE_REASON_BYTES) {
|
|
126
|
+
cut = cut.slice(0, -1);
|
|
127
|
+
}
|
|
128
|
+
return cut + "...";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* `decodeURIComponent` that returns its input instead of throwing.
|
|
133
|
+
*
|
|
134
|
+
* A slot name arrives from a URL, so it is attacker-controlled: `%zz` throws a
|
|
135
|
+
* `URIError`, and both call sites here run inside a WebSocket connection
|
|
136
|
+
* handler, where an exception is an uncaught one.
|
|
137
|
+
*/
|
|
138
|
+
function safeDecode(raw: string): string {
|
|
139
|
+
try {
|
|
140
|
+
return decodeURIComponent(raw);
|
|
141
|
+
} catch {
|
|
142
|
+
return raw;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Parses a frame into a plain JSON object, or `undefined` for anything else. */
|
|
147
|
+
function parseObjectFrame(text: string): Record<string, unknown> | undefined {
|
|
148
|
+
let parsed: unknown;
|
|
149
|
+
try {
|
|
150
|
+
parsed = JSON.parse(text);
|
|
151
|
+
} catch {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
|
|
155
|
+
return parsed as Record<string, unknown>;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* `true` when the frame is the tunnel registration notification asking to join
|
|
160
|
+
* the `_all` aggregate slot, in either of the two shapes the broker accepts.
|
|
161
|
+
*
|
|
162
|
+
* Both are notifications a peer that does not know them ignores, which is why
|
|
163
|
+
* the opt-in could be added without a protocol version: the JSON-RPC form
|
|
164
|
+
* `{"jsonrpc":"2.0","method":"notifications/register","params":{"aggregate":true}}`
|
|
165
|
+
* is what the provider SDK sends on both paths, and the legacy control frame
|
|
166
|
+
* `{"type":"register","aggregate":true}` is what hand-written providers send on
|
|
167
|
+
* the slot-scoped path.
|
|
168
|
+
*/
|
|
169
|
+
function registrationAsksForAggregate(payload: unknown): boolean {
|
|
170
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return false;
|
|
171
|
+
const frame = payload as { jsonrpc?: unknown; method?: unknown; params?: unknown; type?: unknown; aggregate?: unknown };
|
|
172
|
+
if (frame.jsonrpc !== undefined) {
|
|
173
|
+
if (frame.method !== TUNNEL_REGISTER_METHOD) return false;
|
|
174
|
+
const params = frame.params;
|
|
175
|
+
if (typeof params !== "object" || params === null) return false;
|
|
176
|
+
return (params as { aggregate?: unknown }).aggregate === true;
|
|
177
|
+
}
|
|
178
|
+
return frame.type === "register" && frame.aggregate === true;
|
|
179
|
+
}
|
|
180
|
+
|
|
83
181
|
/**
|
|
84
182
|
* Turns the three shapes an operator may configure into the single predicate
|
|
85
183
|
* the Streamable HTTP endpoint expects.
|
|
@@ -157,6 +255,53 @@ export class WsTunnel implements IBrokerContext {
|
|
|
157
255
|
/** Maps a multiplexed WebSocket to the set of provider names it feeds. */
|
|
158
256
|
private readonly _multiplexSockets = new Map<WebSocket, Set<string>>();
|
|
159
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Every inbound provider socket the heartbeat watches, dedicated and
|
|
260
|
+
* multiplexed alike.
|
|
261
|
+
*
|
|
262
|
+
* An explicit collection is needed because there was none: `_providers`
|
|
263
|
+
* holds only the socket that currently owns a slot (so it misses a
|
|
264
|
+
* multiplex socket that has not announced a name yet), and
|
|
265
|
+
* `_providerPrincipals` only has entries when provider auth is configured.
|
|
266
|
+
*/
|
|
267
|
+
private readonly _providerSockets = new Set<WebSocket>();
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Liveness of each provider socket: `true` when it answered the last ping,
|
|
271
|
+
* `false` while a ping is outstanding.
|
|
272
|
+
*
|
|
273
|
+
* A `WeakMap` so a terminated socket needs no cleanup here, and so a socket
|
|
274
|
+
* the heartbeat never saw (heartbeat disabled) reads back `undefined`,
|
|
275
|
+
* which the admission check treats as "no evidence either way" and refuses
|
|
276
|
+
* the takeover.
|
|
277
|
+
*/
|
|
278
|
+
private readonly _alive = new WeakMap<WebSocket, boolean>();
|
|
279
|
+
|
|
280
|
+
/** Heartbeat sweep timer; `null` while stopped or disabled. */
|
|
281
|
+
private _heartbeatTimer: NodeJS.Timeout | null = null;
|
|
282
|
+
|
|
283
|
+
/** Pending-request deadline sweep timer; `null` while stopped or disabled. */
|
|
284
|
+
private _requestTimeoutTimer: NodeJS.Timeout | null = null;
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Counter behind every broker-assigned request id. Monotonic per process,
|
|
288
|
+
* which is all the uniqueness the pending maps need.
|
|
289
|
+
*/
|
|
290
|
+
private _nextRequestId = 1;
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Slots already warned about for answering with an id the broker never
|
|
294
|
+
* issued, so a provider that does this on every frame warns once.
|
|
295
|
+
*/
|
|
296
|
+
private readonly _unmatchedIdWarnedProviders = new Set<string>();
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Slots already warned about for emitting a frame that is not JSON-RPC.
|
|
300
|
+
* A provider that speaks a non-JSON dialect emits one on every message, so
|
|
301
|
+
* the warning fires once per slot instead of flooding the log.
|
|
302
|
+
*/
|
|
303
|
+
private readonly _nonJsonWarnedProviders = new Set<string>();
|
|
304
|
+
|
|
160
305
|
/** Upstream providers (stdio child processes and remote URL servers), keyed by name. */
|
|
161
306
|
private readonly _upstreams = new Map<string, IUpstream>();
|
|
162
307
|
|
|
@@ -265,6 +410,29 @@ export class WsTunnel implements IBrokerContext {
|
|
|
265
410
|
};
|
|
266
411
|
}
|
|
267
412
|
|
|
413
|
+
/**
|
|
414
|
+
* What the broker enforces on its listening surface right now, for
|
|
415
|
+
* `broker_diagnose`.
|
|
416
|
+
*
|
|
417
|
+
* The rule it feeds is the one that catches a page the broker serves itself
|
|
418
|
+
* being refused by its own origin check: static files mounted, no browser
|
|
419
|
+
* origin allowed. That combination now costs more than it did, because the
|
|
420
|
+
* check reaches the legacy SSE endpoints too.
|
|
421
|
+
*/
|
|
422
|
+
public getSecurityInfo(): IBrokerSecurityInfo {
|
|
423
|
+
return {
|
|
424
|
+
allowedOriginsConfigured: this._options.allowedOrigins !== undefined,
|
|
425
|
+
clientAuthEnabled: this._authGuard !== null,
|
|
426
|
+
providerAuthEnabled: this._providerAuth !== null,
|
|
427
|
+
staticMountPrefixes: (this._options.staticMounts ?? []).map((m) => m.urlPrefix),
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Slot the stdio bridge is pinned to, or `null` when there is no bridge. */
|
|
432
|
+
public getStdioBridgeTarget(): string | null {
|
|
433
|
+
return this._options.stdioClient?.providerName ?? null;
|
|
434
|
+
}
|
|
435
|
+
|
|
268
436
|
public getProvidersInfo(): IBrokerProviderInfo[] {
|
|
269
437
|
const out: IBrokerProviderInfo[] = [];
|
|
270
438
|
for (const [name, state] of this._providers) {
|
|
@@ -354,32 +522,18 @@ export class WsTunnel implements IBrokerContext {
|
|
|
354
522
|
onClose: null,
|
|
355
523
|
send: (message: string): void => {
|
|
356
524
|
if (closed) return;
|
|
357
|
-
let id: string | number | null = null;
|
|
358
|
-
try {
|
|
359
|
-
const parsed = JSON.parse(message) as { id?: string | number };
|
|
360
|
-
if (parsed?.id != null) id = parsed.id;
|
|
361
|
-
} catch {
|
|
362
|
-
/* forward as-is */
|
|
363
|
-
}
|
|
364
525
|
if (this._isProviderConnected(providerName, state)) {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
client.onMessage?.(
|
|
369
|
-
JSON.stringify({
|
|
370
|
-
jsonrpc: "2.0",
|
|
371
|
-
id,
|
|
372
|
-
error: { code: -32000, message: `Provider "${providerName}" not connected` },
|
|
373
|
-
})
|
|
374
|
-
);
|
|
526
|
+
this._sendToProvider(state, providerName, this._trackRequest(state, message, { type: "internal", client }));
|
|
527
|
+
} else if (requestIdOf(message) !== undefined) {
|
|
528
|
+
client.onMessage?.(this._notConnectedPayload(providerName, message));
|
|
375
529
|
}
|
|
376
530
|
},
|
|
377
531
|
close: (): void => {
|
|
378
532
|
if (closed) return;
|
|
379
533
|
closed = true;
|
|
380
534
|
state.internalClients.delete(client);
|
|
381
|
-
for (const [
|
|
382
|
-
if (sink.type === "internal" && sink.client === client) state.pending.delete(
|
|
535
|
+
for (const [brokerId, entry] of state.pending) {
|
|
536
|
+
if (entry.sink.type === "internal" && entry.sink.client === client) state.pending.delete(brokerId);
|
|
383
537
|
}
|
|
384
538
|
},
|
|
385
539
|
};
|
|
@@ -420,10 +574,17 @@ export class WsTunnel implements IBrokerContext {
|
|
|
420
574
|
// -------------------------------------------------------------------------
|
|
421
575
|
|
|
422
576
|
/**
|
|
423
|
-
* Starts the broker. Resolves once the HTTP server is listening
|
|
577
|
+
* Starts the broker. Resolves once the HTTP server is listening, and
|
|
578
|
+
* **rejects** when the listen fails.
|
|
579
|
+
*
|
|
580
|
+
* The rejection is the point: until the port is bound, a listen failure has
|
|
581
|
+
* nowhere else to go. `ws` mirrors the HTTP server's `'error'` onto the
|
|
582
|
+
* `WebSocketServer` from a listener it installs inside its own constructor,
|
|
583
|
+
* so an unhandled one is rethrown by Node as an uncaught exception, outside
|
|
584
|
+
* any caller's `await` and outside any `catch` the caller wrote.
|
|
424
585
|
*/
|
|
425
586
|
start(): Promise<void> {
|
|
426
|
-
return new Promise((resolve) => {
|
|
587
|
+
return new Promise((resolve, reject) => {
|
|
427
588
|
const handler = (req: IncomingMessage, res: ServerResponse) => this._handleHttp(req, res);
|
|
428
589
|
this._httpServer = this._options.tls ? https.createServer({ cert: this._options.tls.cert, key: this._options.tls.key }, handler) : http.createServer(handler);
|
|
429
590
|
// Disable perMessageDeflate: payloads may be large base64-encoded blobs
|
|
@@ -431,28 +592,76 @@ export class WsTunnel implements IBrokerContext {
|
|
|
431
592
|
// CPU without reducing size, and caused multi-second stalls in practice.
|
|
432
593
|
this._wss = new WebSocketServer({ server: this._httpServer, perMessageDeflate: false, verifyClient: this._makeVerifyClient() });
|
|
433
594
|
|
|
595
|
+
// Server-level errors, of which the one that actually happens is
|
|
596
|
+
// `EADDRINUSE` on the very first bind.
|
|
597
|
+
//
|
|
598
|
+
// `settled` guards the reject: once the server is listening the
|
|
599
|
+
// promise is spoken for, and a later error (a socket reset during an
|
|
600
|
+
// upgrade, say) must be logged instead, or it lands as a rejection on
|
|
601
|
+
// an already-resolved promise, which Node reports as unhandled.
|
|
602
|
+
//
|
|
603
|
+
// The same handler goes on BOTH emitters deliberately. `ws` forwards
|
|
604
|
+
// the HTTP server's `'error'` to the `WebSocketServer` from a listener
|
|
605
|
+
// registered inside `new WebSocketServer({ server })`; being first, it
|
|
606
|
+
// rethrows an unhandled event before any listener added here to
|
|
607
|
+
// `_httpServer` could run. The `_httpServer` listener is still needed
|
|
608
|
+
// for the errors `ws` does not forward. `lastError` is what stops the
|
|
609
|
+
// doubly delivered listen failure from being reported twice.
|
|
610
|
+
let settled = false;
|
|
611
|
+
let lastError: unknown = null;
|
|
612
|
+
const onServerError = (err: Error): void => {
|
|
613
|
+
if (err === lastError) return;
|
|
614
|
+
lastError = err;
|
|
615
|
+
if (settled) {
|
|
616
|
+
console.error(`[broker] http/websocket server error: ${err.message}`);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
settled = true;
|
|
620
|
+
reject(this._listenError(err));
|
|
621
|
+
};
|
|
622
|
+
this._wss.on("error", onServerError);
|
|
623
|
+
this._httpServer.on("error", onServerError);
|
|
624
|
+
|
|
434
625
|
this._wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
|
|
435
626
|
const url = req.url ?? "/";
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
627
|
+
const route = this._classifyWsRoute(url);
|
|
628
|
+
|
|
629
|
+
switch (route.role) {
|
|
630
|
+
case "reject":
|
|
631
|
+
// Defence in depth. `verifyClient` refuses these before the
|
|
632
|
+
// handshake completes, with the full diagnosis in the HTTP
|
|
633
|
+
// body, so this branch should be unreachable: it exists so
|
|
634
|
+
// the invariant "a rejected path never reaches a handler"
|
|
635
|
+
// holds even if the hook is ever changed.
|
|
636
|
+
console.warn(`[broker] ws connect path="${url}" REFUSED after handshake: ${route.detail}`);
|
|
637
|
+
this._closeWs(ws, 1008, route.closeReason);
|
|
638
|
+
return;
|
|
639
|
+
case "multiplex-provider":
|
|
640
|
+
// Multiplexed provider: one WebSocket carries N providers via envelopes.
|
|
641
|
+
this._logWsConnection(url, "multiplex-provider", "(announced per envelope)");
|
|
642
|
+
this._onMultiplexProviderConnect(ws, req);
|
|
643
|
+
return;
|
|
644
|
+
case "dedicated-provider":
|
|
645
|
+
this._logWsConnection(url, "dedicated-provider", route.slot);
|
|
646
|
+
this._onProviderConnect(ws, route.slot, req);
|
|
647
|
+
return;
|
|
648
|
+
case "client":
|
|
649
|
+
this._logWsConnection(url, "client", route.slot);
|
|
650
|
+
this._onClientConnect(ws, route.slot, req);
|
|
651
|
+
return;
|
|
452
652
|
}
|
|
453
653
|
});
|
|
454
654
|
|
|
455
655
|
this._httpServer.listen(this._options.port, this._options.host ?? "0.0.0.0", () => {
|
|
656
|
+
// Listening: from here on `onServerError` logs instead of rejecting.
|
|
657
|
+
settled = true;
|
|
658
|
+
|
|
659
|
+
// Both sweeps run on `unref`'d timers cleared by `stop()`, so an
|
|
660
|
+
// embedder (or a test file) that starts a tunnel is never held
|
|
661
|
+
// alive by them.
|
|
662
|
+
this._startHeartbeat();
|
|
663
|
+
this._startRequestTimeoutSweep();
|
|
664
|
+
|
|
456
665
|
// Bring the aggregate `_all` slot up before any upstream connects
|
|
457
666
|
// (a Streamable HTTP upstream opens synchronously on connect()).
|
|
458
667
|
this._maybeStartAggregateServer();
|
|
@@ -516,11 +725,58 @@ export class WsTunnel implements IBrokerContext {
|
|
|
516
725
|
});
|
|
517
726
|
}
|
|
518
727
|
|
|
728
|
+
/**
|
|
729
|
+
* Turns a listen failure into an error whose message says what to do next.
|
|
730
|
+
*
|
|
731
|
+
* `EADDRINUSE` is the one that happens in practice, and the reflex it
|
|
732
|
+
* triggers is usually the wrong one: another broker gets spawned on another
|
|
733
|
+
* port. Two brokers do not share anything, a slot is held by exactly one
|
|
734
|
+
* process, so the second instance's providers are invisible to the first
|
|
735
|
+
* instance's clients. Hence the message points at attaching to the running
|
|
736
|
+
* broker first, and only then at changing the port.
|
|
737
|
+
*/
|
|
738
|
+
private _listenError(err: Error): Error {
|
|
739
|
+
const host = this._options.host ?? "0.0.0.0";
|
|
740
|
+
const port = this._options.port;
|
|
741
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
742
|
+
|
|
743
|
+
if (code === "EADDRINUSE") {
|
|
744
|
+
const scheme = this._options.tls ? "https" : "http";
|
|
745
|
+
// A wildcard bind is not a reachable authority; name one that is.
|
|
746
|
+
const reachable = host === "0.0.0.0" || host === "::" ? "localhost" : host;
|
|
747
|
+
return new Error(
|
|
748
|
+
`Cannot listen on ${host}:${port}: the address is already in use. Another broker (or another process) already holds that port. ` +
|
|
749
|
+
`If it is a broker, use it: attach clients to ${scheme}://${reachable}:${port}/<slot>/mcp and inspect it at ${scheme}://${reachable}:${port}/_broker/mcp, ` +
|
|
750
|
+
`rather than starting a second instance, which would share no provider slot with the first. ` +
|
|
751
|
+
`To run a second one anyway, give it a free port (MCP_BROKER_PORT, or the \`port\` option).`,
|
|
752
|
+
{ cause: err }
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
if (code === "EACCES") {
|
|
757
|
+
return new Error(
|
|
758
|
+
`Cannot listen on ${host}:${port}: permission denied. Ports below 1024 require elevated privileges; ` +
|
|
759
|
+
`pick a port above 1024 (MCP_BROKER_PORT, or the \`port\` option), or put a reverse proxy in front.`,
|
|
760
|
+
{ cause: err }
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (code === "EADDRNOTAVAIL") {
|
|
765
|
+
return new Error(
|
|
766
|
+
`Cannot listen on ${host}:${port}: the host address is not available on this machine. ` +
|
|
767
|
+
`Bind to "0.0.0.0" (every interface) or "127.0.0.1" (local only) via MCP_BROKER_HOST, or the \`host\` option.`,
|
|
768
|
+
{ cause: err }
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
return new Error(`Cannot listen on ${host}:${port}: ${err.message}`, { cause: err });
|
|
773
|
+
}
|
|
774
|
+
|
|
519
775
|
/**
|
|
520
776
|
* Starts the in-process MCP server that exposes the broker's own behaviors
|
|
521
|
-
* (`broker_info`, `providers_list`, `provider_status`
|
|
522
|
-
* provider slot `_broker`. No-op when
|
|
523
|
-
* is `false`.
|
|
777
|
+
* (`broker_info`, `providers_list`, `provider_status`, `broker_guide`,
|
|
778
|
+
* `broker_diagnose`) under the reserved provider slot `_broker`. No-op when
|
|
779
|
+
* {@link IWsTunnelOptions.enableBrokerProvider} is `false`.
|
|
524
780
|
*/
|
|
525
781
|
private async _maybeStartBrokerServer(): Promise<void> {
|
|
526
782
|
if (this._options.enableBrokerProvider === false) return;
|
|
@@ -559,6 +815,14 @@ export class WsTunnel implements IBrokerContext {
|
|
|
559
815
|
* Gracefully closes all connections and stops the HTTP server.
|
|
560
816
|
*/
|
|
561
817
|
async stop(): Promise<void> {
|
|
818
|
+
// First thing, before any `await`: a live interval keeps the event loop
|
|
819
|
+
// busy, and both of these fire on sockets that are about to be torn
|
|
820
|
+
// down. They are `unref`'d, so they cannot by themselves hold a process
|
|
821
|
+
// open, but a test runner that checks for leaked handles counts them.
|
|
822
|
+
this._stopHeartbeat();
|
|
823
|
+
this._stopRequestTimeoutSweep();
|
|
824
|
+
this._providerSockets.clear();
|
|
825
|
+
|
|
562
826
|
// Stop the embedded broker first so it does not see its loopback close
|
|
563
827
|
// as an unexpected disconnect (and to flush any pending broker responses).
|
|
564
828
|
const brokerServer = this._brokerServer;
|
|
@@ -610,10 +874,336 @@ export class WsTunnel implements IBrokerContext {
|
|
|
610
874
|
this._loopbackProviders.clear();
|
|
611
875
|
this._startedAt = null;
|
|
612
876
|
this._wss?.close();
|
|
613
|
-
|
|
877
|
+
|
|
878
|
+
const httpServer = this._httpServer;
|
|
879
|
+
if (!httpServer) {
|
|
880
|
+
// Never started: there is no close callback coming, and waiting
|
|
881
|
+
// for one would hang the caller's teardown.
|
|
882
|
+
resolve();
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
httpServer.close((err) => {
|
|
886
|
+
// A tunnel whose `start()` rejected (the port was already taken)
|
|
887
|
+
// has a server object that never listened, and closing that one
|
|
888
|
+
// answers `ERR_SERVER_NOT_RUNNING`. Failing the teardown on it
|
|
889
|
+
// would replace the real diagnosis with a meaningless one, in the
|
|
890
|
+
// `finally` where the caller is trying to clean up after the first.
|
|
891
|
+
if (err && (err as NodeJS.ErrnoException).code !== "ERR_SERVER_NOT_RUNNING") reject(err);
|
|
892
|
+
else resolve();
|
|
893
|
+
});
|
|
614
894
|
});
|
|
615
895
|
}
|
|
616
896
|
|
|
897
|
+
// -------------------------------------------------------------------------
|
|
898
|
+
// Provider liveness (heartbeat) and slot takeover
|
|
899
|
+
// -------------------------------------------------------------------------
|
|
900
|
+
|
|
901
|
+
/** Effective heartbeat period in ms; `0` means the heartbeat is off. */
|
|
902
|
+
private _heartbeatIntervalMs(): number {
|
|
903
|
+
const configured = this._options.providerHeartbeatIntervalMs;
|
|
904
|
+
return Math.max(0, configured ?? DEFAULT_PROVIDER_HEARTBEAT_MS);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/** Effective per-request deadline in ms; `0` means requests never expire. */
|
|
908
|
+
private _requestTimeoutMs(): number {
|
|
909
|
+
const configured = this._options.providerRequestTimeoutMs;
|
|
910
|
+
return Math.max(0, configured ?? DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Starts watching every provider socket for a missed pong.
|
|
915
|
+
*
|
|
916
|
+
* `unref()` is not cosmetic: without it this interval alone keeps the Node
|
|
917
|
+
* event loop alive, so every test file that starts a tunnel would hang on
|
|
918
|
+
* teardown, and an embedder's process would refuse to exit.
|
|
919
|
+
*/
|
|
920
|
+
private _startHeartbeat(): void {
|
|
921
|
+
const interval = this._heartbeatIntervalMs();
|
|
922
|
+
if (interval === 0) return;
|
|
923
|
+
this._heartbeatTimer = setInterval(() => this._sweepHeartbeats(), interval);
|
|
924
|
+
this._heartbeatTimer.unref?.();
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
private _stopHeartbeat(): void {
|
|
928
|
+
if (!this._heartbeatTimer) return;
|
|
929
|
+
clearInterval(this._heartbeatTimer);
|
|
930
|
+
this._heartbeatTimer = null;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* One heartbeat round: terminate whoever did not answer the previous ping,
|
|
935
|
+
* then ping everybody still standing.
|
|
936
|
+
*
|
|
937
|
+
* The two-phase shape is what gives a provider a full interval to reply,
|
|
938
|
+
* and it is why {@link _alive} means "answered since the last sweep" rather
|
|
939
|
+
* than "is connected".
|
|
940
|
+
*/
|
|
941
|
+
private _sweepHeartbeats(): void {
|
|
942
|
+
const interval = this._heartbeatIntervalMs();
|
|
943
|
+
for (const ws of [...this._providerSockets]) {
|
|
944
|
+
if (ws.readyState !== WebSocket.OPEN) {
|
|
945
|
+
this._providerSockets.delete(ws);
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
if (this._alive.get(ws) === false) {
|
|
949
|
+
const slots = this._slotsOfSocket(ws);
|
|
950
|
+
console.warn(
|
|
951
|
+
`[broker] provider socket for ${slots} did not answer a WebSocket ping within ${interval}ms; terminating it and freeing the slot. ` +
|
|
952
|
+
`The provider process, tab or network path is gone. It may reconnect immediately. ` +
|
|
953
|
+
`If this provider is alive but slow, raise providerHeartbeatIntervalMs (or set it to 0 to disable the heartbeat).`
|
|
954
|
+
);
|
|
955
|
+
this._providerSockets.delete(ws);
|
|
956
|
+
ws.terminate();
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
this._alive.set(ws, false);
|
|
960
|
+
try {
|
|
961
|
+
ws.ping();
|
|
962
|
+
} catch (err) {
|
|
963
|
+
// A socket that died between the readyState check and the ping.
|
|
964
|
+
// Nothing to diagnose: the close handler is already on its way.
|
|
965
|
+
this._providerSockets.delete(ws);
|
|
966
|
+
void err;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Puts one accepted provider socket under the heartbeat.
|
|
973
|
+
*
|
|
974
|
+
* No-op when the heartbeat is disabled, which is what keeps
|
|
975
|
+
* {@link _alive} empty and makes the admission check fall back to refusing
|
|
976
|
+
* every takeover: with no liveness evidence, evicting the incumbent would
|
|
977
|
+
* be a guess.
|
|
978
|
+
*/
|
|
979
|
+
private _watchProviderSocket(ws: WebSocket): void {
|
|
980
|
+
if (this._heartbeatIntervalMs() === 0) return;
|
|
981
|
+
this._alive.set(ws, true);
|
|
982
|
+
this._providerSockets.add(ws);
|
|
983
|
+
ws.on("pong", () => this._alive.set(ws, true));
|
|
984
|
+
ws.on("close", () => this._providerSockets.delete(ws));
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/** Human-readable list of the slots one provider socket currently serves. */
|
|
988
|
+
private _slotsOfSocket(ws: WebSocket): string {
|
|
989
|
+
const announced = this._multiplexSockets.get(ws);
|
|
990
|
+
if (announced) return announced.size > 0 ? `multiplexed slots [${[...announced].join(", ")}]` : "a multiplexed socket that announced no slot";
|
|
991
|
+
const owned = [...this._providers.entries()].filter(([, state]) => state.ws === ws).map(([name]) => name);
|
|
992
|
+
return owned.length > 0 ? `slot "${owned.join('", "')}"` : "a slot it no longer owns";
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* Decides whether a newly connected provider socket may take the slot
|
|
997
|
+
* `name`, evicting the incumbent when it is allowed to.
|
|
998
|
+
*
|
|
999
|
+
* Returns `null` when the newcomer is admitted, or the refusal otherwise:
|
|
1000
|
+
* `detail` is the full diagnosis for the log and for the envelope the
|
|
1001
|
+
* multiplexed path can carry, `closeReason` the same thing squeezed into the
|
|
1002
|
+
* 123 bytes RFC 6455 allows on a close frame, with the actionable part
|
|
1003
|
+
* first so truncation eats the slot name (which the peer already knows)
|
|
1004
|
+
* rather than the instruction.
|
|
1005
|
+
*
|
|
1006
|
+
* See {@link ProviderTakeoverMode} for what each mode means and why
|
|
1007
|
+
* `"always"` is gated on provider authentication.
|
|
1008
|
+
*/
|
|
1009
|
+
private _claimSlot(name: string, ws: WebSocket): { detail: string; closeReason: string } | null {
|
|
1010
|
+
const incumbent = this._providers.get(name)?.ws ?? null;
|
|
1011
|
+
if (!incumbent || incumbent === ws || incumbent.readyState !== WebSocket.OPEN) return null;
|
|
1012
|
+
|
|
1013
|
+
const mode: ProviderTakeoverMode = this._options.providerTakeover ?? "liveness";
|
|
1014
|
+
|
|
1015
|
+
if (mode === "always") {
|
|
1016
|
+
if (this._sameProviderPrincipal(incumbent, ws)) {
|
|
1017
|
+
console.warn(`[broker] provider "${name}": takeover mode "always", the incumbent socket is being terminated in favor of the new one from the same principal.`);
|
|
1018
|
+
incumbent.terminate();
|
|
1019
|
+
return null;
|
|
1020
|
+
}
|
|
1021
|
+
// Not a refusal on its own: fall through to the liveness rule. Saying
|
|
1022
|
+
// so out loud matters, because the operator asked for "always" and is
|
|
1023
|
+
// entitled to know why they did not get it.
|
|
1024
|
+
console.warn(
|
|
1025
|
+
`[broker] provider "${name}": takeover mode "always" is not honored here and the broker fell back to "liveness". ` +
|
|
1026
|
+
`Unconditional takeover is only safe when providerAuth is configured AND the new socket authenticated as the same principal as the incumbent; ` +
|
|
1027
|
+
`otherwise anyone able to reach the provider URL could evict the real provider at will. Configure providerAuth (withProviderSecret) to enable it.`
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
if (mode !== "reject" && this._alive.get(incumbent) === false) {
|
|
1032
|
+
console.warn(`[broker] provider "${name}": the incumbent socket missed its last heartbeat, so it is terminated and the new connection takes the slot.`);
|
|
1033
|
+
incumbent.terminate();
|
|
1034
|
+
return null;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
const heartbeat = this._heartbeatIntervalMs();
|
|
1038
|
+
const why =
|
|
1039
|
+
mode === "reject"
|
|
1040
|
+
? `providerTakeover is "reject", so a live slot is never handed over.`
|
|
1041
|
+
: heartbeat === 0
|
|
1042
|
+
? `The heartbeat is disabled (providerHeartbeatIntervalMs: 0), so the broker has no evidence the incumbent is dead and will not evict it.`
|
|
1043
|
+
: `The incumbent answered the last heartbeat, so it is treated as alive.`;
|
|
1044
|
+
const recovery =
|
|
1045
|
+
heartbeat === 0
|
|
1046
|
+
? `Enable the heartbeat (providerHeartbeatIntervalMs) so a dead socket frees its slot on its own`
|
|
1047
|
+
: `wait up to ${heartbeat}ms for the heartbeat to notice a dead socket and reconnect`;
|
|
1048
|
+
return {
|
|
1049
|
+
detail:
|
|
1050
|
+
`Provider "${name}" is already connected. ${why} ` +
|
|
1051
|
+
`If the previous instance really is gone, ${recovery}, close the old socket cleanly from the provider side, or publish on a different slot name. ` +
|
|
1052
|
+
`Call provider_status on the _broker slot to see which socket holds it.`,
|
|
1053
|
+
closeReason: `Slot already held by a live provider; call provider_status on _broker. Slot: "${name}"`,
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* `true` when two provider sockets authenticated as the same principal.
|
|
1059
|
+
*
|
|
1060
|
+
* Deliberately `false` when provider auth is off: with no authenticator
|
|
1061
|
+
* there are no principals to compare, and treating "both anonymous" as
|
|
1062
|
+
* "the same provider" is exactly the spoofing primitive the gate exists to
|
|
1063
|
+
* prevent.
|
|
1064
|
+
*/
|
|
1065
|
+
private _sameProviderPrincipal(a: WebSocket, b: WebSocket): boolean {
|
|
1066
|
+
if (!this._providerAuth) return false;
|
|
1067
|
+
const left = this._providerPrincipals.get(a);
|
|
1068
|
+
const right = this._providerPrincipals.get(b);
|
|
1069
|
+
return left !== undefined && right !== undefined && left.id === right.id;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/** Closes a socket with a reason RFC 6455 actually allows on the wire. */
|
|
1073
|
+
private _closeWs(ws: WebSocket, code: number, reason: string): void {
|
|
1074
|
+
ws.close(code, truncateReason(reason));
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// -------------------------------------------------------------------------
|
|
1078
|
+
// Pending requests: correlation and deadlines
|
|
1079
|
+
// -------------------------------------------------------------------------
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Registers one outbound request against a slot and returns the frame to
|
|
1083
|
+
* put on the wire, with the client's JSON-RPC id replaced by a
|
|
1084
|
+
* broker-assigned one.
|
|
1085
|
+
*
|
|
1086
|
+
* The rewrite is the fix for a cross-client response leak. `state.pending`
|
|
1087
|
+
* is one map per **slot**, written from five different ingresses (raw WS,
|
|
1088
|
+
* legacy SSE, Streamable HTTP, the stdio bridge, in-process clients), and
|
|
1089
|
+
* it used to be keyed by the id the client chose. Two clients on one slot
|
|
1090
|
+
* that both start numbering at 1 (MCP Inspector plus Claude, the pairing
|
|
1091
|
+
* the architecture doc explicitly advertises) therefore shared one entry:
|
|
1092
|
+
* the second write replaced the first, one client received the other's
|
|
1093
|
+
* result, and the overwritten request hung forever with no error. Rewriting
|
|
1094
|
+
* also stops the provider from seeing two concurrent requests with the same
|
|
1095
|
+
* id, which it has no way to answer correctly either.
|
|
1096
|
+
*
|
|
1097
|
+
* Invisible to conforming clients: {@link _routeFromProvider} restores the
|
|
1098
|
+
* original id before the answer is delivered.
|
|
1099
|
+
*
|
|
1100
|
+
* Frames with no usable id (notifications) and JSON-RPC batches (an array,
|
|
1101
|
+
* which has no top-level id) are returned untouched and untracked, exactly
|
|
1102
|
+
* as before.
|
|
1103
|
+
*/
|
|
1104
|
+
private _trackRequest(state: IProviderState, frame: string, sink: ResponseSink): string {
|
|
1105
|
+
const message = parseObjectFrame(frame);
|
|
1106
|
+
if (!message) return frame;
|
|
1107
|
+
const clientId = message.id;
|
|
1108
|
+
if (typeof clientId !== "string" && typeof clientId !== "number") return frame;
|
|
1109
|
+
|
|
1110
|
+
const brokerId = `${BROKER_REQUEST_ID_PREFIX}${this._nextRequestId++}`;
|
|
1111
|
+
const timeout = this._requestTimeoutMs();
|
|
1112
|
+
state.pending.set(brokerId, { sink, clientId, expiresAt: timeout > 0 ? Date.now() + timeout : 0 });
|
|
1113
|
+
message.id = brokerId;
|
|
1114
|
+
return JSON.stringify(message);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/** Delivers one already-addressed frame to the sink that is waiting for it. */
|
|
1118
|
+
private _deliverToSink(state: IProviderState, sink: ResponseSink, data: string): void {
|
|
1119
|
+
switch (sink.type) {
|
|
1120
|
+
case "ws":
|
|
1121
|
+
if (sink.socket.readyState === WebSocket.OPEN) sink.socket.send(data);
|
|
1122
|
+
return;
|
|
1123
|
+
case "sse": {
|
|
1124
|
+
const sseRes = state.sseSessions.get(sink.sessionId);
|
|
1125
|
+
if (sseRes) this._sendSseEvent(sseRes, data);
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
case "http-session":
|
|
1129
|
+
// The session transport decides whether this answers a held-open
|
|
1130
|
+
// POST or travels on the GET stream: it correlates by id.
|
|
1131
|
+
state.httpSessions.get(sink.sessionId)?.transport.send(data);
|
|
1132
|
+
return;
|
|
1133
|
+
case "stdio":
|
|
1134
|
+
this._stdioClientTransport?.send(data);
|
|
1135
|
+
return;
|
|
1136
|
+
case "internal":
|
|
1137
|
+
sink.client.onMessage?.(data);
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* Starts the sweep that fails requests a provider never answered.
|
|
1144
|
+
*
|
|
1145
|
+
* Same `unref()` requirement as the heartbeat. The period is derived from
|
|
1146
|
+
* the deadline rather than fixed, so a short timeout (a test, or a
|
|
1147
|
+
* latency-sensitive deployment) is still honored roughly on time instead of
|
|
1148
|
+
* being rounded up to the next sweep minutes later.
|
|
1149
|
+
*/
|
|
1150
|
+
private _startRequestTimeoutSweep(): void {
|
|
1151
|
+
const timeout = this._requestTimeoutMs();
|
|
1152
|
+
if (timeout === 0) return;
|
|
1153
|
+
const period = Math.max(250, Math.min(5_000, Math.floor(timeout / 2)));
|
|
1154
|
+
this._requestTimeoutTimer = setInterval(() => this._sweepPendingTimeouts(), period);
|
|
1155
|
+
this._requestTimeoutTimer.unref?.();
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
private _stopRequestTimeoutSweep(): void {
|
|
1159
|
+
if (!this._requestTimeoutTimer) return;
|
|
1160
|
+
clearInterval(this._requestTimeoutTimer);
|
|
1161
|
+
this._requestTimeoutTimer = null;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* Fails every request whose deadline has passed.
|
|
1166
|
+
*
|
|
1167
|
+
* Until this existed a pending entry was released only by a matching
|
|
1168
|
+
* response, a provider disconnect, or the client's own close, so a provider
|
|
1169
|
+
* that stayed connected and simply never answered (a browser tab throttled
|
|
1170
|
+
* in the background is the ordinary case) left the caller waiting with
|
|
1171
|
+
* nothing to release it and no trace anywhere. A named error is strictly
|
|
1172
|
+
* better than a hang.
|
|
1173
|
+
*/
|
|
1174
|
+
private _sweepPendingTimeouts(): void {
|
|
1175
|
+
const timeout = this._requestTimeoutMs();
|
|
1176
|
+
if (timeout === 0) return;
|
|
1177
|
+
const now = Date.now();
|
|
1178
|
+
|
|
1179
|
+
for (const [name, state] of this._providers) {
|
|
1180
|
+
for (const [brokerId, entry] of state.pending) {
|
|
1181
|
+
if (entry.expiresAt === 0 || entry.expiresAt > now) continue;
|
|
1182
|
+
state.pending.delete(brokerId);
|
|
1183
|
+
console.warn(
|
|
1184
|
+
`[broker] provider "${name}" did not answer a request within ${timeout}ms; the caller was sent a timeout error. ` +
|
|
1185
|
+
`The socket is still connected, so this is the provider not replying rather than a disconnect: check that its MCP message handler was installed BEFORE the transport connected, ` +
|
|
1186
|
+
`that it echoes the JSON-RPC id it was given, and that the page hosting it is not throttled in a background tab. ` +
|
|
1187
|
+
`Raise providerRequestTimeoutMs for genuinely long-running tools.`
|
|
1188
|
+
);
|
|
1189
|
+
this._deliverToSink(
|
|
1190
|
+
state,
|
|
1191
|
+
entry.sink,
|
|
1192
|
+
JSON.stringify({
|
|
1193
|
+
jsonrpc: "2.0",
|
|
1194
|
+
id: entry.clientId,
|
|
1195
|
+
error: {
|
|
1196
|
+
code: -32000,
|
|
1197
|
+
message:
|
|
1198
|
+
`Provider "${name}" did not respond within ${timeout}ms. The provider socket is still connected but sent no answer for this request. ` +
|
|
1199
|
+
`Call broker_diagnose on the _broker slot for the live state of this slot.`,
|
|
1200
|
+
},
|
|
1201
|
+
})
|
|
1202
|
+
);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
617
1207
|
private _authorizationSubject(principal: IPrincipal | null): IAuthorizationSubject {
|
|
618
1208
|
if (principal?.subject) return principal.subject;
|
|
619
1209
|
if (!principal) return { ids: [] };
|
|
@@ -625,9 +1215,18 @@ export class WsTunnel implements IBrokerContext {
|
|
|
625
1215
|
}
|
|
626
1216
|
);
|
|
627
1217
|
} catch (error) {
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
1218
|
+
// Both branches are logged, deliberately. A `SubjectMappingError` used
|
|
1219
|
+
// to be swallowed here, which made the single most likely
|
|
1220
|
+
// misconfiguration the one case that produced no output at all, while
|
|
1221
|
+
// the empty subject returned below makes the policy engine deny every
|
|
1222
|
+
// request from this caller. It is a configuration fault, not attacker
|
|
1223
|
+
// noise, and the HTTP path already diagnoses it out loud (a 403 saying
|
|
1224
|
+
// "Malformed configured JWT subject claim", see `auth/http.auth.ts`).
|
|
1225
|
+
const kind = error instanceof SubjectMappingError ? "malformed configured JWT subject claim" : "subject mapping failed";
|
|
1226
|
+
console.error(
|
|
1227
|
+
`[broker] authorization ${kind}: ${(error as Error).message}` +
|
|
1228
|
+
` -- every request from this caller is denied while this stands. Check authorization.subjectMapping against the claims the token actually carries.`
|
|
1229
|
+
);
|
|
631
1230
|
return { ids: [], claims: principal.claims };
|
|
632
1231
|
}
|
|
633
1232
|
}
|
|
@@ -683,9 +1282,14 @@ export class WsTunnel implements IBrokerContext {
|
|
|
683
1282
|
for (const value of operations) {
|
|
684
1283
|
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
|
|
685
1284
|
const operation = value as Readonly<{ method?: string; params?: unknown }>;
|
|
1285
|
+
// Hoisted out of the `try` so the failure path can still name the
|
|
1286
|
+
// capability when the throw came from the engine rather than from the
|
|
1287
|
+
// classifier.
|
|
1288
|
+
let capability: string | undefined;
|
|
686
1289
|
try {
|
|
687
1290
|
const classified = authorization.capabilityClassifier.classify(operation, resource, providerName);
|
|
688
1291
|
if (!classified) continue;
|
|
1292
|
+
capability = classified.capability;
|
|
689
1293
|
const decision = authorization.engine.authorize({
|
|
690
1294
|
subject,
|
|
691
1295
|
capability: classified.capability,
|
|
@@ -695,10 +1299,22 @@ export class WsTunnel implements IBrokerContext {
|
|
|
695
1299
|
});
|
|
696
1300
|
this._auditDecision(subject, providerName, resource, classified.capability, classified.tool, decision);
|
|
697
1301
|
if (!decision.allowed) return false;
|
|
698
|
-
} catch {
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
1302
|
+
} catch (error) {
|
|
1303
|
+
// The evaluation itself threw: a classifier or engine fault, not a
|
|
1304
|
+
// policy miss. The two call for opposite fixes, and the audit event
|
|
1305
|
+
// used to claim "no-matching-grant", which sends the operator off
|
|
1306
|
+
// to write grants that can never help. So say what actually
|
|
1307
|
+
// happened, with everything needed to reproduce it.
|
|
1308
|
+
console.error(
|
|
1309
|
+
`[broker] policy evaluation error on slot "${providerName}": ${(error as Error).message}` +
|
|
1310
|
+
` -- subject=[${subject.ids.join(", ") || "(none)"}] resource="${resource.value}" capability="${capability ?? "(unclassified)"}" method="${operation.method ?? "(none)"}".` +
|
|
1311
|
+
` The request is denied. This is a fault in the authorization configuration or in the frame, not a missing grant.`
|
|
1312
|
+
);
|
|
1313
|
+
// "evaluation-error", not "no-matching-grant": nothing was
|
|
1314
|
+
// decided here, the request is denied because that is the safe
|
|
1315
|
+
// answer to a fault.
|
|
1316
|
+
const decision: IAuthorizationDecision = { allowed: false, reason: "evaluation-error" };
|
|
1317
|
+
this._auditDecision(subject, providerName, resource, capability, undefined, decision);
|
|
702
1318
|
return false;
|
|
703
1319
|
}
|
|
704
1320
|
}
|
|
@@ -775,6 +1391,10 @@ export class WsTunnel implements IBrokerContext {
|
|
|
775
1391
|
const { providerName, endpoint } = route;
|
|
776
1392
|
const kind = this._mcpEndpointKind(endpoint, method);
|
|
777
1393
|
if (kind) {
|
|
1394
|
+
// The `mcp` kind is left out on purpose: `StreamableHttpEndpoint`
|
|
1395
|
+
// already runs the identical check, with the same predicate.
|
|
1396
|
+
if ((kind === "sse-connect" || kind === "sse-message") && !this._sseOriginAllowed(req, res, providerName, kind)) return;
|
|
1397
|
+
|
|
778
1398
|
if (this._authGuard) {
|
|
779
1399
|
// Gate on a valid bearer token issued for this slot before touching
|
|
780
1400
|
// the provider. On failure, emit an RFC 9728 challenge / 500.
|
|
@@ -799,6 +1419,47 @@ export class WsTunnel implements IBrokerContext {
|
|
|
799
1419
|
}
|
|
800
1420
|
}
|
|
801
1421
|
|
|
1422
|
+
/**
|
|
1423
|
+
* Applies the browser-origin check to the legacy SSE pair, answering `403`
|
|
1424
|
+
* and returning `false` when the origin is refused.
|
|
1425
|
+
*
|
|
1426
|
+
* This closes a hole, and it is the one behavior change in this release a
|
|
1427
|
+
* browser page can notice. `allowedOrigins` reached exactly one place, the
|
|
1428
|
+
* Streamable HTTP endpoint, so `/<slot>/sse` and `/<slot>/messages` were
|
|
1429
|
+
* open to every page on the machine: `Access-Control-Allow-Origin: *` is
|
|
1430
|
+
* set unconditionally a few lines above, and a POST of JSON to
|
|
1431
|
+
* `/<slot>/messages` is a CORS *simple* request, so any page could open an
|
|
1432
|
+
* `EventSource`, read the session id off the `endpoint` event, and drive the
|
|
1433
|
+
* broker. That is verbatim the attack the origin check exists to stop.
|
|
1434
|
+
*
|
|
1435
|
+
* The contract of the check is preserved exactly: a request carrying **no**
|
|
1436
|
+
* `Origin` always passes, which is every non-browser client (Claude Desktop,
|
|
1437
|
+
* Inspector, the server-side SDKs), and an `Origin` passes only if
|
|
1438
|
+
* `allowedOrigins` names it. The same predicate object is used as for
|
|
1439
|
+
* `/<slot>/mcp`, so the two endpoints cannot drift apart.
|
|
1440
|
+
*/
|
|
1441
|
+
private _sseOriginAllowed(req: IncomingMessage, res: ServerResponse, providerName: string, kind: McpEndpointKind): boolean {
|
|
1442
|
+
const origin = req.headers.origin;
|
|
1443
|
+
// A request with no Origin cannot come from a browser, so it is not this
|
|
1444
|
+
// check's business and passes straight through.
|
|
1445
|
+
if (typeof origin !== "string" || origin.length === 0) return true;
|
|
1446
|
+
if (this._allowedOrigins(origin)) return true;
|
|
1447
|
+
|
|
1448
|
+
const endpoint = kind === "sse-connect" ? `/${providerName}${this._options.ssePath ?? "/sse"}` : `/${providerName}${this._options.messagesPath ?? "/messages"}`;
|
|
1449
|
+
const configured = this._options.allowedOrigins !== undefined;
|
|
1450
|
+
const description = configured
|
|
1451
|
+
? `Origin "${origin}" is not allowed to reach "${endpoint}". It is compared verbatim against the configured allowedOrigins, so a different scheme, port or a trailing slash does not match. ` +
|
|
1452
|
+
`Add the exact origin the browser sends (MCP_BROKER_ALLOWED_ORIGINS, the \`allowedOrigins\` config key, or WsTunnelBuilder.withAllowedOrigins).`
|
|
1453
|
+
: `Origin "${origin}" is refused because no browser origin is allowed: \`allowedOrigins\` is unset, and the default is closed. ` +
|
|
1454
|
+
`A page served by this broker is not exempt, its origin has to be listed too. ` +
|
|
1455
|
+
`Set MCP_BROKER_ALLOWED_ORIGINS (the \`allowedOrigins\` config key, or WsTunnelBuilder.withAllowedOrigins) to "${origin}" to open it, and only then.`;
|
|
1456
|
+
|
|
1457
|
+
console.warn(`[broker] refused SSE request on "${endpoint}": ${description}`);
|
|
1458
|
+
res.writeHead(403, { "Content-Type": "application/json; charset=utf-8" });
|
|
1459
|
+
res.end(JSON.stringify({ error: "invalid_origin", error_description: description }));
|
|
1460
|
+
return false;
|
|
1461
|
+
}
|
|
1462
|
+
|
|
802
1463
|
/**
|
|
803
1464
|
* Parses `/<providerName>/<endpoint>` from a URL path.
|
|
804
1465
|
* Returns `null` if the URL does not match this two-segment pattern.
|
|
@@ -857,38 +1518,117 @@ export class WsTunnel implements IBrokerContext {
|
|
|
857
1518
|
}
|
|
858
1519
|
|
|
859
1520
|
/**
|
|
860
|
-
*
|
|
861
|
-
* upgrade before the connection is accepted, returning a real `401`/`403`
|
|
862
|
-
* during the handshake rather than a post-handshake close:
|
|
1521
|
+
* Decides, from the URL alone, what one WebSocket upgrade is asking for.
|
|
863
1522
|
*
|
|
1523
|
+
* One function rather than the two `startsWith` chains this used to be, one
|
|
1524
|
+
* in the connection handler and one in `verifyClient`. They agreed by
|
|
1525
|
+
* coincidence, and a disagreement would mean a socket authenticated as one
|
|
1526
|
+
* role and then served as another.
|
|
1527
|
+
*
|
|
1528
|
+
* The three refusals are paths that cannot work whatever is behind them:
|
|
1529
|
+
* a provider URL with no slot name (which used to mint a slot literally
|
|
1530
|
+
* named `(unnamed)` that nothing could ever address) and a slot-scoped
|
|
1531
|
+
* provider URL with more than one segment (`/provider/a/b`, which aliases
|
|
1532
|
+
* the `%2F`-encoded spelling of the same slot, so refusing it removes
|
|
1533
|
+
* nothing and forces one spelling).
|
|
1534
|
+
*
|
|
1535
|
+
* What is deliberately **not** refused: a slot name that matches nothing
|
|
1536
|
+
* configured. Claiming a free slot by connecting to it is how a provider
|
|
1537
|
+
* registers, so an unknown name is the normal case, not an error.
|
|
1538
|
+
*/
|
|
1539
|
+
private _classifyWsRoute(url: string): WsRouteClassification {
|
|
1540
|
+
const providerPath = this._options.providerPath ?? "/provider";
|
|
1541
|
+
const providersPath = this._options.providersPath ?? "/providers";
|
|
1542
|
+
|
|
1543
|
+
// Exact match plus query string: the multiplex endpoint takes its slot
|
|
1544
|
+
// names from the envelopes, never from the path.
|
|
1545
|
+
if (url === providersPath || url.startsWith(providersPath + "?")) return { role: "multiplex-provider" };
|
|
1546
|
+
|
|
1547
|
+
// `${providerPath}?x=1` is deliberately absent from this test: it has
|
|
1548
|
+
// always been served as a client on a slot named after the path, and
|
|
1549
|
+
// narrowing that is not this change's business.
|
|
1550
|
+
if (url === providerPath) {
|
|
1551
|
+
return {
|
|
1552
|
+
role: "reject",
|
|
1553
|
+
detail:
|
|
1554
|
+
`"${providerPath}" is the provider path PREFIX, not an endpoint. A slot-scoped provider appends its slot name: "${providerPath}/<name>". ` +
|
|
1555
|
+
`If you meant the shared multiplexed socket, that is "${providersPath}" (exact path, one socket for many slots, envelope framing). ` +
|
|
1556
|
+
`Connecting here used to occupy a slot literally named "(unnamed)", which no client could address.`,
|
|
1557
|
+
closeReason: `Missing slot name: connect to ${providerPath}/<name>, or to ${providersPath}`,
|
|
1558
|
+
};
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
if (url.startsWith(providerPath + "/")) {
|
|
1562
|
+
const raw = url.slice(providerPath.length + 1).split("?")[0];
|
|
1563
|
+
if (raw.length === 0) {
|
|
1564
|
+
return {
|
|
1565
|
+
role: "reject",
|
|
1566
|
+
detail: `"${url}" has an empty slot name. Append the slot the provider publishes: "${providerPath}/<name>".`,
|
|
1567
|
+
closeReason: `Empty slot name: connect to ${providerPath}/<name>`,
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
// Tested on the RAW segment, before decoding: hierarchical slot names
|
|
1571
|
+
// are legitimate and travel percent-encoded (`%2Fsite-a%2Fline-3`),
|
|
1572
|
+
// so decoding first would refuse exactly the shape the authorization
|
|
1573
|
+
// layer is built around.
|
|
1574
|
+
if (raw.includes("/")) {
|
|
1575
|
+
return {
|
|
1576
|
+
role: "reject",
|
|
1577
|
+
detail:
|
|
1578
|
+
`"${url}" has more than one path segment after "${providerPath}/". A slot-scoped provider URL carries exactly one segment. ` +
|
|
1579
|
+
`A slot name containing "/" must be percent-encoded: "${providerPath}/${encodeURIComponent(safeDecode(raw))}". ` +
|
|
1580
|
+
`Both spellings resolve to the same slot, so only the encoded one is accepted.`,
|
|
1581
|
+
closeReason: `Too many path segments: percent-encode the slot name after ${providerPath}/`,
|
|
1582
|
+
};
|
|
1583
|
+
}
|
|
1584
|
+
return { role: "dedicated-provider", slot: safeDecode(raw) };
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
// Raw WS MCP client: URL is "/<slot>" or "/".
|
|
1588
|
+
return { role: "client", slot: safeDecode(url.replace(/^\//, "").split("?")[0]) };
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
/**
|
|
1592
|
+
* Builds the `ws` verifyClient hook, which decides every WebSocket upgrade
|
|
1593
|
+
* before the connection is accepted, returning a real HTTP status during
|
|
1594
|
+
* the handshake rather than a post-handshake close:
|
|
1595
|
+
*
|
|
1596
|
+
* - Paths that cannot work are refused with `400` and the full reason as the
|
|
1597
|
+
* response body (see {@link _classifyWsRoute}).
|
|
864
1598
|
* - **Raw MCP clients** (`/<slot>`) are gated by the OAuth 2.1 resource
|
|
865
1599
|
* server ({@link _authGuard}) with the RFC 9728 `WWW-Authenticate` challenge.
|
|
866
1600
|
* - **Providers** (`/provider/<slot>`, `/providers`) are gated by the
|
|
867
1601
|
* {@link _providerAuth} shared-secret / custom authenticator.
|
|
868
1602
|
*
|
|
869
|
-
* Each side is independent: a branch with no authenticator
|
|
870
|
-
* through unchanged.
|
|
1603
|
+
* Each authentication side is independent: a branch with no authenticator
|
|
1604
|
+
* configured is let through unchanged. The hook is always installed now,
|
|
1605
|
+
* because the path check applies whether or not anything is authenticated.
|
|
871
1606
|
*/
|
|
872
|
-
private _makeVerifyClient(): VerifyClientCallbackAsync
|
|
1607
|
+
private _makeVerifyClient(): VerifyClientCallbackAsync {
|
|
873
1608
|
const guard = this._authGuard;
|
|
874
1609
|
const providerAuth = this._providerAuth;
|
|
875
|
-
if (!guard && !providerAuth) return undefined;
|
|
876
|
-
|
|
877
|
-
const providerPath = this._options.providerPath ?? "/provider";
|
|
878
|
-
const providersPath = this._options.providersPath ?? "/providers";
|
|
879
1610
|
|
|
880
1611
|
return (info, cb) => {
|
|
881
1612
|
const url = info.req.url ?? "/";
|
|
882
|
-
const
|
|
883
|
-
|
|
1613
|
+
const route = this._classifyWsRoute(url);
|
|
1614
|
+
|
|
1615
|
+
if (route.role === "reject") {
|
|
1616
|
+
// `ws` writes this string as the body of the refused upgrade, so
|
|
1617
|
+
// a Node client reads it off `unexpected-response`. A browser
|
|
1618
|
+
// gets no status from script, which is why the connect log line
|
|
1619
|
+
// carries the same diagnosis on the server side.
|
|
1620
|
+
console.warn(`[broker] ws upgrade REFUSED path="${url}": ${route.detail}`);
|
|
1621
|
+
cb(false, 400, route.detail);
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
884
1624
|
|
|
885
|
-
if (
|
|
1625
|
+
if (route.role === "multiplex-provider" || route.role === "dedicated-provider") {
|
|
886
1626
|
// Provider (engine) upgrade, shared-secret / custom authenticator.
|
|
887
1627
|
if (!providerAuth) {
|
|
888
1628
|
cb(true);
|
|
889
1629
|
return;
|
|
890
1630
|
}
|
|
891
|
-
const slot =
|
|
1631
|
+
const slot = route.role === "dedicated-provider" ? route.slot : undefined;
|
|
892
1632
|
Promise.resolve(providerAuth.authenticate(info.req, slot)).then(
|
|
893
1633
|
(rawResult) => {
|
|
894
1634
|
const result = normalizeProviderAuthentication(rawResult);
|
|
@@ -965,8 +1705,8 @@ export class WsTunnel implements IBrokerContext {
|
|
|
965
1705
|
|
|
966
1706
|
req.on("close", () => {
|
|
967
1707
|
state.sseSessions.delete(sessionId);
|
|
968
|
-
for (const [
|
|
969
|
-
if (sink.type === "sse" && sink.sessionId === sessionId) state.pending.delete(
|
|
1708
|
+
for (const [brokerId, entry] of state.pending) {
|
|
1709
|
+
if (entry.sink.type === "sse" && entry.sink.sessionId === sessionId) state.pending.delete(brokerId);
|
|
970
1710
|
}
|
|
971
1711
|
});
|
|
972
1712
|
}
|
|
@@ -997,33 +1737,15 @@ export class WsTunnel implements IBrokerContext {
|
|
|
997
1737
|
res.end(this._policyDeniedPayload(body));
|
|
998
1738
|
return;
|
|
999
1739
|
}
|
|
1000
|
-
try {
|
|
1001
|
-
const msg = JSON.parse(body) as { id?: string | number };
|
|
1002
|
-
if (msg.id != null) state.pending.set(msg.id, { type: "sse", sessionId });
|
|
1003
|
-
} catch {
|
|
1004
|
-
/* malformed, forward anyway */
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
1740
|
if (this._isProviderConnected(providerName, state)) {
|
|
1008
|
-
|
|
1741
|
+
// Tracked only once the frame is actually on its way out: an
|
|
1742
|
+
// entry added before the connectivity check has nothing to
|
|
1743
|
+
// answer it and would pin that id until the slot next
|
|
1744
|
+
// disconnects (the raw-WS path had the same ordering bug).
|
|
1745
|
+
this._sendToProvider(state, providerName, this._trackRequest(state, body, { type: "sse", sessionId }), principal);
|
|
1009
1746
|
} else {
|
|
1010
1747
|
const sseRes = state.sseSessions.get(sessionId);
|
|
1011
|
-
if (sseRes)
|
|
1012
|
-
let errId: string | number | null = null;
|
|
1013
|
-
try {
|
|
1014
|
-
errId = (JSON.parse(body) as { id?: string | number }).id ?? null;
|
|
1015
|
-
} catch {
|
|
1016
|
-
/* */
|
|
1017
|
-
}
|
|
1018
|
-
this._sendSseEvent(
|
|
1019
|
-
sseRes,
|
|
1020
|
-
JSON.stringify({
|
|
1021
|
-
jsonrpc: "2.0",
|
|
1022
|
-
id: errId,
|
|
1023
|
-
error: { code: -32000, message: `Provider "${providerName}" not connected` },
|
|
1024
|
-
})
|
|
1025
|
-
);
|
|
1026
|
-
}
|
|
1748
|
+
if (sseRes) this._sendSseEvent(sseRes, this._notConnectedPayload(providerName, body));
|
|
1027
1749
|
}
|
|
1028
1750
|
|
|
1029
1751
|
res.writeHead(202);
|
|
@@ -1088,8 +1810,8 @@ export class WsTunnel implements IBrokerContext {
|
|
|
1088
1810
|
state.httpSessions.delete(sessionId);
|
|
1089
1811
|
// Nothing can answer the requests this session had in
|
|
1090
1812
|
// flight; leaving them would pin the ids forever.
|
|
1091
|
-
for (const [
|
|
1092
|
-
if (sink.type === "http-session" && sink.sessionId === sessionId) state.pending.delete(
|
|
1813
|
+
for (const [brokerId, entry] of state.pending) {
|
|
1814
|
+
if (entry.sink.type === "http-session" && entry.sink.sessionId === sessionId) state.pending.delete(brokerId);
|
|
1093
1815
|
}
|
|
1094
1816
|
},
|
|
1095
1817
|
};
|
|
@@ -1111,10 +1833,7 @@ export class WsTunnel implements IBrokerContext {
|
|
|
1111
1833
|
return;
|
|
1112
1834
|
}
|
|
1113
1835
|
|
|
1114
|
-
|
|
1115
|
-
if (id !== undefined) state.pending.set(id, { type: "http-session", sessionId });
|
|
1116
|
-
|
|
1117
|
-
this._sendToProvider(state, providerName, frame, session.principal);
|
|
1836
|
+
this._sendToProvider(state, providerName, this._trackRequest(state, frame, { type: "http-session", sessionId }), session.principal);
|
|
1118
1837
|
}
|
|
1119
1838
|
|
|
1120
1839
|
/** Writes one JSON-RPC message as an SSE `message` event. */
|
|
@@ -1127,6 +1846,39 @@ export class WsTunnel implements IBrokerContext {
|
|
|
1127
1846
|
// WebSocket connection handlers
|
|
1128
1847
|
// -------------------------------------------------------------------------
|
|
1129
1848
|
|
|
1849
|
+
/**
|
|
1850
|
+
* Prints one line for every accepted WebSocket upgrade: the path asked for,
|
|
1851
|
+
* the role the router gave it, and the slot it landed on.
|
|
1852
|
+
*
|
|
1853
|
+
* Without it a successful connect produces no output whatsoever, so a
|
|
1854
|
+
* mistyped provider URL looks exactly like a working one until nothing ever
|
|
1855
|
+
* answers. The last router branch accepts **any** unmatched path as a client
|
|
1856
|
+
* slot, so `/providers/foo` (neither the multiplex endpoint `/providers` nor
|
|
1857
|
+
* a dedicated `/provider/foo`) becomes a client on a slot literally named
|
|
1858
|
+
* `providers/foo`; that case gets the fix spelled out in the same line.
|
|
1859
|
+
*
|
|
1860
|
+
* `console.log` is safe here: in stdio mode `bin.ts` rebinds the console to
|
|
1861
|
+
* stderr before the tunnel starts, so this never reaches the JSON-RPC stream.
|
|
1862
|
+
*/
|
|
1863
|
+
private _logWsConnection(path: string, role: WsConnectRole, slot: string): void {
|
|
1864
|
+
let hint = "";
|
|
1865
|
+
if (role === "client") {
|
|
1866
|
+
const providerPath = this._options.providerPath ?? "/provider";
|
|
1867
|
+
const providersPath = this._options.providersPath ?? "/providers";
|
|
1868
|
+
const providerSlot = providerPath.replace(/^\//, "");
|
|
1869
|
+
const providersSlot = providersPath.replace(/^\//, "");
|
|
1870
|
+
// `/provider?x=1` matches neither provider branch (they test the bare
|
|
1871
|
+
// path and the `<path>/` prefix), and `/providers/foo` matches neither
|
|
1872
|
+
// the exact multiplex path nor the dedicated prefix.
|
|
1873
|
+
if (slot === providerSlot || slot === providersSlot || slot.startsWith(providersSlot + "/")) {
|
|
1874
|
+
hint =
|
|
1875
|
+
` -- this looks like a provider URL but matched no provider route, so it was accepted as a CLIENT slot and nothing will ever answer it. ` +
|
|
1876
|
+
`Connect a MultiplexTransport to "${providersPath}" (exact path, slots announced per envelope), or a DirectTransport to "${providerPath}/<name>".`;
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
console.log(`[broker] ws connect path="${path}" role=${role} slot="${slot}"${hint}`);
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1130
1882
|
private _logProviderRegistration(principal: IProviderPrincipal, slot: string, resource: ResourcePath | undefined, allowed: boolean): void {
|
|
1131
1883
|
const event = {
|
|
1132
1884
|
timestamp: new Date().toISOString(),
|
|
@@ -1150,24 +1902,26 @@ export class WsTunnel implements IBrokerContext {
|
|
|
1150
1902
|
console.warn(
|
|
1151
1903
|
`[broker] WARNING: WebSocket provider "${name}" rejected: a stdio upstream with the same name is already configured. ` + `Rename one of them to avoid the conflict.`
|
|
1152
1904
|
);
|
|
1153
|
-
|
|
1905
|
+
this._closeWs(ws, 1008, `Provider "${name}" is managed by a stdio upstream`);
|
|
1154
1906
|
return;
|
|
1155
1907
|
}
|
|
1156
1908
|
|
|
1157
1909
|
if (this._loopbackProviders.has(name)) {
|
|
1158
1910
|
console.warn(`[broker] WARNING: WebSocket provider "${name}" rejected: the slot is held by an in-process loopback (reserved system slot).`);
|
|
1159
|
-
|
|
1911
|
+
this._closeWs(ws, 1008, `Provider "${name}" is reserved by the broker`);
|
|
1160
1912
|
return;
|
|
1161
1913
|
}
|
|
1162
1914
|
|
|
1163
|
-
const
|
|
1164
|
-
if (
|
|
1165
|
-
|
|
1915
|
+
const refusal = this._claimSlot(name, ws);
|
|
1916
|
+
if (refusal) {
|
|
1917
|
+
console.warn(`[broker] WARNING: WebSocket provider "${name}" rejected. ${refusal.detail}`);
|
|
1918
|
+
this._closeWs(ws, 1008, refusal.closeReason);
|
|
1166
1919
|
return;
|
|
1167
1920
|
}
|
|
1168
1921
|
|
|
1169
1922
|
const state = this._getOrCreateProviderState(name);
|
|
1170
1923
|
state.ws = ws;
|
|
1924
|
+
this._watchProviderSocket(ws);
|
|
1171
1925
|
if (providerPrincipal) {
|
|
1172
1926
|
this._logProviderRegistration(providerPrincipal, name, this._slotResourceResolver.resolve(name), true);
|
|
1173
1927
|
}
|
|
@@ -1181,35 +1935,164 @@ export class WsTunnel implements IBrokerContext {
|
|
|
1181
1935
|
const text = data.toString();
|
|
1182
1936
|
if (!registrationChecked) {
|
|
1183
1937
|
registrationChecked = true;
|
|
1938
|
+
if (this._refuseEnvelopeOnSlotPath(ws, name, text)) return;
|
|
1184
1939
|
if (this._tryHandleRegistration(name, text)) return;
|
|
1185
1940
|
}
|
|
1186
1941
|
this._routeFromProvider(state, name, text);
|
|
1187
1942
|
});
|
|
1188
1943
|
|
|
1189
1944
|
ws.on("close", () => {
|
|
1945
|
+
// Only the socket that currently holds the slot may release it.
|
|
1946
|
+
//
|
|
1947
|
+
// A provider that reconnects while its predecessor is still CLOSING
|
|
1948
|
+
// is admitted (the incumbent is no longer OPEN), takes `state.ws`,
|
|
1949
|
+
// and would then have its own registration nulled out by the
|
|
1950
|
+
// predecessor's deferred close. The slot stays wedged: the socket is
|
|
1951
|
+
// open and answering, `provider_status` reports `connected: false`,
|
|
1952
|
+
// and `_failProviderDisconnected` has already evicted the slot from
|
|
1953
|
+
// `_all`, which the aggregate opt-in cannot restore because it is
|
|
1954
|
+
// checked once per socket, on the first frame. The multiplexed path
|
|
1955
|
+
// has always guarded this way.
|
|
1956
|
+
if (state.ws !== ws) return;
|
|
1190
1957
|
state.ws = null;
|
|
1191
1958
|
this._failProviderDisconnected(state, name);
|
|
1192
1959
|
});
|
|
1960
|
+
|
|
1961
|
+
// `ws` emits 'error' for receive-side protocol faults, an invalid UTF-8
|
|
1962
|
+
// text frame or a bad opcode among them, and Node rethrows an unhandled
|
|
1963
|
+
// 'error' event as an uncaught exception. Without this listener one
|
|
1964
|
+
// malformed frame from one provider takes the whole broker down and every
|
|
1965
|
+
// other provider with it. Log and let the socket close on its own: `ws`
|
|
1966
|
+
// closes it right after emitting.
|
|
1967
|
+
ws.on("error", (err: Error) => {
|
|
1968
|
+
console.error(`[broker] provider "${name}" socket error: ${err.message}. The socket is being closed; the provider should reconnect.`);
|
|
1969
|
+
});
|
|
1193
1970
|
}
|
|
1194
1971
|
|
|
1195
1972
|
/**
|
|
1196
|
-
* Inspects a provider's first WebSocket message for
|
|
1197
|
-
*
|
|
1198
|
-
*
|
|
1199
|
-
*
|
|
1973
|
+
* Inspects a provider's first WebSocket message for a registration frame,
|
|
1974
|
+
* and consumes it when that is what it is. Returns `true` when the message
|
|
1975
|
+
* was a registration, and thus must not be routed as MCP traffic.
|
|
1976
|
+
*
|
|
1977
|
+
* Two shapes are accepted, both notifications a peer that does not know them
|
|
1978
|
+
* ignores:
|
|
1979
|
+
*
|
|
1980
|
+
* - `{"jsonrpc":"2.0","method":"notifications/register","params":{"aggregate":true}}`,
|
|
1981
|
+
* what `@cyanmycelium/mcp-broker-provider` sends on **both** paths. This
|
|
1982
|
+
* is the shape to write new providers against: the multiplexed socket has
|
|
1983
|
+
* always used it (wrapped in an envelope), so there is now one
|
|
1984
|
+
* registration to learn instead of one per path.
|
|
1985
|
+
* - `{"type":"register","aggregate":true}`, the legacy control frame, kept
|
|
1986
|
+
* working indefinitely for hand-written providers already sending it.
|
|
1987
|
+
*
|
|
1988
|
+
* `aggregate` is opt-in and stays that way. Joining `_all` publishes a
|
|
1989
|
+
* provider's tools and prompts to every client of the aggregate slot, which
|
|
1990
|
+
* is a confidentiality boundary: a provider that never asks is reachable
|
|
1991
|
+
* only on its own slot.
|
|
1992
|
+
*
|
|
1993
|
+
* A normal MCP frame carries `jsonrpc` with some other `method`, so it
|
|
1200
1994
|
* returns `false` and the provider stays non-aggregated.
|
|
1201
1995
|
*/
|
|
1202
1996
|
private _tryHandleRegistration(name: string, text: string): boolean {
|
|
1203
|
-
|
|
1997
|
+
const frame = parseObjectFrame(text);
|
|
1998
|
+
if (!frame) return false;
|
|
1999
|
+
|
|
2000
|
+
const isJsonRpcRegister = frame.jsonrpc !== undefined && frame.method === TUNNEL_REGISTER_METHOD;
|
|
2001
|
+
const isLegacyRegister = frame.jsonrpc === undefined && frame.type === "register";
|
|
2002
|
+
if (!isJsonRpcRegister && !isLegacyRegister) return false;
|
|
2003
|
+
|
|
2004
|
+
if (registrationAsksForAggregate(frame)) {
|
|
2005
|
+
void this._aggregateServer?.addProvider(name);
|
|
2006
|
+
}
|
|
2007
|
+
return true;
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
/**
|
|
2011
|
+
* Detects a `MultiplexTransport` connected to a slot-scoped provider URL,
|
|
2012
|
+
* the single most reported way to wire this broker up wrong, and refuses it
|
|
2013
|
+
* instead of letting it look like it worked.
|
|
2014
|
+
*
|
|
2015
|
+
* What the mistake looks like without this check: the socket connects, the
|
|
2016
|
+
* broker registers the slot from the URL before any frame exists, so
|
|
2017
|
+
* `provider_status` reports the provider connected; then every frame the
|
|
2018
|
+
* broker sends is a plain JSON-RPC one that the peer's envelope decoder
|
|
2019
|
+
* drops on the floor, and every frame the peer sends is an envelope with no
|
|
2020
|
+
* top-level `id`, so it matches no pending request. Both sides believe they
|
|
2021
|
+
* are connected and no request is ever answered. Nothing is logged anywhere.
|
|
2022
|
+
*
|
|
2023
|
+
* Only an **unambiguous** envelope closes the socket: a JSON object with no
|
|
2024
|
+
* `jsonrpc` member that decodes as `{provider, payload}`. A malformed frame
|
|
2025
|
+
* is left alone and routed as before, since a provider is entitled to speak
|
|
2026
|
+
* a dialect the broker does not recognize. The legacy
|
|
2027
|
+
* `{"type":"register","aggregate":true}` frame has no `provider`/`payload`
|
|
2028
|
+
* pair and so is never mistaken for one.
|
|
2029
|
+
*
|
|
2030
|
+
* The diagnosis goes out three ways because each reaches a different reader:
|
|
2031
|
+
* the broker log, an error **envelope** (the only framing this particular
|
|
2032
|
+
* peer can decode), and the close reason, which the provider SDK surfaces
|
|
2033
|
+
* through `onError` and prints to the browser console.
|
|
2034
|
+
*/
|
|
2035
|
+
private _refuseEnvelopeOnSlotPath(ws: WebSocket, name: string, text: string): boolean {
|
|
2036
|
+
const frame = parseObjectFrame(text);
|
|
2037
|
+
if (!frame || frame.jsonrpc !== undefined) return false;
|
|
2038
|
+
const envelope = decodeEnvelope(text);
|
|
2039
|
+
if (!envelope) return false;
|
|
2040
|
+
|
|
2041
|
+
const providerPath = this._options.providerPath ?? "/provider";
|
|
2042
|
+
const providersPath = this._options.providersPath ?? "/providers";
|
|
2043
|
+
const detail =
|
|
2044
|
+
`Provider "${name}" is registered on the slot-scoped path "${providerPath}/${name}", which carries plain JSON-RPC frames, but its first frame is a multiplex envelope ` +
|
|
2045
|
+
`(it announced slot "${envelope.provider}"). This is a MultiplexTransport pointed at a DirectTransport URL. Two corrections, either works: ` +
|
|
2046
|
+
`use DirectTransport for "${providerPath}/${name}" (plain frames, one slot fixed by the URL), or keep MultiplexTransport and connect it to "${providersPath}" ` +
|
|
2047
|
+
`(exact path, envelope framing, slots announced per frame). The connection is being closed; it would otherwise look connected and answer nothing.`;
|
|
2048
|
+
|
|
2049
|
+
console.warn(`[broker] ${detail}`);
|
|
1204
2050
|
try {
|
|
1205
|
-
|
|
2051
|
+
// Addressed to the slot the peer announced, not to the URL's: that is
|
|
2052
|
+
// the name its own decoder will match the envelope against.
|
|
2053
|
+
ws.send(encodeErrorEnvelope(envelope.provider, TunnelErrorCodes.ProviderUnavailable, detail));
|
|
1206
2054
|
} catch {
|
|
1207
|
-
|
|
2055
|
+
/* the socket may already be gone; the log and the close reason stand */
|
|
1208
2056
|
}
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
2057
|
+
this._closeWs(ws, 1008, `Envelope frame on slot-scoped path ${providerPath}/${name}: use DirectTransport here, or connect to ${providersPath}`);
|
|
2058
|
+
return true;
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
/**
|
|
2062
|
+
* The mirror image: a `DirectTransport` connected to the shared multiplexed
|
|
2063
|
+
* base. Refuses it instead of dropping its frames one by one in silence.
|
|
2064
|
+
*
|
|
2065
|
+
* Signature of the mistake: the socket opens, the broker never learns a slot
|
|
2066
|
+
* name (they only arrive inside envelopes), so no slot is ever claimed and
|
|
2067
|
+
* every client is told the provider is not connected, while the provider
|
|
2068
|
+
* believes it published successfully.
|
|
2069
|
+
*
|
|
2070
|
+
* Only a frame that is unambiguously plain JSON-RPC (a JSON object carrying
|
|
2071
|
+
* `jsonrpc`, that did not decode as an envelope) closes the socket. Anything
|
|
2072
|
+
* else keeps the old behavior of dropping the frame, now with one warning.
|
|
2073
|
+
*/
|
|
2074
|
+
private _refusePlainFrameOnMultiplexPath(ws: WebSocket, text: string): boolean {
|
|
2075
|
+
const frame = parseObjectFrame(text);
|
|
2076
|
+
if (!frame || frame.jsonrpc === undefined) return false;
|
|
2077
|
+
|
|
2078
|
+
const providerPath = this._options.providerPath ?? "/provider";
|
|
2079
|
+
const providersPath = this._options.providersPath ?? "/providers";
|
|
2080
|
+
const detail =
|
|
2081
|
+
`A provider connected to the multiplexed path "${providersPath}", which carries envelope frames {"provider":"<slot>","payload":{...}}, but sent a plain JSON-RPC frame ` +
|
|
2082
|
+
`(method "${String(frame.method ?? "(none)")}"). This is a DirectTransport pointed at a MultiplexTransport URL. Two corrections, either works: ` +
|
|
2083
|
+
`use MultiplexTransport for "${providersPath}" (it wraps every frame and announces its slots), or keep DirectTransport and connect it to "${providerPath}/<name>" ` +
|
|
2084
|
+
`(the slot comes from the URL there). The connection is being closed; on this path the broker never learns a slot name, so every client would be told the provider is not connected.`;
|
|
2085
|
+
|
|
2086
|
+
console.warn(`[broker] ${detail}`);
|
|
2087
|
+
try {
|
|
2088
|
+
// A bare JSON-RPC error, matching what this peer speaks: an envelope
|
|
2089
|
+
// would be dropped by a DirectTransport exactly the way its own
|
|
2090
|
+
// frames are being dropped here.
|
|
2091
|
+
ws.send(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: TunnelErrorCodes.ProviderUnavailable, message: detail } }));
|
|
2092
|
+
} catch {
|
|
2093
|
+
/* the socket may already be gone; the log and the close reason stand */
|
|
1212
2094
|
}
|
|
2095
|
+
this._closeWs(ws, 1008, `Plain JSON-RPC on multiplex path ${providersPath}: use MultiplexTransport here, or connect to ${providerPath}/<name>`);
|
|
1213
2096
|
return true;
|
|
1214
2097
|
}
|
|
1215
2098
|
|
|
@@ -1229,10 +2112,17 @@ export class WsTunnel implements IBrokerContext {
|
|
|
1229
2112
|
|
|
1230
2113
|
ws.on("close", () => {
|
|
1231
2114
|
state.wsClients.delete(ws);
|
|
1232
|
-
for (const [
|
|
1233
|
-
if (sink.type === "ws" && sink.socket === ws) state.pending.delete(
|
|
2115
|
+
for (const [brokerId, entry] of state.pending) {
|
|
2116
|
+
if (entry.sink.type === "ws" && entry.sink.socket === ws) state.pending.delete(brokerId);
|
|
1234
2117
|
}
|
|
1235
2118
|
});
|
|
2119
|
+
|
|
2120
|
+
// Same reason as on the provider sockets: an unhandled 'error' event is
|
|
2121
|
+
// rethrown by Node, so a single client sending a malformed frame would
|
|
2122
|
+
// otherwise kill the broker for everybody.
|
|
2123
|
+
ws.on("error", (err: Error) => {
|
|
2124
|
+
console.error(`[broker] client socket error on slot "${providerName}": ${err.message}. The socket is being closed; the client should reconnect.`);
|
|
2125
|
+
});
|
|
1236
2126
|
}
|
|
1237
2127
|
|
|
1238
2128
|
/**
|
|
@@ -1244,15 +2134,39 @@ export class WsTunnel implements IBrokerContext {
|
|
|
1244
2134
|
private _onMultiplexProviderConnect(ws: WebSocket, req: IncomingMessage): void {
|
|
1245
2135
|
const providerNames = new Set<string>();
|
|
1246
2136
|
this._multiplexSockets.set(ws, providerNames);
|
|
2137
|
+
this._watchProviderSocket(ws);
|
|
1247
2138
|
const providerPrincipal = this._pendingProviderPrincipals.get(req);
|
|
1248
2139
|
if (providerPrincipal) {
|
|
1249
2140
|
this._pendingProviderPrincipals.delete(req);
|
|
1250
2141
|
this._providerPrincipals.set(ws, providerPrincipal);
|
|
1251
2142
|
}
|
|
1252
2143
|
|
|
2144
|
+
// Undecodable frames used to be dropped without a word. The first one is
|
|
2145
|
+
// where a DirectTransport on the wrong URL is caught; later ones warn
|
|
2146
|
+
// once, because a peer that speaks the wrong dialect speaks it on every
|
|
2147
|
+
// frame and must not be allowed to flood the log.
|
|
2148
|
+
let firstFrame = true;
|
|
2149
|
+
let undecodableWarned = false;
|
|
2150
|
+
|
|
1253
2151
|
ws.on("message", (data: Buffer) => {
|
|
1254
|
-
const
|
|
1255
|
-
|
|
2152
|
+
const text = data.toString();
|
|
2153
|
+
const envelope = decodeEnvelope(text);
|
|
2154
|
+
const wasFirstFrame = firstFrame;
|
|
2155
|
+
firstFrame = false;
|
|
2156
|
+
|
|
2157
|
+
if (!envelope) {
|
|
2158
|
+
if (wasFirstFrame && this._refusePlainFrameOnMultiplexPath(ws, text)) return;
|
|
2159
|
+
if (!undecodableWarned) {
|
|
2160
|
+
undecodableWarned = true;
|
|
2161
|
+
const preview = text.length > 200 ? `${text.slice(0, 200)}...` : text;
|
|
2162
|
+
console.warn(
|
|
2163
|
+
`[broker] multiplexed provider socket sent a frame that is not a tunnel envelope; it was dropped. ` +
|
|
2164
|
+
`This path expects {"provider":"<slot>","payload":{ ...JSON-RPC... }} on every frame. First 200 chars: ${JSON.stringify(preview)}. ` +
|
|
2165
|
+
`Further undecodable frames from this socket are not logged.`
|
|
2166
|
+
);
|
|
2167
|
+
}
|
|
2168
|
+
return;
|
|
2169
|
+
}
|
|
1256
2170
|
|
|
1257
2171
|
const name = envelope.provider;
|
|
1258
2172
|
|
|
@@ -1280,10 +2194,15 @@ export class WsTunnel implements IBrokerContext {
|
|
|
1280
2194
|
return;
|
|
1281
2195
|
}
|
|
1282
2196
|
|
|
1283
|
-
const
|
|
1284
|
-
if (
|
|
1285
|
-
// Provider already connected via another socket, reject this
|
|
1286
|
-
|
|
2197
|
+
const refusal = this._claimSlot(name, ws);
|
|
2198
|
+
if (refusal) {
|
|
2199
|
+
// Provider already connected via another socket, reject this
|
|
2200
|
+
// name. The socket itself stays open on purpose: it may be
|
|
2201
|
+
// carrying other slots it is legitimately serving.
|
|
2202
|
+
console.warn(`[broker] WARNING: multiplexed WebSocket provider "${name}" rejected. ${refusal.detail}`);
|
|
2203
|
+
// An envelope has no length limit, so the peer gets the full
|
|
2204
|
+
// diagnosis here, not the close-frame abbreviation.
|
|
2205
|
+
ws.send(encodeErrorEnvelope(name, TunnelErrorCodes.ProviderUnavailable, refusal.detail));
|
|
1287
2206
|
return;
|
|
1288
2207
|
}
|
|
1289
2208
|
providerNames.add(name);
|
|
@@ -1292,6 +2211,16 @@ export class WsTunnel implements IBrokerContext {
|
|
|
1292
2211
|
if (providerPrincipal) {
|
|
1293
2212
|
this._logProviderRegistration(providerPrincipal, name, this._slotResourceResolver.resolve(name), true);
|
|
1294
2213
|
}
|
|
2214
|
+
|
|
2215
|
+
// The multiplexed path could not aggregate at all before this:
|
|
2216
|
+
// it had no `addProvider` call anywhere, so the one transport the
|
|
2217
|
+
// provider README demonstrates was the one that could never join
|
|
2218
|
+
// `_all`. The opt-in rides on the `notifications/register`
|
|
2219
|
+
// notification the transport already sends on every open, as
|
|
2220
|
+
// `params.aggregate`.
|
|
2221
|
+
if (registrationAsksForAggregate(envelope.payload)) {
|
|
2222
|
+
void this._aggregateServer?.addProvider(name);
|
|
2223
|
+
}
|
|
1295
2224
|
}
|
|
1296
2225
|
|
|
1297
2226
|
const state = this._providers.get(name)!;
|
|
@@ -1308,6 +2237,17 @@ export class WsTunnel implements IBrokerContext {
|
|
|
1308
2237
|
}
|
|
1309
2238
|
this._multiplexSockets.delete(ws);
|
|
1310
2239
|
});
|
|
2240
|
+
|
|
2241
|
+
// An unhandled 'error' event is rethrown by Node. One multiplex socket
|
|
2242
|
+
// carries every slot it announced, so losing the process over a single
|
|
2243
|
+
// malformed frame is the worst of the three cases: name the slots this
|
|
2244
|
+
// socket was serving, since they are all about to go with it.
|
|
2245
|
+
ws.on("error", (err: Error) => {
|
|
2246
|
+
const slots = providerNames.size > 0 ? [...providerNames].join(", ") : "(none announced yet)";
|
|
2247
|
+
console.error(
|
|
2248
|
+
`[broker] multiplex provider socket error: ${err.message}. Slots carried by this socket: ${slots}. The socket is being closed; the provider should reconnect.`
|
|
2249
|
+
);
|
|
2250
|
+
});
|
|
1311
2251
|
}
|
|
1312
2252
|
|
|
1313
2253
|
// -------------------------------------------------------------------------
|
|
@@ -1352,15 +2292,8 @@ export class WsTunnel implements IBrokerContext {
|
|
|
1352
2292
|
}
|
|
1353
2293
|
|
|
1354
2294
|
private _routeFromStdioClient(state: IProviderState, data: string): void {
|
|
1355
|
-
try {
|
|
1356
|
-
const msg = JSON.parse(data) as { id?: string | number };
|
|
1357
|
-
if (msg?.id != null) state.pending.set(msg.id, { type: "stdio" });
|
|
1358
|
-
} catch {
|
|
1359
|
-
/* forward as-is */
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
2295
|
if (this._isProviderConnected(this._stdioClientProvider!, state)) {
|
|
1363
|
-
this._sendToProvider(state, this._stdioClientProvider!, data);
|
|
2296
|
+
this._sendToProvider(state, this._stdioClientProvider!, this._trackRequest(state, data, { type: "stdio" }));
|
|
1364
2297
|
} else {
|
|
1365
2298
|
let errId: string | number | null = null;
|
|
1366
2299
|
try {
|
|
@@ -1369,11 +2302,23 @@ export class WsTunnel implements IBrokerContext {
|
|
|
1369
2302
|
/* */
|
|
1370
2303
|
}
|
|
1371
2304
|
if (errId != null) {
|
|
2305
|
+
// A stdio host (Claude Desktop) starts the broker and sends
|
|
2306
|
+
// `initialize` immediately, long before a browser or engine
|
|
2307
|
+
// provider has had a chance to connect, and it treats the failure
|
|
2308
|
+
// as a dead server with no retry. So the error names the two slots
|
|
2309
|
+
// that exist from the first millisecond instead of just reporting
|
|
2310
|
+
// the miss.
|
|
1372
2311
|
this._stdioClientTransport?.send(
|
|
1373
2312
|
JSON.stringify({
|
|
1374
2313
|
jsonrpc: "2.0",
|
|
1375
2314
|
id: errId,
|
|
1376
|
-
error: {
|
|
2315
|
+
error: {
|
|
2316
|
+
code: -32000,
|
|
2317
|
+
message:
|
|
2318
|
+
`Provider "${this._stdioClientProvider}" not connected. A stdio host cannot wait for a provider to appear: pin ` +
|
|
2319
|
+
`MCP_BROKER_STDIO_PROVIDER to "_all" (the aggregate slot, present at startup, and it announces tools as providers join) ` +
|
|
2320
|
+
`or to "_broker" (introspection only: broker_info, providers_list, provider_status).`,
|
|
2321
|
+
},
|
|
1377
2322
|
})
|
|
1378
2323
|
);
|
|
1379
2324
|
}
|
|
@@ -1387,24 +2332,31 @@ export class WsTunnel implements IBrokerContext {
|
|
|
1387
2332
|
return;
|
|
1388
2333
|
}
|
|
1389
2334
|
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
this._sendToProvider(state, providerName, data, principal);
|
|
1399
|
-
} else {
|
|
2335
|
+
if (!this._isProviderConnected(providerName, state)) {
|
|
2336
|
+
// Echo the request id and name the slot. With `id: null` a client that
|
|
2337
|
+
// correlates by id (every SDK does) drops the frame and then waits for
|
|
2338
|
+
// an answer that is never coming, so the symptom of an absent provider
|
|
2339
|
+
// was an indefinite hang rather than an error. The pointer to
|
|
2340
|
+
// `providers_list` is what turns it into something actionable: the
|
|
2341
|
+
// usual cause is a slot name that does not match the one the provider
|
|
2342
|
+
// registered.
|
|
1400
2343
|
client.send(
|
|
1401
2344
|
JSON.stringify({
|
|
1402
2345
|
jsonrpc: "2.0",
|
|
1403
|
-
id: null,
|
|
1404
|
-
error: {
|
|
2346
|
+
id: requestIdOf(data) ?? null,
|
|
2347
|
+
error: {
|
|
2348
|
+
code: -32000,
|
|
2349
|
+
message: `Provider "${providerName}" is not connected. Call providers_list on the _broker slot to see which slots are live.`,
|
|
2350
|
+
},
|
|
1405
2351
|
})
|
|
1406
2352
|
);
|
|
2353
|
+
return;
|
|
1407
2354
|
}
|
|
2355
|
+
|
|
2356
|
+
// Registered only once the frame is actually on its way out: an entry
|
|
2357
|
+
// added before the connectivity check has nothing to answer it and would
|
|
2358
|
+
// pin that id until the slot next disconnects.
|
|
2359
|
+
this._sendToProvider(state, providerName, this._trackRequest(state, data, { type: "ws", socket: client }), principal);
|
|
1408
2360
|
}
|
|
1409
2361
|
|
|
1410
2362
|
private _routeFromProvider(state: IProviderState, providerName: string, data: string): void {
|
|
@@ -1412,32 +2364,67 @@ export class WsTunnel implements IBrokerContext {
|
|
|
1412
2364
|
const msg = JSON.parse(data) as { id?: string | number };
|
|
1413
2365
|
|
|
1414
2366
|
if (msg.id != null) {
|
|
1415
|
-
// Response: route to the specific sink that made the request
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
state.httpSessions.get(sink.sessionId)?.transport.send(data);
|
|
1426
|
-
} else if (sink?.type === "stdio") {
|
|
1427
|
-
this._stdioClientTransport?.send(data);
|
|
1428
|
-
} else if (sink?.type === "internal") {
|
|
1429
|
-
sink.client.onMessage?.(data);
|
|
2367
|
+
// Response: route to the specific sink that made the request, and
|
|
2368
|
+
// put the client's own id back in place of the broker's before it
|
|
2369
|
+
// is delivered (see `_trackRequest`).
|
|
2370
|
+
const entry = state.pending.get(msg.id);
|
|
2371
|
+
if (entry) {
|
|
2372
|
+
state.pending.delete(msg.id);
|
|
2373
|
+
(msg as { id: unknown }).id = entry.clientId;
|
|
2374
|
+
this._deliverToSink(state, entry.sink, JSON.stringify(msg));
|
|
2375
|
+
} else {
|
|
2376
|
+
this._warnUnmatchedResponseId(providerName, msg.id);
|
|
1430
2377
|
}
|
|
1431
|
-
state.pending.delete(msg.id);
|
|
1432
2378
|
} else {
|
|
1433
2379
|
// Notification (no id): broadcast to all clients of this provider.
|
|
1434
2380
|
this._broadcast(state, providerName, data);
|
|
1435
2381
|
}
|
|
1436
2382
|
} catch {
|
|
2383
|
+
// The frame could not be parsed and routed as JSON-RPC. It is still
|
|
2384
|
+
// broadcast, because some providers legitimately put non-JSON-RPC text
|
|
2385
|
+
// on this socket and silently dropping it would be a behavior change,
|
|
2386
|
+
// but it no longer travels unannounced: the usual source is a reverse
|
|
2387
|
+
// proxy answering with an HTML error page under a 200, which then
|
|
2388
|
+
// surfaces in every client as an "unexpected token <" with nothing
|
|
2389
|
+
// naming the provider it came from.
|
|
2390
|
+
if (!this._nonJsonWarnedProviders.has(providerName)) {
|
|
2391
|
+
this._nonJsonWarnedProviders.add(providerName);
|
|
2392
|
+
const preview = data.length > 200 ? `${data.slice(0, 200)}...` : data;
|
|
2393
|
+
console.warn(
|
|
2394
|
+
`[broker] provider "${providerName}" sent a frame the broker could not route as JSON-RPC; it is being broadcast to that slot's clients verbatim. ` +
|
|
2395
|
+
`First 200 chars: ${JSON.stringify(preview)}. If this is HTML, something between the broker and the provider (a proxy, a login page) is answering instead of the provider. ` +
|
|
2396
|
+
`Further such frames from this slot are not logged.`
|
|
2397
|
+
);
|
|
2398
|
+
}
|
|
1437
2399
|
this._broadcast(state, providerName, data);
|
|
1438
2400
|
}
|
|
1439
2401
|
}
|
|
1440
2402
|
|
|
2403
|
+
/**
|
|
2404
|
+
* Reports a frame carrying an id nothing is waiting for.
|
|
2405
|
+
*
|
|
2406
|
+
* Two very different causes, so the message names both. Either the provider
|
|
2407
|
+
* did not echo the id it was given (the frame is then unroutable and the
|
|
2408
|
+
* real client hangs until the request deadline), or the provider is opening
|
|
2409
|
+
* a **server-to-client** request of its own (`sampling/createMessage`,
|
|
2410
|
+
* `roots/list`, `elicitation/create`), which this broker does not relay: the
|
|
2411
|
+
* frame is dropped and the provider waits for an answer that never comes.
|
|
2412
|
+
* Both used to be silent, which is what made the second one impossible to
|
|
2413
|
+
* diagnose from either end.
|
|
2414
|
+
*
|
|
2415
|
+
* Once per slot: a provider doing this does it on every frame.
|
|
2416
|
+
*/
|
|
2417
|
+
private _warnUnmatchedResponseId(providerName: string, id: string | number): void {
|
|
2418
|
+
if (this._unmatchedIdWarnedProviders.has(providerName)) return;
|
|
2419
|
+
this._unmatchedIdWarnedProviders.add(providerName);
|
|
2420
|
+
console.warn(
|
|
2421
|
+
`[broker] provider "${providerName}" sent a frame with id ${JSON.stringify(id)}, which matches no request the broker is waiting on; it was dropped. ` +
|
|
2422
|
+
`Either the provider answered with a different id than the one it received (JSON-RPC requires echoing it verbatim, including its type: 1 and "1" are not the same id), ` +
|
|
2423
|
+
`or it is initiating a request of its own (sampling/createMessage, roots/list, elicitation/create), which the broker does not relay to clients. ` +
|
|
2424
|
+
`Further unmatched ids from this slot are not logged.`
|
|
2425
|
+
);
|
|
2426
|
+
}
|
|
2427
|
+
|
|
1441
2428
|
/** Sends a message to all clients connected to one provider. */
|
|
1442
2429
|
private _broadcast(state: IProviderState, providerName: string, data: string): void {
|
|
1443
2430
|
for (const client of state.wsClients) {
|
|
@@ -1475,27 +2462,17 @@ export class WsTunnel implements IBrokerContext {
|
|
|
1475
2462
|
* close handlers (dedicated WS, multiplexed WS, loopback).
|
|
1476
2463
|
*/
|
|
1477
2464
|
private _failProviderDisconnected(state: IProviderState, name: string): void {
|
|
1478
|
-
// Echoing the
|
|
1479
|
-
// matches the answer to its held-open POST by
|
|
1480
|
-
// error would leave that request
|
|
1481
|
-
|
|
2465
|
+
// Echoing the **client's** id rather than `null` or the broker's: a
|
|
2466
|
+
// Streamable HTTP session matches the answer to its held-open POST by
|
|
2467
|
+
// the id it chose, so an unaddressed error would leave that request
|
|
2468
|
+
// hanging until it times out.
|
|
2469
|
+
for (const entry of state.pending.values()) {
|
|
1482
2470
|
const error = JSON.stringify({
|
|
1483
2471
|
jsonrpc: "2.0",
|
|
1484
|
-
id,
|
|
2472
|
+
id: entry.clientId,
|
|
1485
2473
|
error: { code: -32000, message: `Provider "${name}" disconnected` },
|
|
1486
2474
|
});
|
|
1487
|
-
|
|
1488
|
-
sink.socket.send(error);
|
|
1489
|
-
} else if (sink.type === "sse") {
|
|
1490
|
-
const sseRes = state.sseSessions.get(sink.sessionId);
|
|
1491
|
-
if (sseRes) this._sendSseEvent(sseRes, error);
|
|
1492
|
-
} else if (sink.type === "http-session") {
|
|
1493
|
-
state.httpSessions.get(sink.sessionId)?.transport.send(error);
|
|
1494
|
-
} else if (sink.type === "stdio") {
|
|
1495
|
-
this._stdioClientTransport?.send(error);
|
|
1496
|
-
} else if (sink.type === "internal") {
|
|
1497
|
-
sink.client.onMessage?.(error);
|
|
1498
|
-
}
|
|
2475
|
+
this._deliverToSink(state, entry.sink, error);
|
|
1499
2476
|
}
|
|
1500
2477
|
state.pending.clear();
|
|
1501
2478
|
for (const ic of state.internalClients) ic.onClose?.();
|