@cyanmycelium/mcp-broker 0.2.0 → 0.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/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.server.js +12 -1
- package/dist/broker/broker.server.js.map +1 -1
- package/dist/config.d.ts +35 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- 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 +67 -2
- package/dist/ws.tunnel.js +200 -79
- 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.server.ts +12 -1
- package/src/config.ts +37 -0
- package/src/index.ts +8 -0
- 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 +239 -74
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal ZIP extractor for `.mcpb` bundles, built on `node:zlib` only.
|
|
3
|
+
*
|
|
4
|
+
* A `.mcpb` bundle is an ordinary ZIP archive. The broker deliberately avoids
|
|
5
|
+
* the `@anthropic-ai/mcpb` package so it stays compatible with the format
|
|
6
|
+
* without being coupled to Anthropic's tooling — hence this small reader.
|
|
7
|
+
*
|
|
8
|
+
* Supports the two compression methods used in practice: stored (0) and
|
|
9
|
+
* deflate (8). ZIP64 archives are rejected with a clear error (`.mcpb` bundles
|
|
10
|
+
* are small and never need it). Any trailing bytes after the End Of Central
|
|
11
|
+
* Directory record — e.g. a native PKCS#7 signature block — are ignored, since
|
|
12
|
+
* extraction is driven entirely by the central directory.
|
|
13
|
+
*/
|
|
14
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
16
|
+
import { inflateRawSync } from "node:zlib";
|
|
17
|
+
|
|
18
|
+
const EOCD_SIGNATURE = 0x06054b50;
|
|
19
|
+
const CENTRAL_HEADER_SIGNATURE = 0x02014b50;
|
|
20
|
+
const LOCAL_HEADER_SIGNATURE = 0x04034b50;
|
|
21
|
+
|
|
22
|
+
/** Locates the End Of Central Directory record by scanning backwards. */
|
|
23
|
+
function findEocd(buf: Buffer): number {
|
|
24
|
+
// The EOCD is 22 bytes plus a comment of up to 65535 bytes.
|
|
25
|
+
const minPos = Math.max(0, buf.length - 22 - 0xffff);
|
|
26
|
+
for (let pos = buf.length - 22; pos >= minPos; pos--) {
|
|
27
|
+
if (buf.readUInt32LE(pos) === EOCD_SIGNATURE) return pos;
|
|
28
|
+
}
|
|
29
|
+
throw new Error("not a ZIP archive (no End Of Central Directory record found)");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Extracts every entry of the `.mcpb` archive at `mcpbPath` into `destDir`.
|
|
34
|
+
* `destDir` is created if missing. Entries whose resolved path would escape
|
|
35
|
+
* `destDir` (zip-slip) are rejected.
|
|
36
|
+
*/
|
|
37
|
+
export function unzipMcpb(mcpbPath: string, destDir: string): void {
|
|
38
|
+
const buf = readFileSync(mcpbPath);
|
|
39
|
+
const eocd = findEocd(buf);
|
|
40
|
+
|
|
41
|
+
const totalEntries = buf.readUInt16LE(eocd + 10);
|
|
42
|
+
const centralDirOffset = buf.readUInt32LE(eocd + 16);
|
|
43
|
+
if (centralDirOffset === 0xffffffff || totalEntries === 0xffff) {
|
|
44
|
+
throw new Error("ZIP64 archives are not supported");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const absDest = resolve(destDir);
|
|
48
|
+
mkdirSync(absDest, { recursive: true });
|
|
49
|
+
|
|
50
|
+
let pos = centralDirOffset;
|
|
51
|
+
for (let i = 0; i < totalEntries; i++) {
|
|
52
|
+
if (buf.readUInt32LE(pos) !== CENTRAL_HEADER_SIGNATURE) {
|
|
53
|
+
throw new Error(`corrupt ZIP: bad central directory header at offset ${pos}`);
|
|
54
|
+
}
|
|
55
|
+
const method = buf.readUInt16LE(pos + 10);
|
|
56
|
+
const compressedSize = buf.readUInt32LE(pos + 20);
|
|
57
|
+
const uncompressedSize = buf.readUInt32LE(pos + 24);
|
|
58
|
+
const nameLen = buf.readUInt16LE(pos + 28);
|
|
59
|
+
const extraLen = buf.readUInt16LE(pos + 30);
|
|
60
|
+
const commentLen = buf.readUInt16LE(pos + 32);
|
|
61
|
+
const localHeaderOffset = buf.readUInt32LE(pos + 42);
|
|
62
|
+
const name = buf.toString("utf8", pos + 46, pos + 46 + nameLen);
|
|
63
|
+
pos += 46 + nameLen + extraLen + commentLen;
|
|
64
|
+
|
|
65
|
+
if (compressedSize === 0xffffffff || uncompressedSize === 0xffffffff || localHeaderOffset === 0xffffffff) {
|
|
66
|
+
throw new Error("ZIP64 archives are not supported");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Resolve the destination path and reject zip-slip escapes.
|
|
70
|
+
const target = resolve(absDest, name);
|
|
71
|
+
const rel = relative(absDest, target);
|
|
72
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
73
|
+
throw new Error(`unsafe ZIP entry path (zip-slip): ${name}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Directory entry.
|
|
77
|
+
if (name.endsWith("/")) {
|
|
78
|
+
mkdirSync(target, { recursive: true });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Parse the local header to locate the entry's data.
|
|
83
|
+
if (buf.readUInt32LE(localHeaderOffset) !== LOCAL_HEADER_SIGNATURE) {
|
|
84
|
+
throw new Error(`corrupt ZIP: bad local header for "${name}"`);
|
|
85
|
+
}
|
|
86
|
+
const localNameLen = buf.readUInt16LE(localHeaderOffset + 26);
|
|
87
|
+
const localExtraLen = buf.readUInt16LE(localHeaderOffset + 28);
|
|
88
|
+
const dataStart = localHeaderOffset + 30 + localNameLen + localExtraLen;
|
|
89
|
+
const compressed = buf.subarray(dataStart, dataStart + compressedSize);
|
|
90
|
+
|
|
91
|
+
let data: Buffer;
|
|
92
|
+
if (method === 0) {
|
|
93
|
+
data = compressed;
|
|
94
|
+
} else if (method === 8) {
|
|
95
|
+
data = inflateRawSync(compressed);
|
|
96
|
+
} else {
|
|
97
|
+
throw new Error(`unsupported ZIP compression method ${method} for "${name}"`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
101
|
+
writeFileSync(target, data);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -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,
|