@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
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand-rolled client transports for reaching a remote MCP server by URL.
|
|
3
|
+
*
|
|
4
|
+
* No `@modelcontextprotocol/sdk` dependency: the broker stays SDK-free, the
|
|
5
|
+
* official SDK is only used outside the broker (in the demo) to prove the
|
|
6
|
+
* broker is implementation-agnostic. Each transport is a thin frame relay —
|
|
7
|
+
* it does not interpret MCP, it pipes JSON-RPC strings both ways.
|
|
8
|
+
*/
|
|
9
|
+
import * as http from "node:http";
|
|
10
|
+
import * as https from "node:https";
|
|
11
|
+
import type { ClientRequest, IncomingMessage, RequestOptions } from "node:http";
|
|
12
|
+
import { WebSocket } from "ws";
|
|
13
|
+
|
|
14
|
+
/** Lifecycle + callbacks shared by every remote client transport. */
|
|
15
|
+
export interface RemoteTransport {
|
|
16
|
+
onMessage: ((data: string) => void) | null;
|
|
17
|
+
onOpen: (() => void) | null;
|
|
18
|
+
onClose: (() => void) | null;
|
|
19
|
+
onError: ((error: Error) => void) | null;
|
|
20
|
+
connect(): void;
|
|
21
|
+
send(data: string): void;
|
|
22
|
+
close(): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The three supported remote transports. */
|
|
26
|
+
export type RemoteTransportKind = "streamable-http" | "sse" | "websocket";
|
|
27
|
+
|
|
28
|
+
/** Issues an http/https request, picking the module from the URL scheme. */
|
|
29
|
+
function makeRequest(url: URL, options: RequestOptions, onResponse: (res: IncomingMessage) => void): ClientRequest {
|
|
30
|
+
return url.protocol === "https:" ? https.request(url, options, onResponse) : http.request(url, options, onResponse);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Incremental Server-Sent-Events decoder. Normalizes line endings, buffers
|
|
35
|
+
* across chunks, and emits one `(event, data)` pair per complete SSE block.
|
|
36
|
+
*/
|
|
37
|
+
class SseDecoder {
|
|
38
|
+
private _buf = "";
|
|
39
|
+
|
|
40
|
+
feed(chunk: string, emit: (event: string, data: string) => void): void {
|
|
41
|
+
this._buf += chunk.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
42
|
+
let sep: number;
|
|
43
|
+
while ((sep = this._buf.indexOf("\n\n")) !== -1) {
|
|
44
|
+
const block = this._buf.slice(0, sep);
|
|
45
|
+
this._buf = this._buf.slice(sep + 2);
|
|
46
|
+
let event = "message";
|
|
47
|
+
const data: string[] = [];
|
|
48
|
+
for (const line of block.split("\n")) {
|
|
49
|
+
if (line.startsWith(":")) continue; // comment
|
|
50
|
+
const colon = line.indexOf(":");
|
|
51
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
52
|
+
let value = colon === -1 ? "" : line.slice(colon + 1);
|
|
53
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
54
|
+
if (field === "event") event = value;
|
|
55
|
+
else if (field === "data") data.push(value);
|
|
56
|
+
}
|
|
57
|
+
if (data.length > 0) emit(event, data.join("\n"));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// WebSocket
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
class WebSocketRemoteTransport implements RemoteTransport {
|
|
67
|
+
onMessage: ((data: string) => void) | null = null;
|
|
68
|
+
onOpen: (() => void) | null = null;
|
|
69
|
+
onClose: (() => void) | null = null;
|
|
70
|
+
onError: ((error: Error) => void) | null = null;
|
|
71
|
+
|
|
72
|
+
private _ws: WebSocket | null = null;
|
|
73
|
+
|
|
74
|
+
constructor(
|
|
75
|
+
private readonly _url: string,
|
|
76
|
+
private readonly _headers: Record<string, string>
|
|
77
|
+
) {}
|
|
78
|
+
|
|
79
|
+
connect(): void {
|
|
80
|
+
const ws = new WebSocket(this._url, { headers: this._headers });
|
|
81
|
+
this._ws = ws;
|
|
82
|
+
ws.on("open", () => this.onOpen?.());
|
|
83
|
+
ws.on("message", (data: Buffer) => this.onMessage?.(data.toString("utf8")));
|
|
84
|
+
ws.on("close", () => this.onClose?.());
|
|
85
|
+
ws.on("error", (err: Error) => this.onError?.(err));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
send(data: string): void {
|
|
89
|
+
if (this._ws && this._ws.readyState === WebSocket.OPEN) this._ws.send(data);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
close(): void {
|
|
93
|
+
this._ws?.close();
|
|
94
|
+
this._ws = null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Legacy MCP SSE (GET event stream + POST message endpoint)
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
class SseRemoteTransport implements RemoteTransport {
|
|
103
|
+
onMessage: ((data: string) => void) | null = null;
|
|
104
|
+
onOpen: (() => void) | null = null;
|
|
105
|
+
onClose: (() => void) | null = null;
|
|
106
|
+
onError: ((error: Error) => void) | null = null;
|
|
107
|
+
|
|
108
|
+
private readonly _decoder = new SseDecoder();
|
|
109
|
+
private _streamReq: ClientRequest | null = null;
|
|
110
|
+
private _postUrl: string | null = null;
|
|
111
|
+
private _closed = false;
|
|
112
|
+
|
|
113
|
+
constructor(
|
|
114
|
+
private readonly _url: string,
|
|
115
|
+
private readonly _headers: Record<string, string>
|
|
116
|
+
) {}
|
|
117
|
+
|
|
118
|
+
connect(): void {
|
|
119
|
+
const req = makeRequest(new URL(this._url), { method: "GET", headers: { ...this._headers, Accept: "text/event-stream" } }, (res) => {
|
|
120
|
+
const status = res.statusCode ?? 0;
|
|
121
|
+
if (status >= 400) {
|
|
122
|
+
this.onError?.(new Error(`SSE GET responded ${status}`));
|
|
123
|
+
res.resume();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
res.setEncoding("utf8");
|
|
127
|
+
res.on("data", (chunk: string) => this._decoder.feed(chunk, (ev, data) => this._onEvent(ev, data)));
|
|
128
|
+
res.on("end", () => {
|
|
129
|
+
if (!this._closed) this.onClose?.();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
this._streamReq = req;
|
|
133
|
+
req.on("error", (err: Error) => {
|
|
134
|
+
if (!this._closed) this.onError?.(err);
|
|
135
|
+
});
|
|
136
|
+
req.end();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private _onEvent(event: string, data: string): void {
|
|
140
|
+
if (event === "endpoint") {
|
|
141
|
+
this._postUrl = new URL(data, this._url).toString();
|
|
142
|
+
this.onOpen?.();
|
|
143
|
+
} else if (event === "message") {
|
|
144
|
+
this.onMessage?.(data);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
send(data: string): void {
|
|
149
|
+
if (!this._postUrl) return;
|
|
150
|
+
const req = makeRequest(new URL(this._postUrl), { method: "POST", headers: { ...this._headers, "Content-Type": "application/json" } }, (res) => res.resume());
|
|
151
|
+
req.on("error", (err: Error) => this.onError?.(err));
|
|
152
|
+
req.end(data);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
close(): void {
|
|
156
|
+
this._closed = true;
|
|
157
|
+
this._streamReq?.destroy();
|
|
158
|
+
this._streamReq = null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Streamable HTTP (MCP 2025-03-26)
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
class StreamableHttpRemoteTransport implements RemoteTransport {
|
|
167
|
+
onMessage: ((data: string) => void) | null = null;
|
|
168
|
+
onOpen: (() => void) | null = null;
|
|
169
|
+
onClose: (() => void) | null = null;
|
|
170
|
+
onError: ((error: Error) => void) | null = null;
|
|
171
|
+
|
|
172
|
+
private _sessionId: string | null = null;
|
|
173
|
+
private _streamReq: ClientRequest | null = null;
|
|
174
|
+
private _closed = false;
|
|
175
|
+
|
|
176
|
+
constructor(
|
|
177
|
+
private readonly _url: string,
|
|
178
|
+
private readonly _headers: Record<string, string>
|
|
179
|
+
) {}
|
|
180
|
+
|
|
181
|
+
connect(): void {
|
|
182
|
+
// Streamable HTTP is stateless — there is no connection to open. The
|
|
183
|
+
// transport is usable as soon as connect() is called; the session id
|
|
184
|
+
// is captured later from the first response that carries one.
|
|
185
|
+
this._closed = false;
|
|
186
|
+
this.onOpen?.();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
send(data: string): void {
|
|
190
|
+
if (this._closed) return;
|
|
191
|
+
const headers: Record<string, string> = {
|
|
192
|
+
...this._headers,
|
|
193
|
+
"Content-Type": "application/json",
|
|
194
|
+
Accept: "application/json, text/event-stream",
|
|
195
|
+
};
|
|
196
|
+
if (this._sessionId) headers["Mcp-Session-Id"] = this._sessionId;
|
|
197
|
+
const req = makeRequest(new URL(this._url), { method: "POST", headers }, (res) => this._handleResponse(res));
|
|
198
|
+
req.on("error", (err: Error) => {
|
|
199
|
+
if (!this._closed) this.onError?.(err);
|
|
200
|
+
});
|
|
201
|
+
req.end(data);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private _handleResponse(res: IncomingMessage): void {
|
|
205
|
+
const sid = res.headers["mcp-session-id"];
|
|
206
|
+
if (typeof sid === "string" && sid.length > 0 && sid !== this._sessionId) {
|
|
207
|
+
this._sessionId = sid;
|
|
208
|
+
this._openServerStream();
|
|
209
|
+
}
|
|
210
|
+
const status = res.statusCode ?? 0;
|
|
211
|
+
if (status === 202) {
|
|
212
|
+
res.resume();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (status >= 400) {
|
|
216
|
+
this.onError?.(new Error(`Streamable HTTP POST responded ${status}`));
|
|
217
|
+
res.resume();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
res.setEncoding("utf8");
|
|
221
|
+
if (String(res.headers["content-type"] ?? "").includes("text/event-stream")) {
|
|
222
|
+
const decoder = new SseDecoder();
|
|
223
|
+
res.on("data", (chunk: string) =>
|
|
224
|
+
decoder.feed(chunk, (ev, data) => {
|
|
225
|
+
if (ev === "message") this.onMessage?.(data);
|
|
226
|
+
})
|
|
227
|
+
);
|
|
228
|
+
} else {
|
|
229
|
+
let body = "";
|
|
230
|
+
res.on("data", (chunk: string) => {
|
|
231
|
+
body += chunk;
|
|
232
|
+
});
|
|
233
|
+
res.on("end", () => this._emitJsonBody(body));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private _emitJsonBody(body: string): void {
|
|
238
|
+
const trimmed = body.trim();
|
|
239
|
+
if (!trimmed) return;
|
|
240
|
+
try {
|
|
241
|
+
const parsed: unknown = JSON.parse(trimmed);
|
|
242
|
+
if (Array.isArray(parsed)) {
|
|
243
|
+
for (const message of parsed) this.onMessage?.(JSON.stringify(message));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
} catch {
|
|
247
|
+
/* not JSON — forward verbatim */
|
|
248
|
+
}
|
|
249
|
+
this.onMessage?.(trimmed);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Opens the optional standalone GET stream for server-initiated messages. */
|
|
253
|
+
private _openServerStream(): void {
|
|
254
|
+
if (this._closed || this._streamReq) return;
|
|
255
|
+
const headers: Record<string, string> = { ...this._headers, Accept: "text/event-stream" };
|
|
256
|
+
if (this._sessionId) headers["Mcp-Session-Id"] = this._sessionId;
|
|
257
|
+
const req = makeRequest(new URL(this._url), { method: "GET", headers }, (res) => {
|
|
258
|
+
if ((res.statusCode ?? 0) >= 400) {
|
|
259
|
+
// The server may not support the standalone GET stream (405). Fine.
|
|
260
|
+
res.resume();
|
|
261
|
+
this._streamReq = null;
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const decoder = new SseDecoder();
|
|
265
|
+
res.setEncoding("utf8");
|
|
266
|
+
res.on("data", (chunk: string) =>
|
|
267
|
+
decoder.feed(chunk, (ev, data) => {
|
|
268
|
+
if (ev === "message") this.onMessage?.(data);
|
|
269
|
+
})
|
|
270
|
+
);
|
|
271
|
+
res.on("end", () => {
|
|
272
|
+
this._streamReq = null;
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
this._streamReq = req;
|
|
276
|
+
req.on("error", () => {
|
|
277
|
+
this._streamReq = null;
|
|
278
|
+
});
|
|
279
|
+
req.end();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
close(): void {
|
|
283
|
+
this._closed = true;
|
|
284
|
+
this._streamReq?.destroy();
|
|
285
|
+
this._streamReq = null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// Factory
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
/** Builds the transport of the given kind. */
|
|
294
|
+
export function createRemoteTransport(url: string, kind: RemoteTransportKind, headers: Record<string, string>): RemoteTransport {
|
|
295
|
+
switch (kind) {
|
|
296
|
+
case "websocket":
|
|
297
|
+
return new WebSocketRemoteTransport(url, headers);
|
|
298
|
+
case "sse":
|
|
299
|
+
return new SseRemoteTransport(url, headers);
|
|
300
|
+
case "streamable-http":
|
|
301
|
+
return new StreamableHttpRemoteTransport(url, headers);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Heuristic transport detection from the URL when none is configured. */
|
|
306
|
+
export function detectTransport(url: string): RemoteTransportKind {
|
|
307
|
+
let parsed: URL;
|
|
308
|
+
try {
|
|
309
|
+
parsed = new URL(url);
|
|
310
|
+
} catch {
|
|
311
|
+
return "streamable-http";
|
|
312
|
+
}
|
|
313
|
+
if (parsed.protocol === "ws:" || parsed.protocol === "wss:") return "websocket";
|
|
314
|
+
if (parsed.pathname.replace(/\/+$/, "").endsWith("/sse")) return "sse";
|
|
315
|
+
return "streamable-http";
|
|
316
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Upstream } from "./upstream.js";
|
|
2
|
+
import { createRemoteTransport, detectTransport, type RemoteTransport, type RemoteTransportKind } from "./remote.transports.js";
|
|
3
|
+
|
|
4
|
+
export interface RemoteUpstreamConfig {
|
|
5
|
+
/** Provider slot name this upstream is bound to. */
|
|
6
|
+
name: string;
|
|
7
|
+
/** URL of the remote MCP server. */
|
|
8
|
+
url: string;
|
|
9
|
+
/** Transport to use. Auto-detected from the URL scheme/path when omitted. */
|
|
10
|
+
transport?: RemoteTransportKind;
|
|
11
|
+
/** Extra HTTP / WebSocket headers (e.g. an `Authorization` header). */
|
|
12
|
+
headers?: Record<string, string>;
|
|
13
|
+
/** When `true`, this upstream joins the `_all` aggregate slot once connected. */
|
|
14
|
+
aggregate?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Bridges a remote MCP server (reachable by URL) into a broker provider slot.
|
|
19
|
+
*
|
|
20
|
+
* The broker-facing contract is identical to {@link StdioUpstream}: the broker
|
|
21
|
+
* muxes the slot's clients onto this single upstream. The only difference is
|
|
22
|
+
* the transport — Streamable HTTP / SSE / WebSocket instead of a child process.
|
|
23
|
+
*/
|
|
24
|
+
export class RemoteUpstream implements Upstream {
|
|
25
|
+
readonly name: string;
|
|
26
|
+
|
|
27
|
+
onMessage: ((data: string) => void) | null = null;
|
|
28
|
+
onOpen: (() => void) | null = null;
|
|
29
|
+
onClose: (() => void) | null = null;
|
|
30
|
+
onError: ((error: Error) => void) | null = null;
|
|
31
|
+
|
|
32
|
+
private readonly _config: RemoteUpstreamConfig;
|
|
33
|
+
private _transport: RemoteTransport | null = null;
|
|
34
|
+
private _open = false;
|
|
35
|
+
|
|
36
|
+
constructor(config: RemoteUpstreamConfig) {
|
|
37
|
+
this.name = config.name;
|
|
38
|
+
this._config = config;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get isOpen(): boolean {
|
|
42
|
+
return this._open;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
connect(): void {
|
|
46
|
+
const kind = this._config.transport ?? detectTransport(this._config.url);
|
|
47
|
+
const transport = createRemoteTransport(this._config.url, kind, this._config.headers ?? {});
|
|
48
|
+
this._transport = transport;
|
|
49
|
+
|
|
50
|
+
transport.onOpen = (): void => {
|
|
51
|
+
this._open = true;
|
|
52
|
+
this.onOpen?.();
|
|
53
|
+
};
|
|
54
|
+
transport.onMessage = (data: string): void => this.onMessage?.(data);
|
|
55
|
+
transport.onClose = (): void => {
|
|
56
|
+
this._open = false;
|
|
57
|
+
this.onClose?.();
|
|
58
|
+
};
|
|
59
|
+
transport.onError = (err: Error): void => {
|
|
60
|
+
this.onError?.(new Error(`RemoteUpstream "${this.name}": ${err.message}`));
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
transport.connect();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
send(data: string): void {
|
|
67
|
+
this._transport?.send(data);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
close(): void {
|
|
71
|
+
this._open = false;
|
|
72
|
+
this._transport?.close();
|
|
73
|
+
this._transport = null;
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/stdio.upstream.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn, ChildProcess } from "node:child_process";
|
|
2
|
+
import type { Upstream } from "./upstream.js";
|
|
2
3
|
|
|
3
4
|
// ---------------------------------------------------------------------------
|
|
4
5
|
// Configuration
|
|
@@ -13,6 +14,8 @@ export interface StdioUpstreamConfig {
|
|
|
13
14
|
args?: string[];
|
|
14
15
|
/** Extra environment variables merged with `process.env`. */
|
|
15
16
|
env?: NodeJS.ProcessEnv;
|
|
17
|
+
/** When `true`, this upstream joins the `_all` aggregate slot once connected. */
|
|
18
|
+
aggregate?: boolean;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
// ---------------------------------------------------------------------------
|
|
@@ -27,7 +30,7 @@ export interface StdioUpstreamConfig {
|
|
|
27
30
|
* One instance per configured provider. The broker uses this to bridge
|
|
28
31
|
* WebSocket/SSE/HTTP clients to local MCP server processes.
|
|
29
32
|
*/
|
|
30
|
-
export class StdioUpstream {
|
|
33
|
+
export class StdioUpstream implements Upstream {
|
|
31
34
|
readonly name: string;
|
|
32
35
|
|
|
33
36
|
private readonly _config: StdioUpstreamConfig;
|
package/src/upstream.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Common contract for an upstream MCP server bound to a provider slot.
|
|
3
|
+
*
|
|
4
|
+
* The broker treats every upstream the same way: it muxes the slot's clients
|
|
5
|
+
* onto the single upstream connection, regardless of the underlying transport.
|
|
6
|
+
*
|
|
7
|
+
* Implementations:
|
|
8
|
+
* - `StdioUpstream` — a local child process (newline-delimited JSON-RPC over stdio).
|
|
9
|
+
* - `RemoteUpstream` — a remote MCP server reached by URL (Streamable HTTP / SSE / WebSocket).
|
|
10
|
+
*
|
|
11
|
+
* `StdioUpstream` is reused as-is by the future `.mcpb` bundle loader: a bundle
|
|
12
|
+
* is a local server whose `mcp_config` maps directly onto a stdio upstream.
|
|
13
|
+
*/
|
|
14
|
+
export interface Upstream {
|
|
15
|
+
/** Provider slot name this upstream is bound to. */
|
|
16
|
+
readonly name: string;
|
|
17
|
+
/** Whether the upstream connection is currently usable. */
|
|
18
|
+
readonly isOpen: boolean;
|
|
19
|
+
/** Receives one complete JSON-RPC message from the upstream server. */
|
|
20
|
+
onMessage: ((data: string) => void) | null;
|
|
21
|
+
/** Fires once the upstream connection is established and ready to send. */
|
|
22
|
+
onOpen: (() => void) | null;
|
|
23
|
+
/** Fires when the upstream connection closes. */
|
|
24
|
+
onClose: (() => void) | null;
|
|
25
|
+
/** Fires on a connection or runtime error. */
|
|
26
|
+
onError: ((error: Error) => void) | null;
|
|
27
|
+
/** Opens the upstream connection. */
|
|
28
|
+
connect(): void;
|
|
29
|
+
/** Sends one JSON-RPC message to the upstream server. */
|
|
30
|
+
send(data: string): void;
|
|
31
|
+
/** Closes the upstream connection. */
|
|
32
|
+
close(): void;
|
|
33
|
+
}
|
package/src/ws.tunnel.builder.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import { WsTunnel, type WsTunnelOptions, type StaticMount } from "./ws.tunnel.js";
|
|
3
3
|
import type { StdioUpstreamConfig } from "./stdio.upstream.js";
|
|
4
|
+
import type { RemoteUpstreamConfig } from "./remote.upstream.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Fluent builder that constructs a configured {@link WsTunnel}.
|
|
@@ -31,6 +32,7 @@ export class WsTunnelBuilder {
|
|
|
31
32
|
private _samplesIndexPath = "/__samples_index__";
|
|
32
33
|
private _staticMounts: StaticMount[] = [];
|
|
33
34
|
private _stdioUpstreams: StdioUpstreamConfig[] = [];
|
|
35
|
+
private _remoteUpstreams: RemoteUpstreamConfig[] = [];
|
|
34
36
|
private _stdioClient: { providerName: string } | undefined = undefined;
|
|
35
37
|
private _tls: { cert: string; key: string } | undefined = undefined;
|
|
36
38
|
private _brokerLocalGrammarsDir: string | undefined = undefined;
|
|
@@ -129,19 +131,26 @@ export class WsTunnelBuilder {
|
|
|
129
131
|
}
|
|
130
132
|
|
|
131
133
|
/**
|
|
132
|
-
* Registers a stdio upstream provider. The broker
|
|
133
|
-
* command and
|
|
134
|
-
*
|
|
134
|
+
* Registers a stdio upstream provider. The broker spawns the configured
|
|
135
|
+
* command and bridges its stdin/stdout as an MCP transport. Clients reach
|
|
136
|
+
* it using `config.name` directly (e.g. `/<name>/mcp`).
|
|
135
137
|
*
|
|
136
138
|
* Can be called multiple times to register multiple providers.
|
|
139
|
+
*/
|
|
140
|
+
withStdioUpstream(config: StdioUpstreamConfig): this {
|
|
141
|
+
this._stdioUpstreams.push(config);
|
|
142
|
+
return this;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Registers a remote MCP server reached by URL. The broker connects out to
|
|
147
|
+
* it and exposes it as a provider slot named `config.name`, bridging the
|
|
148
|
+
* Streamable HTTP / SSE / WebSocket transport for the slot's clients.
|
|
137
149
|
*
|
|
138
|
-
*
|
|
139
|
-
* @param command Executable to spawn.
|
|
140
|
-
* @param args Arguments passed to the command.
|
|
141
|
-
* @param env Extra environment variables merged with `process.env`.
|
|
150
|
+
* Can be called multiple times to register multiple servers.
|
|
142
151
|
*/
|
|
143
|
-
|
|
144
|
-
this.
|
|
152
|
+
withRemoteUpstream(config: RemoteUpstreamConfig): this {
|
|
153
|
+
this._remoteUpstreams.push(config);
|
|
145
154
|
return this;
|
|
146
155
|
}
|
|
147
156
|
|
|
@@ -205,6 +214,7 @@ export class WsTunnelBuilder {
|
|
|
205
214
|
samplesIndexPath: this._samplesIndexPath,
|
|
206
215
|
staticMounts: this._staticMounts.length > 0 ? [...this._staticMounts] : undefined,
|
|
207
216
|
stdioUpstreams: this._stdioUpstreams.length > 0 ? [...this._stdioUpstreams] : undefined,
|
|
217
|
+
remoteUpstreams: this._remoteUpstreams.length > 0 ? [...this._remoteUpstreams] : undefined,
|
|
208
218
|
stdioClient: this._stdioClient,
|
|
209
219
|
tls: this._tls,
|
|
210
220
|
brokerLocalGrammarsDir: this._brokerLocalGrammarsDir,
|