@cyanmycelium/mcp-broker 0.2.0 → 0.4.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/README.md +23 -0
- package/.mcp-broker.example/config.json +11 -0
- package/README.md +1 -16
- package/dist/bin.js +30 -3
- package/dist/bin.js.map +1 -1
- package/dist/broker/aggregate/aggregate.catalog.d.ts +54 -0
- package/dist/broker/aggregate/aggregate.catalog.js +105 -0
- package/dist/broker/aggregate/aggregate.catalog.js.map +1 -0
- package/dist/broker/aggregate/aggregate.server.d.ts +47 -0
- package/dist/broker/aggregate/aggregate.server.js +151 -0
- package/dist/broker/aggregate/aggregate.server.js.map +1 -0
- package/dist/broker/aggregate/provider.client.session.d.ts +52 -0
- package/dist/broker/aggregate/provider.client.session.js +140 -0
- package/dist/broker/aggregate/provider.client.session.js.map +1 -0
- package/dist/broker/broker.grammars.d.ts +50 -86
- package/dist/broker/broker.grammars.js +55 -84
- package/dist/broker/broker.grammars.js.map +1 -1
- package/dist/broker/broker.server.d.ts +23 -21
- package/dist/broker/broker.server.js +33 -71
- package/dist/broker/broker.server.js.map +1 -1
- package/dist/broker/index.d.ts +2 -2
- package/dist/broker/index.js +1 -1
- package/dist/broker/index.js.map +1 -1
- package/dist/config.d.ts +35 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/mcpb.loader.d.ts +24 -0
- package/dist/mcpb.loader.js +161 -0
- package/dist/mcpb.loader.js.map +1 -0
- package/dist/mcpb.unzip.d.ts +6 -0
- package/dist/mcpb.unzip.js +95 -0
- package/dist/mcpb.unzip.js.map +1 -0
- package/dist/remote.transports.d.ts +16 -0
- package/dist/remote.transports.js +297 -0
- package/dist/remote.transports.js.map +1 -0
- package/dist/remote.upstream.d.ts +36 -0
- package/dist/remote.upstream.js +52 -0
- package/dist/remote.upstream.js.map +1 -0
- package/dist/stdio.upstream.d.ts +4 -1
- package/dist/stdio.upstream.js.map +1 -1
- package/dist/upstream.d.ts +33 -0
- package/dist/upstream.js +2 -0
- package/dist/upstream.js.map +1 -0
- package/dist/ws.tunnel.builder.d.ts +14 -8
- package/dist/ws.tunnel.builder.js +17 -9
- package/dist/ws.tunnel.builder.js.map +1 -1
- package/dist/ws.tunnel.d.ts +85 -22
- package/dist/ws.tunnel.js +201 -82
- package/dist/ws.tunnel.js.map +1 -1
- package/package.json +3 -2
- package/scripts/pack-mcpb.mjs +84 -0
- package/scripts/sign-bundle.mjs +61 -0
- package/src/bin.ts +32 -3
- package/src/broker/aggregate/aggregate.catalog.ts +145 -0
- package/src/broker/aggregate/aggregate.server.ts +178 -0
- package/src/broker/aggregate/provider.client.session.ts +172 -0
- package/src/broker/broker.grammars.ts +74 -122
- package/src/broker/broker.server.ts +57 -99
- package/src/broker/index.ts +3 -5
- package/src/config.ts +37 -0
- package/src/index.ts +10 -10
- package/src/mcpb.loader.ts +186 -0
- package/src/mcpb.unzip.ts +103 -0
- package/src/remote.transports.ts +316 -0
- package/src/remote.upstream.ts +75 -0
- package/src/stdio.upstream.ts +4 -1
- package/src/upstream.ts +33 -0
- package/src/ws.tunnel.builder.ts +19 -9
- package/src/ws.tunnel.ts +258 -99
package/dist/ws.tunnel.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { IMessageTransport } from "@cyanmycelium/mcp-core";
|
|
1
|
+
import type { GrammarResolverOptions, IMessageTransport } from "@cyanmycelium/mcp-core";
|
|
2
2
|
import { type StdioUpstreamConfig } from "./stdio.upstream.js";
|
|
3
|
-
import
|
|
3
|
+
import { type RemoteUpstreamConfig } from "./remote.upstream.js";
|
|
4
|
+
import type { BrokerContext, BrokerProviderInfo } from "./broker/index.js";
|
|
4
5
|
/**
|
|
5
6
|
* A single static-file mount: serves the contents of `dir` under `urlPrefix`.
|
|
6
7
|
*
|
|
@@ -14,6 +15,27 @@ export interface StaticMount {
|
|
|
14
15
|
/** Absolute path to the directory to serve. */
|
|
15
16
|
dir: string;
|
|
16
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* In-process client handle for a provider slot — the symmetric counterpart of
|
|
20
|
+
* {@link WsTunnel.registerLoopbackProvider}. Lets a component inside the broker
|
|
21
|
+
* process (e.g. the aggregate server) issue MCP requests to a provider slot and
|
|
22
|
+
* receive both the responses and the provider's broadcast notifications,
|
|
23
|
+
* without opening a real network connection.
|
|
24
|
+
*/
|
|
25
|
+
export interface InternalClient {
|
|
26
|
+
/**
|
|
27
|
+
* Sends a JSON-RPC message to the provider slot. When the message carries an
|
|
28
|
+
* `id`, the matching response is delivered to {@link onMessage}. When the
|
|
29
|
+
* provider is not connected, a JSON-RPC error is delivered synchronously.
|
|
30
|
+
*/
|
|
31
|
+
send(message: string): void;
|
|
32
|
+
/** Receives responses to this client's requests and the provider's notifications. */
|
|
33
|
+
onMessage: ((data: string) => void) | null;
|
|
34
|
+
/** Fires when the provider slot loses its connection. */
|
|
35
|
+
onClose: (() => void) | null;
|
|
36
|
+
/** Detaches this internal client; pending requests are dropped. */
|
|
37
|
+
close(): void;
|
|
38
|
+
}
|
|
17
39
|
/**
|
|
18
40
|
* Configuration options for a {@link WsTunnel} instance.
|
|
19
41
|
*/
|
|
@@ -83,6 +105,8 @@ export interface WsTunnelOptions {
|
|
|
83
105
|
* @default undefined — no stdio providers
|
|
84
106
|
*/
|
|
85
107
|
stdioUpstreams?: StdioUpstreamConfig[];
|
|
108
|
+
/** Remote MCP servers reached by URL, exposed as provider slots. */
|
|
109
|
+
remoteUpstreams?: RemoteUpstreamConfig[];
|
|
86
110
|
/**
|
|
87
111
|
* Stdio client transport. When set, the broker reads JSON-RPC from
|
|
88
112
|
* `process.stdin` and writes responses to `process.stdout`, bridging an
|
|
@@ -125,6 +149,16 @@ export interface WsTunnelOptions {
|
|
|
125
149
|
* @default true
|
|
126
150
|
*/
|
|
127
151
|
enableBrokerProvider?: boolean;
|
|
152
|
+
/**
|
|
153
|
+
* When `true` (default), the broker exposes the reserved slot `_all` — an
|
|
154
|
+
* aggregate MCP server that unions the tools and prompts of every provider
|
|
155
|
+
* that opted in via the registration handshake. Reachable like any other
|
|
156
|
+
* slot (`<host>/_all/mcp`, etc.).
|
|
157
|
+
*
|
|
158
|
+
* Set to `false` to disable aggregation entirely.
|
|
159
|
+
* @default true
|
|
160
|
+
*/
|
|
161
|
+
enableAggregateProvider?: boolean;
|
|
128
162
|
/**
|
|
129
163
|
* Logical name reported by `broker_info`. Useful when running multiple
|
|
130
164
|
* broker instances and you want to tell them apart from the agent side
|
|
@@ -133,27 +167,25 @@ export interface WsTunnelOptions {
|
|
|
133
167
|
*/
|
|
134
168
|
brokerName?: string;
|
|
135
169
|
/**
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
* `clientInfo.name` against known LLM families).
|
|
145
|
-
*/
|
|
146
|
-
brokerUserAgentResolver?: BrokerUserAgentResolver;
|
|
147
|
-
/**
|
|
148
|
-
* Custom source of the raw locale string fed to the locale resolver.
|
|
149
|
-
* Defaults to `() => process.env.MCP_BROKER_LOCALE`. Override when the
|
|
150
|
-
* locale should come from a config file, HTTP header, etc.
|
|
170
|
+
* Overrides for the embedded broker server's grammar resolver — passed
|
|
171
|
+
* straight through to `mcp-core`'s `grammarResolverFromOptions`. The
|
|
172
|
+
* broker installs its own default `localeSource` (reads
|
|
173
|
+
* `process.env.MCP_BROKER_LOCALE`); anything you set here wins.
|
|
174
|
+
*
|
|
175
|
+
* Use this to inject a custom `localeSource` (e.g. read from an HTTP
|
|
176
|
+
* header proxied by your transport), enable the optional `versionFrom`
|
|
177
|
+
* dimension, or extend the `agents` map with additional LLM families.
|
|
151
178
|
*/
|
|
152
|
-
|
|
179
|
+
brokerGrammarResolverOptions?: Partial<GrammarResolverOptions>;
|
|
153
180
|
/**
|
|
154
181
|
* Path to a user-supplied grammars directory whose `<userAgent>/<locale>.json`
|
|
155
|
-
* files are
|
|
156
|
-
* broker server. Typically pointed at `.mcp-broker/grammars/`.
|
|
182
|
+
* files are registered alongside the packaged grammars used by the
|
|
183
|
+
* embedded broker server. Typically pointed at `.mcp-broker/grammars/`.
|
|
184
|
+
*
|
|
185
|
+
* The candidate-chain resolution in `McpServer.initialize`
|
|
186
|
+
* (mcp-core@0.3.0) handles cascade across user-agent and locale
|
|
187
|
+
* dimensions, so partial files no longer need to be pre-merged with a
|
|
188
|
+
* baseline.
|
|
157
189
|
*/
|
|
158
190
|
brokerLocalGrammarsDir?: string;
|
|
159
191
|
}
|
|
@@ -190,8 +222,8 @@ export declare class WsTunnel implements BrokerContext {
|
|
|
190
222
|
private readonly _providers;
|
|
191
223
|
/** Maps a multiplexed WebSocket to the set of provider names it feeds. */
|
|
192
224
|
private readonly _multiplexSockets;
|
|
193
|
-
/**
|
|
194
|
-
private readonly
|
|
225
|
+
/** Upstream providers (stdio child processes and remote URL servers), keyed by name. */
|
|
226
|
+
private readonly _upstreams;
|
|
195
227
|
/**
|
|
196
228
|
* In-process loopback transports registered as provider slots.
|
|
197
229
|
* Used by the embedded broker server (`_broker`) and any other component
|
|
@@ -200,6 +232,8 @@ export declare class WsTunnel implements BrokerContext {
|
|
|
200
232
|
private readonly _loopbackProviders;
|
|
201
233
|
/** The embedded broker MCP server, when {@link WsTunnelOptions.enableBrokerProvider} is on. */
|
|
202
234
|
private _brokerServer;
|
|
235
|
+
/** The aggregate MCP server (`_all` slot), when {@link WsTunnelOptions.enableAggregateProvider} is on. */
|
|
236
|
+
private _aggregateServer;
|
|
203
237
|
/** Provider name that the stdio client transport is bridged to, or null when disabled. */
|
|
204
238
|
private _stdioClientProvider;
|
|
205
239
|
/** Buffered partial line from stdin (stdio client transport). */
|
|
@@ -226,6 +260,16 @@ export declare class WsTunnel implements BrokerContext {
|
|
|
226
260
|
* @throws if the name is already used by a stdio upstream or another loopback.
|
|
227
261
|
*/
|
|
228
262
|
registerLoopbackProvider(name: string, transport: IMessageTransport): void;
|
|
263
|
+
/**
|
|
264
|
+
* Opens an in-process client to a provider slot. The returned handle can
|
|
265
|
+
* issue MCP requests and receives both the responses and the provider's
|
|
266
|
+
* broadcast notifications. Used by the aggregate server to fan a single
|
|
267
|
+
* in-process client out to every aggregated provider.
|
|
268
|
+
*
|
|
269
|
+
* The slot does not need a provider attached yet — `send` returns a
|
|
270
|
+
* JSON-RPC error while the provider is disconnected.
|
|
271
|
+
*/
|
|
272
|
+
openInternalClient(providerName: string): InternalClient;
|
|
229
273
|
get isListening(): boolean;
|
|
230
274
|
/** Total number of connected MCP clients across all providers. */
|
|
231
275
|
get clientCount(): number;
|
|
@@ -244,6 +288,11 @@ export declare class WsTunnel implements BrokerContext {
|
|
|
244
288
|
* is `false`.
|
|
245
289
|
*/
|
|
246
290
|
private _maybeStartBrokerServer;
|
|
291
|
+
/**
|
|
292
|
+
* Starts the aggregate MCP server and registers it on the reserved `_all`
|
|
293
|
+
* slot. No-op when {@link WsTunnelOptions.enableAggregateProvider} is `false`.
|
|
294
|
+
*/
|
|
295
|
+
private _maybeStartAggregateServer;
|
|
247
296
|
/**
|
|
248
297
|
* Gracefully closes all connections and stops the HTTP server.
|
|
249
298
|
*/
|
|
@@ -280,6 +329,14 @@ export declare class WsTunnel implements BrokerContext {
|
|
|
280
329
|
/** Writes one JSON-RPC message as an SSE `message` event. */
|
|
281
330
|
private _sendSseEvent;
|
|
282
331
|
private _onProviderConnect;
|
|
332
|
+
/**
|
|
333
|
+
* Inspects a provider's first WebSocket message for an optional registration
|
|
334
|
+
* control frame `{ "type": "register", "aggregate": boolean }`. Returns
|
|
335
|
+
* `true` when the message was a registration frame — and thus consumed, not
|
|
336
|
+
* routed as MCP traffic. A normal MCP frame always carries `jsonrpc`, so it
|
|
337
|
+
* returns `false` and the provider stays non-aggregated.
|
|
338
|
+
*/
|
|
339
|
+
private _tryHandleRegistration;
|
|
283
340
|
private _onClientConnect;
|
|
284
341
|
/**
|
|
285
342
|
* Handles a multiplexed provider WebSocket (`/providers`).
|
|
@@ -298,6 +355,12 @@ export declare class WsTunnel implements BrokerContext {
|
|
|
298
355
|
private _routeFromProvider;
|
|
299
356
|
/** Sends a message to all clients connected to one provider. */
|
|
300
357
|
private _broadcast;
|
|
358
|
+
/**
|
|
359
|
+
* Notifies every pending sink and internal client that the provider slot
|
|
360
|
+
* has disconnected, then clears the pending map. Shared by all provider
|
|
361
|
+
* close handlers (dedicated WS, multiplexed WS, loopback).
|
|
362
|
+
*/
|
|
363
|
+
private _failProviderDisconnected;
|
|
301
364
|
/**
|
|
302
365
|
* Returns `true` if the provider is reachable — via a WebSocket connection,
|
|
303
366
|
* a stdio upstream, or an in-process loopback transport.
|
package/dist/ws.tunnel.js
CHANGED
|
@@ -5,7 +5,9 @@ import * as nodePath from "path";
|
|
|
5
5
|
import { randomUUID } from "crypto";
|
|
6
6
|
import { WebSocket, WebSocketServer } from "ws";
|
|
7
7
|
import { StdioUpstream } from "./stdio.upstream.js";
|
|
8
|
+
import { RemoteUpstream } from "./remote.upstream.js";
|
|
8
9
|
import { startBrokerServer, BROKER_PROVIDER_NAME } from "./broker/index.js";
|
|
10
|
+
import { AggregateServer } from "./broker/aggregate/aggregate.server.js";
|
|
9
11
|
import { VERSION, PACKAGE_NAME } from "./version.js";
|
|
10
12
|
// ---------------------------------------------------------------------------
|
|
11
13
|
// Static-file helpers
|
|
@@ -60,8 +62,8 @@ export class WsTunnel {
|
|
|
60
62
|
_providers = new Map();
|
|
61
63
|
/** Maps a multiplexed WebSocket to the set of provider names it feeds. */
|
|
62
64
|
_multiplexSockets = new Map();
|
|
63
|
-
/**
|
|
64
|
-
|
|
65
|
+
/** Upstream providers (stdio child processes and remote URL servers), keyed by name. */
|
|
66
|
+
_upstreams = new Map();
|
|
65
67
|
/**
|
|
66
68
|
* In-process loopback transports registered as provider slots.
|
|
67
69
|
* Used by the embedded broker server (`_broker`) and any other component
|
|
@@ -70,6 +72,8 @@ export class WsTunnel {
|
|
|
70
72
|
_loopbackProviders = new Map();
|
|
71
73
|
/** The embedded broker MCP server, when {@link WsTunnelOptions.enableBrokerProvider} is on. */
|
|
72
74
|
_brokerServer = null;
|
|
75
|
+
/** The aggregate MCP server (`_all` slot), when {@link WsTunnelOptions.enableAggregateProvider} is on. */
|
|
76
|
+
_aggregateServer = null;
|
|
73
77
|
/** Provider name that the stdio client transport is bridged to, or null when disabled. */
|
|
74
78
|
_stdioClientProvider = null;
|
|
75
79
|
/** Buffered partial line from stdin (stdio client transport). */
|
|
@@ -136,7 +140,7 @@ export class WsTunnel {
|
|
|
136
140
|
transport = "loopback";
|
|
137
141
|
connected = true;
|
|
138
142
|
}
|
|
139
|
-
else if (this.
|
|
143
|
+
else if (this._upstreams.get(name)?.isOpen) {
|
|
140
144
|
transport = "stdio";
|
|
141
145
|
connected = true;
|
|
142
146
|
}
|
|
@@ -171,7 +175,7 @@ export class WsTunnel {
|
|
|
171
175
|
if (this._loopbackProviders.has(name)) {
|
|
172
176
|
throw new Error(`Loopback provider "${name}" is already registered.`);
|
|
173
177
|
}
|
|
174
|
-
if (this.
|
|
178
|
+
if (this._upstreams.has(name)) {
|
|
175
179
|
throw new Error(`Cannot register loopback "${name}": a stdio upstream with the same name already exists.`);
|
|
176
180
|
}
|
|
177
181
|
const state = this._getOrCreateProviderState(name);
|
|
@@ -179,28 +183,62 @@ export class WsTunnel {
|
|
|
179
183
|
transport.onMessage = (data) => this._routeFromProvider(state, data);
|
|
180
184
|
transport.onClose = () => {
|
|
181
185
|
this._loopbackProviders.delete(name);
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
186
|
+
this._failProviderDisconnected(state, name);
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Opens an in-process client to a provider slot. The returned handle can
|
|
191
|
+
* issue MCP requests and receives both the responses and the provider's
|
|
192
|
+
* broadcast notifications. Used by the aggregate server to fan a single
|
|
193
|
+
* in-process client out to every aggregated provider.
|
|
194
|
+
*
|
|
195
|
+
* The slot does not need a provider attached yet — `send` returns a
|
|
196
|
+
* JSON-RPC error while the provider is disconnected.
|
|
197
|
+
*/
|
|
198
|
+
openInternalClient(providerName) {
|
|
199
|
+
const state = this._getOrCreateProviderState(providerName);
|
|
200
|
+
let closed = false;
|
|
201
|
+
const client = {
|
|
202
|
+
onMessage: null,
|
|
203
|
+
onClose: null,
|
|
204
|
+
send: (message) => {
|
|
205
|
+
if (closed)
|
|
206
|
+
return;
|
|
207
|
+
let id = null;
|
|
208
|
+
try {
|
|
209
|
+
const parsed = JSON.parse(message);
|
|
210
|
+
if (parsed?.id != null)
|
|
211
|
+
id = parsed.id;
|
|
191
212
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if (sseRes)
|
|
195
|
-
this._sendSseEvent(sseRes, error);
|
|
213
|
+
catch {
|
|
214
|
+
/* forward as-is */
|
|
196
215
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
216
|
+
if (this._isProviderConnected(providerName, state)) {
|
|
217
|
+
if (id != null)
|
|
218
|
+
state.pending.set(id, { type: "internal", client });
|
|
219
|
+
this._sendToProvider(state, providerName, message);
|
|
200
220
|
}
|
|
201
|
-
|
|
202
|
-
|
|
221
|
+
else if (id != null) {
|
|
222
|
+
client.onMessage?.(JSON.stringify({
|
|
223
|
+
jsonrpc: "2.0",
|
|
224
|
+
id,
|
|
225
|
+
error: { code: -32000, message: `Provider "${providerName}" not connected` },
|
|
226
|
+
}));
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
close: () => {
|
|
230
|
+
if (closed)
|
|
231
|
+
return;
|
|
232
|
+
closed = true;
|
|
233
|
+
state.internalClients.delete(client);
|
|
234
|
+
for (const [id, sink] of state.pending) {
|
|
235
|
+
if (sink.type === "internal" && sink.client === client)
|
|
236
|
+
state.pending.delete(id);
|
|
237
|
+
}
|
|
238
|
+
},
|
|
203
239
|
};
|
|
240
|
+
state.internalClients.add(client);
|
|
241
|
+
return client;
|
|
204
242
|
}
|
|
205
243
|
// -------------------------------------------------------------------------
|
|
206
244
|
// Public state
|
|
@@ -260,9 +298,13 @@ export class WsTunnel {
|
|
|
260
298
|
}
|
|
261
299
|
});
|
|
262
300
|
this._httpServer.listen(this._options.port, this._options.host ?? "0.0.0.0", () => {
|
|
263
|
-
//
|
|
264
|
-
|
|
265
|
-
|
|
301
|
+
// Bring the aggregate `_all` slot up before any upstream connects
|
|
302
|
+
// (a Streamable HTTP upstream opens synchronously on connect()).
|
|
303
|
+
this._maybeStartAggregateServer();
|
|
304
|
+
// Attach configured upstreams (stdio child processes + remote URL
|
|
305
|
+
// servers). Both implement the Upstream contract, so the wiring
|
|
306
|
+
// into a provider slot is identical.
|
|
307
|
+
const wireUpstream = (cfg, upstream) => {
|
|
266
308
|
upstream.onMessage = (data) => {
|
|
267
309
|
const state = this._getOrCreateProviderState(cfg.name);
|
|
268
310
|
this._routeFromProvider(state, data);
|
|
@@ -270,9 +312,21 @@ export class WsTunnel {
|
|
|
270
312
|
upstream.onError = (err) => {
|
|
271
313
|
console.error(`[broker] ${err.message}`);
|
|
272
314
|
};
|
|
273
|
-
|
|
315
|
+
upstream.onClose = () => {
|
|
316
|
+
const state = this._providers.get(cfg.name);
|
|
317
|
+
if (state)
|
|
318
|
+
this._failProviderDisconnected(state, cfg.name);
|
|
319
|
+
};
|
|
320
|
+
if (cfg.aggregate) {
|
|
321
|
+
upstream.onOpen = () => void this._aggregateServer?.addProvider(cfg.name);
|
|
322
|
+
}
|
|
323
|
+
this._upstreams.set(cfg.name, upstream);
|
|
274
324
|
upstream.connect();
|
|
275
|
-
}
|
|
325
|
+
};
|
|
326
|
+
for (const cfg of this._options.stdioUpstreams ?? [])
|
|
327
|
+
wireUpstream(cfg, new StdioUpstream(cfg));
|
|
328
|
+
for (const cfg of this._options.remoteUpstreams ?? [])
|
|
329
|
+
wireUpstream(cfg, new RemoteUpstream(cfg));
|
|
276
330
|
// Attach stdio client transport if configured.
|
|
277
331
|
// stdin carries Claude Desktop's JSON-RPC requests; stdout carries responses.
|
|
278
332
|
if (this._options.stdioClient) {
|
|
@@ -315,13 +369,32 @@ export class WsTunnel {
|
|
|
315
369
|
if (this._options.enableBrokerProvider === false)
|
|
316
370
|
return;
|
|
317
371
|
const { server, clientTransport } = await startBrokerServer(this, {
|
|
318
|
-
|
|
319
|
-
userAgentResolver: this._options.brokerUserAgentResolver,
|
|
320
|
-
localeSource: this._options.brokerLocaleSource,
|
|
372
|
+
grammarResolverOptions: this._options.brokerGrammarResolverOptions,
|
|
321
373
|
localGrammarsDir: this._options.brokerLocalGrammarsDir,
|
|
322
374
|
});
|
|
323
375
|
this._brokerServer = server;
|
|
324
376
|
this.registerLoopbackProvider(BROKER_PROVIDER_NAME, clientTransport);
|
|
377
|
+
// Aggregate the broker's own introspection tools into `_all`, so a stdio
|
|
378
|
+
// host pinned to `_all` still reaches broker_info / providers_list /
|
|
379
|
+
// provider_status alongside the other aggregated providers.
|
|
380
|
+
void this._aggregateServer?.addProvider(BROKER_PROVIDER_NAME);
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Starts the aggregate MCP server and registers it on the reserved `_all`
|
|
384
|
+
* slot. No-op when {@link WsTunnelOptions.enableAggregateProvider} is `false`.
|
|
385
|
+
*/
|
|
386
|
+
_maybeStartAggregateServer() {
|
|
387
|
+
if (this._options.enableAggregateProvider === false)
|
|
388
|
+
return;
|
|
389
|
+
try {
|
|
390
|
+
const server = new AggregateServer((providerName) => this.openInternalClient(providerName));
|
|
391
|
+
server.start();
|
|
392
|
+
this.registerLoopbackProvider(AggregateServer.SLOT, server);
|
|
393
|
+
this._aggregateServer = server;
|
|
394
|
+
}
|
|
395
|
+
catch (err) {
|
|
396
|
+
console.error(`[broker] aggregate server failed to start: ${err.message}`);
|
|
397
|
+
}
|
|
325
398
|
}
|
|
326
399
|
/**
|
|
327
400
|
* Gracefully closes all connections and stops the HTTP server.
|
|
@@ -339,6 +412,18 @@ export class WsTunnel {
|
|
|
339
412
|
/* best-effort; continue tearing down */
|
|
340
413
|
}
|
|
341
414
|
}
|
|
415
|
+
// Close the aggregate server so its provider sessions and internal
|
|
416
|
+
// clients detach before the provider slots are torn down.
|
|
417
|
+
const aggregateServer = this._aggregateServer;
|
|
418
|
+
this._aggregateServer = null;
|
|
419
|
+
if (aggregateServer) {
|
|
420
|
+
try {
|
|
421
|
+
aggregateServer.close();
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
/* best-effort; continue tearing down */
|
|
425
|
+
}
|
|
426
|
+
}
|
|
342
427
|
return new Promise((resolve, reject) => {
|
|
343
428
|
for (const state of this._providers.values()) {
|
|
344
429
|
for (const res of state.sseSessions.values())
|
|
@@ -354,9 +439,9 @@ export class WsTunnel {
|
|
|
354
439
|
}
|
|
355
440
|
this._providers.clear();
|
|
356
441
|
this._multiplexSockets.clear();
|
|
357
|
-
for (const upstream of this.
|
|
442
|
+
for (const upstream of this._upstreams.values())
|
|
358
443
|
upstream.close();
|
|
359
|
-
this.
|
|
444
|
+
this._upstreams.clear();
|
|
360
445
|
for (const loopback of this._loopbackProviders.values())
|
|
361
446
|
loopback.close();
|
|
362
447
|
this._loopbackProviders.clear();
|
|
@@ -585,7 +670,7 @@ export class WsTunnel {
|
|
|
585
670
|
// WebSocket connection handlers
|
|
586
671
|
// -------------------------------------------------------------------------
|
|
587
672
|
_onProviderConnect(ws, name) {
|
|
588
|
-
if (this.
|
|
673
|
+
if (this._upstreams.has(name)) {
|
|
589
674
|
console.warn(`[broker] WARNING: WebSocket provider "${name}" rejected — a stdio upstream with the same name is already configured. ` +
|
|
590
675
|
`Rename one of them to avoid the conflict.`);
|
|
591
676
|
ws.close(1008, `Provider "${name}" is managed by a stdio upstream`);
|
|
@@ -603,32 +688,47 @@ export class WsTunnel {
|
|
|
603
688
|
}
|
|
604
689
|
const state = this._getOrCreateProviderState(name);
|
|
605
690
|
state.ws = ws;
|
|
606
|
-
|
|
691
|
+
// A provider MAY send a registration control frame as its very first
|
|
692
|
+
// message (see _tryHandleRegistration). Any other first message —
|
|
693
|
+
// including a normal MCP frame — is routed and leaves the provider
|
|
694
|
+
// non-aggregated, so every pre-existing provider keeps working.
|
|
695
|
+
let registrationChecked = false;
|
|
696
|
+
ws.on("message", (data) => {
|
|
697
|
+
const text = data.toString();
|
|
698
|
+
if (!registrationChecked) {
|
|
699
|
+
registrationChecked = true;
|
|
700
|
+
if (this._tryHandleRegistration(name, text))
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
this._routeFromProvider(state, text);
|
|
704
|
+
});
|
|
607
705
|
ws.on("close", () => {
|
|
608
706
|
state.ws = null;
|
|
609
|
-
|
|
610
|
-
const error = JSON.stringify({
|
|
611
|
-
jsonrpc: "2.0",
|
|
612
|
-
id: null,
|
|
613
|
-
error: { code: -32000, message: `Provider "${name}" disconnected` },
|
|
614
|
-
});
|
|
615
|
-
for (const sink of state.pending.values()) {
|
|
616
|
-
if (sink.type === "ws" && sink.socket.readyState === WebSocket.OPEN) {
|
|
617
|
-
sink.socket.send(error);
|
|
618
|
-
}
|
|
619
|
-
else if (sink.type === "sse") {
|
|
620
|
-
const sseRes = state.sseSessions.get(sink.sessionId);
|
|
621
|
-
if (sseRes)
|
|
622
|
-
this._sendSseEvent(sseRes, error);
|
|
623
|
-
}
|
|
624
|
-
else if (sink.type === "http") {
|
|
625
|
-
sink.res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
626
|
-
sink.res.end(error);
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
state.pending.clear();
|
|
707
|
+
this._failProviderDisconnected(state, name);
|
|
630
708
|
});
|
|
631
709
|
}
|
|
710
|
+
/**
|
|
711
|
+
* Inspects a provider's first WebSocket message for an optional registration
|
|
712
|
+
* control frame `{ "type": "register", "aggregate": boolean }`. Returns
|
|
713
|
+
* `true` when the message was a registration frame — and thus consumed, not
|
|
714
|
+
* routed as MCP traffic. A normal MCP frame always carries `jsonrpc`, so it
|
|
715
|
+
* returns `false` and the provider stays non-aggregated.
|
|
716
|
+
*/
|
|
717
|
+
_tryHandleRegistration(name, text) {
|
|
718
|
+
let frame;
|
|
719
|
+
try {
|
|
720
|
+
frame = JSON.parse(text);
|
|
721
|
+
}
|
|
722
|
+
catch {
|
|
723
|
+
return false;
|
|
724
|
+
}
|
|
725
|
+
if (frame.jsonrpc !== undefined || frame.type !== "register")
|
|
726
|
+
return false;
|
|
727
|
+
if (frame.aggregate === true) {
|
|
728
|
+
void this._aggregateServer?.addProvider(name);
|
|
729
|
+
}
|
|
730
|
+
return true;
|
|
731
|
+
}
|
|
632
732
|
_onClientConnect(ws, providerName) {
|
|
633
733
|
const state = this._getOrCreateProviderState(providerName);
|
|
634
734
|
state.wsClients.add(ws);
|
|
@@ -663,7 +763,7 @@ export class WsTunnel {
|
|
|
663
763
|
return;
|
|
664
764
|
// Register provider name lazily on first encounter.
|
|
665
765
|
if (!providerNames.has(name)) {
|
|
666
|
-
if (this.
|
|
766
|
+
if (this._upstreams.has(name)) {
|
|
667
767
|
console.warn(`[broker] WARNING: Multiplexed WebSocket provider "${name}" rejected — a stdio upstream with the same name is already configured. ` +
|
|
668
768
|
`Rename one of them to avoid the conflict.`);
|
|
669
769
|
ws.send(JSON.stringify({
|
|
@@ -712,27 +812,7 @@ export class WsTunnel {
|
|
|
712
812
|
const state = this._providers.get(name);
|
|
713
813
|
if (state && state.ws === ws) {
|
|
714
814
|
state.ws = null;
|
|
715
|
-
|
|
716
|
-
const error = JSON.stringify({
|
|
717
|
-
jsonrpc: "2.0",
|
|
718
|
-
id: null,
|
|
719
|
-
error: { code: -32000, message: `Provider "${name}" disconnected` },
|
|
720
|
-
});
|
|
721
|
-
for (const sink of state.pending.values()) {
|
|
722
|
-
if (sink.type === "ws" && sink.socket.readyState === WebSocket.OPEN) {
|
|
723
|
-
sink.socket.send(error);
|
|
724
|
-
}
|
|
725
|
-
else if (sink.type === "sse") {
|
|
726
|
-
const sseRes = state.sseSessions.get(sink.sessionId);
|
|
727
|
-
if (sseRes)
|
|
728
|
-
this._sendSseEvent(sseRes, error);
|
|
729
|
-
}
|
|
730
|
-
else if (sink.type === "http") {
|
|
731
|
-
sink.res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
732
|
-
sink.res.end(error);
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
state.pending.clear();
|
|
815
|
+
this._failProviderDisconnected(state, name);
|
|
736
816
|
}
|
|
737
817
|
}
|
|
738
818
|
this._multiplexSockets.delete(ws);
|
|
@@ -746,10 +826,10 @@ export class WsTunnel {
|
|
|
746
826
|
* envelope when the provider's WebSocket is a multiplexed connection.
|
|
747
827
|
*/
|
|
748
828
|
_sendToProvider(state, providerName, data) {
|
|
749
|
-
//
|
|
750
|
-
const
|
|
751
|
-
if (
|
|
752
|
-
|
|
829
|
+
// Upstreams (stdio child processes and remote URL servers) take priority.
|
|
830
|
+
const upstream = this._upstreams.get(providerName);
|
|
831
|
+
if (upstream?.isOpen) {
|
|
832
|
+
upstream.send(data);
|
|
753
833
|
return;
|
|
754
834
|
}
|
|
755
835
|
// In-process loopback (e.g. the embedded `_broker`) takes the same priority.
|
|
@@ -839,6 +919,9 @@ export class WsTunnel {
|
|
|
839
919
|
else if (sink?.type === "stdio") {
|
|
840
920
|
process.stdout.write(data + "\n");
|
|
841
921
|
}
|
|
922
|
+
else if (sink?.type === "internal") {
|
|
923
|
+
sink.client.onMessage?.(data);
|
|
924
|
+
}
|
|
842
925
|
state.pending.delete(msg.id);
|
|
843
926
|
}
|
|
844
927
|
else {
|
|
@@ -862,11 +945,46 @@ export class WsTunnel {
|
|
|
862
945
|
for (const mcpRes of state.mcpGetSessions.values()) {
|
|
863
946
|
this._sendSseEvent(mcpRes, data);
|
|
864
947
|
}
|
|
948
|
+
for (const ic of state.internalClients) {
|
|
949
|
+
ic.onMessage?.(data);
|
|
950
|
+
}
|
|
865
951
|
// Forward notifications to the stdio client if it is watching this provider.
|
|
866
952
|
if (this._stdioClientProvider && this._providers.get(this._stdioClientProvider) === state) {
|
|
867
953
|
process.stdout.write(data + "\n");
|
|
868
954
|
}
|
|
869
955
|
}
|
|
956
|
+
/**
|
|
957
|
+
* Notifies every pending sink and internal client that the provider slot
|
|
958
|
+
* has disconnected, then clears the pending map. Shared by all provider
|
|
959
|
+
* close handlers (dedicated WS, multiplexed WS, loopback).
|
|
960
|
+
*/
|
|
961
|
+
_failProviderDisconnected(state, name) {
|
|
962
|
+
const error = JSON.stringify({
|
|
963
|
+
jsonrpc: "2.0",
|
|
964
|
+
id: null,
|
|
965
|
+
error: { code: -32000, message: `Provider "${name}" disconnected` },
|
|
966
|
+
});
|
|
967
|
+
for (const sink of state.pending.values()) {
|
|
968
|
+
if (sink.type === "ws" && sink.socket.readyState === WebSocket.OPEN) {
|
|
969
|
+
sink.socket.send(error);
|
|
970
|
+
}
|
|
971
|
+
else if (sink.type === "sse") {
|
|
972
|
+
const sseRes = state.sseSessions.get(sink.sessionId);
|
|
973
|
+
if (sseRes)
|
|
974
|
+
this._sendSseEvent(sseRes, error);
|
|
975
|
+
}
|
|
976
|
+
else if (sink.type === "http") {
|
|
977
|
+
sink.res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
978
|
+
sink.res.end(error);
|
|
979
|
+
}
|
|
980
|
+
else if (sink.type === "internal") {
|
|
981
|
+
sink.client.onMessage?.(error);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
state.pending.clear();
|
|
985
|
+
for (const ic of state.internalClients)
|
|
986
|
+
ic.onClose?.();
|
|
987
|
+
}
|
|
870
988
|
// -------------------------------------------------------------------------
|
|
871
989
|
// Provider state helpers
|
|
872
990
|
// -------------------------------------------------------------------------
|
|
@@ -875,7 +993,7 @@ export class WsTunnel {
|
|
|
875
993
|
* a stdio upstream, or an in-process loopback transport.
|
|
876
994
|
*/
|
|
877
995
|
_isProviderConnected(providerName, state) {
|
|
878
|
-
if (this.
|
|
996
|
+
if (this._upstreams.get(providerName)?.isOpen)
|
|
879
997
|
return true;
|
|
880
998
|
if (this._loopbackProviders.get(providerName)?.isOpen)
|
|
881
999
|
return true;
|
|
@@ -893,6 +1011,7 @@ export class WsTunnel {
|
|
|
893
1011
|
sseSessions: new Map(),
|
|
894
1012
|
mcpGetSessions: new Map(),
|
|
895
1013
|
wsClients: new Set(),
|
|
1014
|
+
internalClients: new Set(),
|
|
896
1015
|
};
|
|
897
1016
|
this._providers.set(name, state);
|
|
898
1017
|
}
|