@silver886/mcp-proxy 0.1.3 → 0.2.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/README.md +62 -17
- package/dist/host/agent.d.ts +22 -0
- package/dist/host/agent.js +314 -0
- package/dist/host/cli.d.ts +1 -0
- package/dist/host/cli.js +83 -0
- package/dist/host/constants.d.ts +4 -0
- package/dist/host/constants.js +16 -0
- package/dist/host/session.d.ts +21 -0
- package/dist/host/session.js +204 -0
- package/dist/host/tunnel.d.ts +5 -0
- package/dist/host/tunnel.js +82 -0
- package/dist/host.js +8 -0
- package/dist/proxy/core/constants.d.ts +13 -0
- package/dist/proxy/core/constants.js +39 -0
- package/dist/proxy/core/fetch-timeout.d.ts +1 -0
- package/dist/proxy/core/fetch-timeout.js +15 -0
- package/dist/proxy/core/state.d.ts +25 -0
- package/dist/proxy/core/state.js +90 -0
- package/dist/proxy/core/types.d.ts +57 -0
- package/dist/proxy/core/types.js +5 -0
- package/dist/proxy/discovery/client.d.ts +42 -0
- package/dist/proxy/discovery/client.js +283 -0
- package/dist/proxy/discovery/runner.d.ts +21 -0
- package/dist/proxy/discovery/runner.js +319 -0
- package/dist/proxy/pairing/config.d.ts +9 -0
- package/dist/proxy/pairing/config.js +130 -0
- package/dist/proxy/pairing/controller.d.ts +19 -0
- package/dist/proxy/pairing/controller.js +327 -0
- package/dist/proxy/pairing/http.d.ts +70 -0
- package/dist/proxy/pairing/http.js +155 -0
- package/dist/proxy/pairing/static-assets.d.ts +4 -0
- package/dist/proxy/pairing/static-assets.js +13 -0
- package/dist/proxy/pairing/tunnel.d.ts +13 -0
- package/dist/proxy/pairing/tunnel.js +130 -0
- package/dist/proxy/pairing/validation.d.ts +2 -0
- package/dist/proxy/pairing/validation.js +62 -0
- package/dist/proxy/routing/filtering.d.ts +13 -0
- package/dist/proxy/routing/filtering.js +116 -0
- package/dist/proxy/routing/router.d.ts +17 -0
- package/dist/proxy/routing/router.js +74 -0
- package/dist/proxy/routing/uri.d.ts +7 -0
- package/dist/proxy/routing/uri.js +39 -0
- package/dist/proxy/runtime/forwarder.d.ts +15 -0
- package/dist/proxy/runtime/forwarder.js +265 -0
- package/dist/proxy/runtime/handlers.d.ts +48 -0
- package/dist/proxy/runtime/handlers.js +329 -0
- package/dist/proxy/runtime/sse.d.ts +19 -0
- package/dist/proxy/runtime/sse.js +169 -0
- package/dist/proxy/runtime/upstream-bridge.d.ts +27 -0
- package/dist/proxy/runtime/upstream-bridge.js +133 -0
- package/dist/proxy/server.d.ts +15 -0
- package/dist/proxy/server.js +167 -0
- package/dist/proxy.js +5 -0
- package/{mcp/dist → dist}/shared/protocol.d.ts +15 -3
- package/dist/shared/protocol.js +183 -0
- package/dist/wrapper.d.ts +2 -0
- package/dist/wrapper.js +72 -0
- package/package.json +15 -7
- package/static/setup.css +233 -0
- package/static/setup.html +57 -0
- package/static/setup.js +711 -0
- package/static/style.css +208 -0
- package/mcp/dist/host.js +0 -307
- package/mcp/dist/proxy.js +0 -374
- package/mcp/dist/shared/generated.d.ts +0 -2
- package/mcp/dist/shared/generated.js +0 -5
- package/mcp/dist/shared/protocol.js +0 -79
- /package/{mcp/dist → dist}/host.d.ts +0 -0
- /package/{mcp/dist → dist}/proxy.d.ts +0 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PairingHttpServer = void 0;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
const protocol_js_1 = require("../../shared/protocol.js");
|
|
6
|
+
const static_assets_js_1 = require("./static-assets.js");
|
|
7
|
+
class PairingHttpServer {
|
|
8
|
+
bearer;
|
|
9
|
+
handlers;
|
|
10
|
+
server = null;
|
|
11
|
+
bearerExpected;
|
|
12
|
+
constructor(bearer, handlers) {
|
|
13
|
+
this.bearer = bearer;
|
|
14
|
+
this.handlers = handlers;
|
|
15
|
+
this.bearerExpected = Buffer.from(`Bearer ${bearer}`);
|
|
16
|
+
}
|
|
17
|
+
listen() {
|
|
18
|
+
return new Promise((resolveP, rejectP) => {
|
|
19
|
+
const srv = (0, protocol_js_1.createServer)((req, res) => this.handle(req, res));
|
|
20
|
+
srv.once("error", rejectP);
|
|
21
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
22
|
+
const addr = srv.address();
|
|
23
|
+
if (typeof addr !== "object" || !addr) {
|
|
24
|
+
rejectP(new Error("Could not bind pairing HTTP server"));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
this.server = srv;
|
|
28
|
+
resolveP(addr.port);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
close() {
|
|
33
|
+
if (!this.server)
|
|
34
|
+
return;
|
|
35
|
+
this.server.close();
|
|
36
|
+
this.server = null;
|
|
37
|
+
}
|
|
38
|
+
authorized(req) {
|
|
39
|
+
const got = Buffer.from(req.headers.authorization ?? "");
|
|
40
|
+
if (got.length !== this.bearerExpected.length)
|
|
41
|
+
return false;
|
|
42
|
+
return (0, node_crypto_1.timingSafeEqual)(got, this.bearerExpected);
|
|
43
|
+
}
|
|
44
|
+
sendJson(res, status, body) {
|
|
45
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
46
|
+
res.end(typeof body === "string" ? body : JSON.stringify(body));
|
|
47
|
+
}
|
|
48
|
+
sendStatic(res, contentType, body) {
|
|
49
|
+
res.writeHead(200, {
|
|
50
|
+
"Content-Type": contentType,
|
|
51
|
+
"Cache-Control": "no-store",
|
|
52
|
+
"Content-Length": String(body.length),
|
|
53
|
+
});
|
|
54
|
+
res.end(body);
|
|
55
|
+
}
|
|
56
|
+
// Read the request body and JSON.parse it. On failure, write a 400 to the
|
|
57
|
+
// response and return null so the handler can early-return without a
|
|
58
|
+
// separate try/catch ladder. readBody itself is awaited outside so a
|
|
59
|
+
// BodyTooLargeError propagates up to createServer (→ 413) instead of
|
|
60
|
+
// being misreported as "Invalid JSON".
|
|
61
|
+
async readJson(req, res) {
|
|
62
|
+
const raw = await (0, protocol_js_1.readBody)(req);
|
|
63
|
+
try {
|
|
64
|
+
return JSON.parse(raw);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
this.sendJson(res, 400, { error: "Invalid JSON" });
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async handle(req, res) {
|
|
72
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
73
|
+
// Static routes are public — they're the setup page itself, served on
|
|
74
|
+
// the same origin as the API so no CORS is involved. The bearer gate
|
|
75
|
+
// only matters for /pair/* endpoints (which the page calls with the
|
|
76
|
+
// token from its URL fragment).
|
|
77
|
+
if (req.method === "GET") {
|
|
78
|
+
if (url.pathname === "/" || url.pathname === "/setup.html") {
|
|
79
|
+
this.sendStatic(res, "text/html; charset=utf-8", static_assets_js_1.SETUP_HTML);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (url.pathname === "/style.css") {
|
|
83
|
+
this.sendStatic(res, "text/css; charset=utf-8", static_assets_js_1.SETUP_CSS);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (url.pathname === "/setup.css") {
|
|
87
|
+
this.sendStatic(res, "text/css; charset=utf-8", static_assets_js_1.SETUP_PAGE_CSS);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (url.pathname === "/setup.js") {
|
|
91
|
+
this.sendStatic(res, "application/javascript; charset=utf-8", static_assets_js_1.SETUP_JS);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (!this.authorized(req)) {
|
|
96
|
+
this.sendJson(res, 401, { error: "Unauthorized" });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (req.method === "GET" && url.pathname === "/pair/info") {
|
|
100
|
+
this.sendJson(res, 200, this.handlers.info());
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (req.method === "POST" && url.pathname === "/pair/list-servers") {
|
|
104
|
+
const parsed = await this.readJson(req, res);
|
|
105
|
+
if (!parsed)
|
|
106
|
+
return;
|
|
107
|
+
try {
|
|
108
|
+
const result = await this.handlers.listServers(parsed);
|
|
109
|
+
const status = result.status ?? (result.ok ? 200 : 502);
|
|
110
|
+
this.sendJson(res, status, result);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
this.sendJson(res, 502, { ok: false, error: `Upstream unreachable: ${err.message}` });
|
|
114
|
+
}
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (req.method === "POST" && url.pathname === "/pair/discover") {
|
|
118
|
+
const parsed = await this.readJson(req, res);
|
|
119
|
+
if (!parsed)
|
|
120
|
+
return;
|
|
121
|
+
try {
|
|
122
|
+
const result = await this.handlers.discover(parsed);
|
|
123
|
+
const status = result.status ?? (result.ok ? 200 : 502);
|
|
124
|
+
this.sendJson(res, status, result);
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
this.sendJson(res, 502, { ok: false, error: `Upstream unreachable: ${err.message}` });
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (req.method === "POST" && url.pathname === "/pair/complete") {
|
|
132
|
+
const cfg = await this.readJson(req, res);
|
|
133
|
+
if (!cfg)
|
|
134
|
+
return;
|
|
135
|
+
const out = await this.handlers.complete(cfg);
|
|
136
|
+
if (!out.ok) {
|
|
137
|
+
this.sendJson(res, 400, { error: out.error });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
// Only run afterFlush once the response body has actually drained.
|
|
141
|
+
// The pairing tunnel teardown rides this callback so a slow client
|
|
142
|
+
// (mobile data, congested tunnel) doesn't lose the success body to
|
|
143
|
+
// a tunnel that closed on a fixed timer.
|
|
144
|
+
const afterFlush = out.afterFlush;
|
|
145
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
146
|
+
res.end(JSON.stringify({ ok: true }), () => {
|
|
147
|
+
if (afterFlush)
|
|
148
|
+
afterFlush();
|
|
149
|
+
});
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
this.sendJson(res, 404, { error: "Not found" });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
exports.PairingHttpServer = PairingHttpServer;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SETUP_JS = exports.SETUP_PAGE_CSS = exports.SETUP_CSS = exports.SETUP_HTML = void 0;
|
|
4
|
+
const node_fs_1 = require("node:fs");
|
|
5
|
+
const node_path_1 = require("node:path");
|
|
6
|
+
// dist/proxy/pairing/static-assets.js → ../../../static (project root).
|
|
7
|
+
// Resolved at module load so we fail fast at startup rather than on the
|
|
8
|
+
// first browser hit.
|
|
9
|
+
const STATIC_DIR = (0, node_path_1.resolve)(__dirname, "..", "..", "..", "static");
|
|
10
|
+
exports.SETUP_HTML = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(STATIC_DIR, "setup.html"));
|
|
11
|
+
exports.SETUP_CSS = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(STATIC_DIR, "style.css"));
|
|
12
|
+
exports.SETUP_PAGE_CSS = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(STATIC_DIR, "setup.css"));
|
|
13
|
+
exports.SETUP_JS = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(STATIC_DIR, "setup.js"));
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare class PairingTunnel {
|
|
2
|
+
private wrapper;
|
|
3
|
+
private url;
|
|
4
|
+
private lastError;
|
|
5
|
+
private waiters;
|
|
6
|
+
private onUnexpectedExit;
|
|
7
|
+
start(port: number, onUnexpectedExit?: (reason: string) => void): Promise<string>;
|
|
8
|
+
private handleUrl;
|
|
9
|
+
private handleExit;
|
|
10
|
+
private failWaiters;
|
|
11
|
+
private urlReady;
|
|
12
|
+
stop(): void;
|
|
13
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PairingTunnel = void 0;
|
|
4
|
+
const node_child_process_1 = require("node:child_process");
|
|
5
|
+
const node_path_1 = require("node:path");
|
|
6
|
+
const protocol_js_1 = require("../../shared/protocol.js");
|
|
7
|
+
const constants_js_1 = require("../core/constants.js");
|
|
8
|
+
// Owns the cloudflared child via wrapper.js. The wrapper guarantees that
|
|
9
|
+
// cloudflared cannot outlive the proxy: it watches stdin EOF and kills the
|
|
10
|
+
// child on either side dying, with 0ms detection latency on every supported
|
|
11
|
+
// OS. From the proxy's side we only need to start, await the URL, and stop.
|
|
12
|
+
class PairingTunnel {
|
|
13
|
+
wrapper = null;
|
|
14
|
+
url = null;
|
|
15
|
+
// Most recent cloudflared error, captured from wrapper "ERR" lines. Lets
|
|
16
|
+
// us include the actual cause in any rejection / unexpected-exit log so
|
|
17
|
+
// the user sees something more useful than "exited before becoming
|
|
18
|
+
// ready".
|
|
19
|
+
lastError = null;
|
|
20
|
+
waiters = [];
|
|
21
|
+
// Fired only when the wrapper dies AFTER the URL was advertised — i.e.,
|
|
22
|
+
// a runtime crash, not a startup failure. Pre-ready exits already reject
|
|
23
|
+
// the start() promise, so the caller learns about those synchronously.
|
|
24
|
+
// The reason string carries whatever cloudflared error we last saw, so
|
|
25
|
+
// the caller can log a meaningful line (e.g., "tunnel: connection
|
|
26
|
+
// refused") instead of a generic "exited unexpectedly".
|
|
27
|
+
onUnexpectedExit = null;
|
|
28
|
+
start(port, onUnexpectedExit) {
|
|
29
|
+
if (this.wrapper)
|
|
30
|
+
return this.urlReady();
|
|
31
|
+
this.onUnexpectedExit = onUnexpectedExit ?? null;
|
|
32
|
+
// dist/proxy/pairing/tunnel.js → ../../wrapper.js. Two `..` segments
|
|
33
|
+
// because tunnel lives two directories below dist/, not one — keep this
|
|
34
|
+
// aligned with the emitted layout if the file ever moves again.
|
|
35
|
+
const wrapperPath = (0, node_path_1.resolve)(__dirname, "..", "..", "wrapper.js");
|
|
36
|
+
const child = (0, node_child_process_1.spawn)(process.execPath, [wrapperPath, String(port)], {
|
|
37
|
+
stdio: ["pipe", "pipe", "inherit"],
|
|
38
|
+
});
|
|
39
|
+
this.wrapper = child;
|
|
40
|
+
const buf = new protocol_js_1.LineBuffer();
|
|
41
|
+
child.stdout.on("data", (chunk) => {
|
|
42
|
+
for (const line of buf.push(chunk.toString("utf-8"))) {
|
|
43
|
+
if (line.startsWith("URL "))
|
|
44
|
+
this.handleUrl(line.slice(4).trim());
|
|
45
|
+
else if (line.startsWith("ERR "))
|
|
46
|
+
this.lastError = line.slice(4).trim();
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
child.on("exit", () => this.handleExit());
|
|
50
|
+
child.on("error", (err) => {
|
|
51
|
+
// The spawn itself failed (binary missing, EPERM, etc.) — capture as
|
|
52
|
+
// the cause so the rejection isn't blank.
|
|
53
|
+
if (!this.lastError)
|
|
54
|
+
this.lastError = err.message;
|
|
55
|
+
this.failWaiters(new Error(`Pairing tunnel wrapper failed: ${err.message}`));
|
|
56
|
+
});
|
|
57
|
+
return this.urlReady();
|
|
58
|
+
}
|
|
59
|
+
handleUrl(url) {
|
|
60
|
+
this.url = url;
|
|
61
|
+
for (const w of this.waiters) {
|
|
62
|
+
clearTimeout(w.timer);
|
|
63
|
+
w.resolve(url);
|
|
64
|
+
}
|
|
65
|
+
this.waiters = [];
|
|
66
|
+
}
|
|
67
|
+
handleExit() {
|
|
68
|
+
const wasReady = this.url !== null;
|
|
69
|
+
const reason = this.lastError
|
|
70
|
+
? `Pairing tunnel exited: ${this.lastError}`
|
|
71
|
+
: "Pairing tunnel exited before becoming ready";
|
|
72
|
+
this.failWaiters(new Error(reason));
|
|
73
|
+
this.wrapper = null;
|
|
74
|
+
this.url = null;
|
|
75
|
+
if (wasReady) {
|
|
76
|
+
const cb = this.onUnexpectedExit;
|
|
77
|
+
this.onUnexpectedExit = null;
|
|
78
|
+
cb?.(this.lastError ?? "exit without error message");
|
|
79
|
+
}
|
|
80
|
+
this.lastError = null;
|
|
81
|
+
}
|
|
82
|
+
failWaiters(err) {
|
|
83
|
+
for (const w of this.waiters) {
|
|
84
|
+
clearTimeout(w.timer);
|
|
85
|
+
w.reject(err);
|
|
86
|
+
}
|
|
87
|
+
this.waiters = [];
|
|
88
|
+
}
|
|
89
|
+
urlReady() {
|
|
90
|
+
if (this.url)
|
|
91
|
+
return Promise.resolve(this.url);
|
|
92
|
+
return new Promise((resolveP, rejectP) => {
|
|
93
|
+
const timer = setTimeout(() => {
|
|
94
|
+
this.waiters = this.waiters.filter((w) => w.timer !== timer);
|
|
95
|
+
const cause = this.lastError ? ` (last error: ${this.lastError})` : "";
|
|
96
|
+
rejectP(new Error(`Pairing tunnel startup timed out after ${constants_js_1.TUNNEL_STARTUP_TIMEOUT_MS / 1000}s${cause}`));
|
|
97
|
+
}, constants_js_1.TUNNEL_STARTUP_TIMEOUT_MS);
|
|
98
|
+
this.waiters.push({ resolve: resolveP, reject: rejectP, timer });
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
stop() {
|
|
102
|
+
const child = this.wrapper;
|
|
103
|
+
if (!child)
|
|
104
|
+
return;
|
|
105
|
+
this.wrapper = null;
|
|
106
|
+
this.url = null;
|
|
107
|
+
this.lastError = null;
|
|
108
|
+
// Caller-initiated stop; suppress the unexpected-exit callback so a
|
|
109
|
+
// teardownPairing() chain doesn't reenter via the exit handler.
|
|
110
|
+
this.onUnexpectedExit = null;
|
|
111
|
+
try {
|
|
112
|
+
child.stdin?.write("stop\n");
|
|
113
|
+
}
|
|
114
|
+
catch { /* already closed */ }
|
|
115
|
+
try {
|
|
116
|
+
child.stdin?.end();
|
|
117
|
+
}
|
|
118
|
+
catch { /* already closed */ }
|
|
119
|
+
// Hard-kill fallback if the wrapper does not exit promptly.
|
|
120
|
+
setTimeout(() => {
|
|
121
|
+
if (!child.killed) {
|
|
122
|
+
try {
|
|
123
|
+
child.kill("SIGKILL");
|
|
124
|
+
}
|
|
125
|
+
catch { /* already dead */ }
|
|
126
|
+
}
|
|
127
|
+
}, 5000).unref();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
exports.PairingTunnel = PairingTunnel;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Validation for pairing-time inputs: which tunnel hostnames the proxy is
|
|
3
|
+
// willing to dial out to from /pair/* endpoints. Centralised here so the
|
|
4
|
+
// browser can't trick the proxy into hitting arbitrary URLs even if the
|
|
5
|
+
// pairing bearer leaks. Kept structurally identical to the runtime path
|
|
6
|
+
// (handleComplete validates host configs through the same predicate) so
|
|
7
|
+
// what passes pairing-time discovery is exactly what passes pairing-time
|
|
8
|
+
// save.
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.allowedTunnelUrl = allowedTunnelUrl;
|
|
11
|
+
exports.isUnknownSessionError = isUnknownSessionError;
|
|
12
|
+
// Tunnel-URL allowlist for /pair/* discovery endpoints. The bearer token
|
|
13
|
+
// alone must not authorize SSRF — without this, anyone holding the token
|
|
14
|
+
// (browser extension, XSS, leaked terminal scrollback) could pivot the
|
|
15
|
+
// proxy to any URL, including http://127.0.0.1 on the host's LAN.
|
|
16
|
+
const DEFAULT_TUNNEL_HOST_SUFFIXES = [".trycloudflare.com", ".cfargotunnel.com"];
|
|
17
|
+
const LOCAL_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
18
|
+
function tunnelHostSuffixes() {
|
|
19
|
+
const extra = (process.env.MCP_TUNNEL_HOST_SUFFIXES ?? "")
|
|
20
|
+
.split(",")
|
|
21
|
+
.map((s) => s.trim().toLowerCase())
|
|
22
|
+
.filter((s) => s.startsWith("."));
|
|
23
|
+
return [...new Set([...DEFAULT_TUNNEL_HOST_SUFFIXES, ...extra])];
|
|
24
|
+
}
|
|
25
|
+
function allowedTunnelUrl(raw) {
|
|
26
|
+
let url;
|
|
27
|
+
try {
|
|
28
|
+
url = new URL(raw);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
if (url.username || url.password)
|
|
34
|
+
return null;
|
|
35
|
+
const host = url.hostname.toLowerCase();
|
|
36
|
+
const allowLocal = process.env.MCP_ALLOW_LOCAL === "true";
|
|
37
|
+
if (allowLocal && LOCAL_HOSTNAMES.has(host)) {
|
|
38
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
39
|
+
return null;
|
|
40
|
+
return url;
|
|
41
|
+
}
|
|
42
|
+
if (url.protocol !== "https:")
|
|
43
|
+
return null;
|
|
44
|
+
const suffixes = tunnelHostSuffixes();
|
|
45
|
+
const ok = suffixes.some((suffix) => host.endsWith(suffix) && host.length > suffix.length);
|
|
46
|
+
return ok ? url : null;
|
|
47
|
+
}
|
|
48
|
+
// Host returns 404 + JSON error when a forwarded request points at a session
|
|
49
|
+
// it doesn't know about — typically because the host GC'd it after 30 min idle
|
|
50
|
+
// or the host process restarted. Matched here so the proxy can re-init + retry
|
|
51
|
+
// instead of bubbling the failure up to the client.
|
|
52
|
+
function isUnknownSessionError(body) {
|
|
53
|
+
try {
|
|
54
|
+
const e = JSON.parse(body).error;
|
|
55
|
+
if (typeof e !== "string")
|
|
56
|
+
return false;
|
|
57
|
+
return e.startsWith("Unknown session") || e === "Mcp-Session-Id header required";
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { HostState, PairingConfig, Prompt, Resource, ResourceTemplate, Tool, ToolRoute } from "../core/types.js";
|
|
2
|
+
export declare function serverKey(hostId: string, serverName: string): string;
|
|
3
|
+
export declare function isServerSelected(config: PairingConfig | null, hostId: string, serverName: string): boolean;
|
|
4
|
+
export declare function getFilteredTools(config: PairingConfig | null, hosts: Map<string, HostState>, toolRoute: Map<string, ToolRoute>): Tool[];
|
|
5
|
+
export declare function getAggregatedPrompts(config: PairingConfig | null, hosts: Map<string, HostState>, promptRoute: Map<string, ToolRoute>): Prompt[];
|
|
6
|
+
export declare function getAggregatedResources(config: PairingConfig | null, hosts: Map<string, HostState>, exact: Array<{
|
|
7
|
+
uri: string;
|
|
8
|
+
route: ToolRoute;
|
|
9
|
+
}>): Resource[];
|
|
10
|
+
export declare function getAggregatedResourceTemplates(config: PairingConfig | null, hosts: Map<string, HostState>, templates: Array<{
|
|
11
|
+
uriTemplate: string;
|
|
12
|
+
route: ToolRoute;
|
|
13
|
+
}>): ResourceTemplate[];
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.serverKey = serverKey;
|
|
4
|
+
exports.isServerSelected = isServerSelected;
|
|
5
|
+
exports.getFilteredTools = getFilteredTools;
|
|
6
|
+
exports.getAggregatedPrompts = getAggregatedPrompts;
|
|
7
|
+
exports.getAggregatedResources = getAggregatedResources;
|
|
8
|
+
exports.getAggregatedResourceTemplates = getAggregatedResourceTemplates;
|
|
9
|
+
const constants_js_1 = require("../core/constants.js");
|
|
10
|
+
const uri_js_1 = require("./uri.js");
|
|
11
|
+
// One key per server in `selectedServers`. Centralised so the format never
|
|
12
|
+
// drifts between the filter, the setup page, and validation.
|
|
13
|
+
function serverKey(hostId, serverName) {
|
|
14
|
+
return `${hostId}${constants_js_1.TOOL_SEPARATOR}${serverName}`;
|
|
15
|
+
}
|
|
16
|
+
// Server-level allow check. undefined = no filter (every server exposed);
|
|
17
|
+
// array = only the listed entries (each `<hostId>__<serverName>`). This is
|
|
18
|
+
// the single gate that hides ALL of a server's capabilities — tools,
|
|
19
|
+
// prompts, resources, templates, and the routed methods that read them.
|
|
20
|
+
// Without it a server with all tools deselected still leaked prompts and
|
|
21
|
+
// resources through the proxy (CR-01).
|
|
22
|
+
function isServerSelected(config, hostId, serverName) {
|
|
23
|
+
if (!config)
|
|
24
|
+
return false;
|
|
25
|
+
if (config.selectedServers === undefined)
|
|
26
|
+
return true;
|
|
27
|
+
return config.selectedServers.includes(serverKey(hostId, serverName));
|
|
28
|
+
}
|
|
29
|
+
// Build the agent-facing tools list. Filters by both selectedServers
|
|
30
|
+
// (server-level) and selectedTools (tool-level within an allowed server).
|
|
31
|
+
// Description is prefixed with origin so two servers with same-named tools
|
|
32
|
+
// don't confuse the agent.
|
|
33
|
+
function getFilteredTools(config, hosts, toolRoute) {
|
|
34
|
+
if (!config)
|
|
35
|
+
return [];
|
|
36
|
+
const selectedToolSet = config.selectedTools !== undefined ? new Set(config.selectedTools) : null;
|
|
37
|
+
const tools = [];
|
|
38
|
+
for (const [prefixed, route] of toolRoute) {
|
|
39
|
+
if (!isServerSelected(config, route.hostId, route.serverName))
|
|
40
|
+
continue;
|
|
41
|
+
if (selectedToolSet && !selectedToolSet.has(prefixed))
|
|
42
|
+
continue;
|
|
43
|
+
const server = hosts.get(route.hostId)?.servers.get(route.serverName);
|
|
44
|
+
const original = server?.tools.find((t) => t.name === route.originalName);
|
|
45
|
+
if (!original)
|
|
46
|
+
continue;
|
|
47
|
+
tools.push({
|
|
48
|
+
...original,
|
|
49
|
+
name: prefixed,
|
|
50
|
+
description: `[${route.hostId}/${route.serverName}] ${original.description ?? ""}`.trim(),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return tools;
|
|
54
|
+
}
|
|
55
|
+
// Prompts have no per-prompt filter today — only the server-level gate.
|
|
56
|
+
// Description prefixed with origin for the same disambiguation reason as
|
|
57
|
+
// tools.
|
|
58
|
+
function getAggregatedPrompts(config, hosts, promptRoute) {
|
|
59
|
+
if (!config)
|
|
60
|
+
return [];
|
|
61
|
+
const prompts = [];
|
|
62
|
+
for (const [prefixed, route] of promptRoute) {
|
|
63
|
+
if (!isServerSelected(config, route.hostId, route.serverName))
|
|
64
|
+
continue;
|
|
65
|
+
const server = hosts.get(route.hostId)?.servers.get(route.serverName);
|
|
66
|
+
const original = server?.prompts.find((p) => p.name === route.originalName);
|
|
67
|
+
if (!original)
|
|
68
|
+
continue;
|
|
69
|
+
prompts.push({
|
|
70
|
+
...original,
|
|
71
|
+
name: prefixed,
|
|
72
|
+
description: `[${route.hostId}/${route.serverName}] ${original.description ?? ""}`.trim(),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return prompts;
|
|
76
|
+
}
|
|
77
|
+
// Resources are namespaced on the way out: every URI is wrapped with the
|
|
78
|
+
// owning (hostId, serverName) so two upstream servers exposing the same
|
|
79
|
+
// raw URI (or overlapping templates) are unambiguous to the agent and to
|
|
80
|
+
// the routing path. Server-level gate hides every URI from a deselected
|
|
81
|
+
// server. Round-trip is symmetric — handleResourceMethod unwraps on the
|
|
82
|
+
// way back in.
|
|
83
|
+
function getAggregatedResources(config, hosts, exact) {
|
|
84
|
+
if (!config)
|
|
85
|
+
return [];
|
|
86
|
+
const out = [];
|
|
87
|
+
for (const { uri, route } of exact) {
|
|
88
|
+
if (!isServerSelected(config, route.hostId, route.serverName))
|
|
89
|
+
continue;
|
|
90
|
+
const server = hosts.get(route.hostId)?.servers.get(route.serverName);
|
|
91
|
+
const original = server?.resources.find((r) => r.uri === uri);
|
|
92
|
+
if (!original)
|
|
93
|
+
continue;
|
|
94
|
+
out.push({ ...original, uri: (0, uri_js_1.wrapResourceUri)(route.hostId, route.serverName, original.uri) });
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
function getAggregatedResourceTemplates(config, hosts, templates) {
|
|
99
|
+
if (!config)
|
|
100
|
+
return [];
|
|
101
|
+
const out = [];
|
|
102
|
+
for (const { uriTemplate, route } of templates) {
|
|
103
|
+
if (!isServerSelected(config, route.hostId, route.serverName))
|
|
104
|
+
continue;
|
|
105
|
+
const server = hosts.get(route.hostId)?.servers.get(route.serverName);
|
|
106
|
+
const original = server?.resourceTemplates.find((t) => t.uriTemplate === uriTemplate);
|
|
107
|
+
if (!original)
|
|
108
|
+
continue;
|
|
109
|
+
// Wrapping the template is safe: RFC 6570 expansion is left-to-right
|
|
110
|
+
// substitution of `{...}` literals, and the prefix has no braces, so
|
|
111
|
+
// an agent expanding the wrapped template yields a URI the proxy can
|
|
112
|
+
// structurally unwrap on the way back.
|
|
113
|
+
out.push({ ...original, uriTemplate: (0, uri_js_1.wrapResourceUri)(route.hostId, route.serverName, original.uriTemplate) });
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Resource, ResourceTemplate, ToolRoute } from "../core/types.js";
|
|
2
|
+
export declare class ResourceRouter {
|
|
3
|
+
private exact;
|
|
4
|
+
private exactByUri;
|
|
5
|
+
private templates;
|
|
6
|
+
private templatesByUri;
|
|
7
|
+
clear(): void;
|
|
8
|
+
add(hostId: string, serverName: string, resources: Resource[], templates: ResourceTemplate[]): string[];
|
|
9
|
+
exactEntries(): Array<{
|
|
10
|
+
uri: string;
|
|
11
|
+
route: ToolRoute;
|
|
12
|
+
}>;
|
|
13
|
+
templateEntries(): Array<{
|
|
14
|
+
uriTemplate: string;
|
|
15
|
+
route: ToolRoute;
|
|
16
|
+
}>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ResourceRouter = void 0;
|
|
4
|
+
// ResourceRouter is now a bookkeeping store — routing itself is structural
|
|
5
|
+
// (see resource-uri.ts: every URI the agent sees is wrapped with its
|
|
6
|
+
// owning hostId/serverName, so unwrap → route is a pure parse, no map
|
|
7
|
+
// lookup, no template engine).
|
|
8
|
+
//
|
|
9
|
+
// What this class still owns:
|
|
10
|
+
// - The exact-URI list per (host, server), used by getAggregatedResources
|
|
11
|
+
// to render the agent-facing resources/list (with origin-wrapped URIs).
|
|
12
|
+
// Stored as an array, not a URI-keyed map, because two servers can
|
|
13
|
+
// legitimately expose the same concrete URI — both must surface under
|
|
14
|
+
// their own envelopes (hence the wrap step in filtering.ts).
|
|
15
|
+
// - The template list per (host, server), used the same way for
|
|
16
|
+
// resources/templates/list.
|
|
17
|
+
// - Cross-origin collision logging when two servers happen to expose
|
|
18
|
+
// the same raw URI or template string. The wrap step makes them
|
|
19
|
+
// distinct on the wire, so this is informational only — no longer
|
|
20
|
+
// load-bearing for routing correctness.
|
|
21
|
+
class ResourceRouter {
|
|
22
|
+
exact = [];
|
|
23
|
+
exactByUri = new Map();
|
|
24
|
+
templates = [];
|
|
25
|
+
templatesByUri = new Map();
|
|
26
|
+
clear() {
|
|
27
|
+
this.exact = [];
|
|
28
|
+
this.exactByUri.clear();
|
|
29
|
+
this.templates = [];
|
|
30
|
+
this.templatesByUri.clear();
|
|
31
|
+
}
|
|
32
|
+
// Add one server's resources + templates. Returns a list of human-readable
|
|
33
|
+
// collision messages that the caller should write to stderr — kept out of
|
|
34
|
+
// this module so logging stays at the orchestrator layer.
|
|
35
|
+
add(hostId, serverName, resources, templates) {
|
|
36
|
+
const log = [];
|
|
37
|
+
const route = (originalName) => ({ hostId, serverName, originalName });
|
|
38
|
+
for (const r of resources) {
|
|
39
|
+
const existing = this.exactByUri.get(r.uri);
|
|
40
|
+
if (existing && (existing.hostId !== hostId || existing.serverName !== serverName)) {
|
|
41
|
+
log.push(`Resource URI also exposed by ${hostId}/${serverName}: ${r.uri} (already advertised by ${existing.hostId}/${existing.serverName}); both surfaced under their own envelopes`);
|
|
42
|
+
}
|
|
43
|
+
const rt = route(r.uri);
|
|
44
|
+
this.exact.push({ uri: r.uri, route: rt });
|
|
45
|
+
// First-writer wins for the dedup map — only used to detect repeats.
|
|
46
|
+
// The list above is the source of truth for the agent-facing listing.
|
|
47
|
+
if (!existing)
|
|
48
|
+
this.exactByUri.set(r.uri, rt);
|
|
49
|
+
}
|
|
50
|
+
for (const t of templates) {
|
|
51
|
+
const existing = this.templatesByUri.get(t.uriTemplate);
|
|
52
|
+
if (existing && (existing.hostId !== hostId || existing.serverName !== serverName)) {
|
|
53
|
+
log.push(`Resource template also exposed by ${hostId}/${serverName}: ${t.uriTemplate} (already advertised by ${existing.hostId}/${existing.serverName}); both surfaced under their own envelopes`);
|
|
54
|
+
}
|
|
55
|
+
const r = route(t.uriTemplate);
|
|
56
|
+
this.templates.push({ uriTemplate: t.uriTemplate, route: r });
|
|
57
|
+
if (!existing)
|
|
58
|
+
this.templatesByUri.set(t.uriTemplate, r);
|
|
59
|
+
}
|
|
60
|
+
return log;
|
|
61
|
+
}
|
|
62
|
+
// For getAggregatedResources / templates list. Iteration order matches
|
|
63
|
+
// insertion (array semantics), which matches the order servers were added
|
|
64
|
+
// in rebuildToolRoute — stable across runs. Two servers exposing the same
|
|
65
|
+
// raw URI yield two distinct entries; wrapResourceUri disambiguates them
|
|
66
|
+
// on the way out.
|
|
67
|
+
exactEntries() {
|
|
68
|
+
return this.exact.map(({ uri, route }) => ({ uri, route }));
|
|
69
|
+
}
|
|
70
|
+
templateEntries() {
|
|
71
|
+
return this.templates.map(({ uriTemplate, route }) => ({ uriTemplate, route }));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
exports.ResourceRouter = ResourceRouter;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare function wrapResourceUri(hostId: string, serverName: string, original: string): string;
|
|
2
|
+
export interface UnwrappedResourceUri {
|
|
3
|
+
hostId: string;
|
|
4
|
+
serverName: string;
|
|
5
|
+
originalUri: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function unwrapResourceUri(wrapped: string): UnwrappedResourceUri | null;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Resource URIs are namespaced with the upstream's (hostId, serverName) so
|
|
3
|
+
// routing is structural — every URI the agent ever sees encodes its origin
|
|
4
|
+
// server, and `resources/read` / `subscribe` / completion can be dispatched
|
|
5
|
+
// without consulting any routing table or template engine. This eliminates
|
|
6
|
+
// the cross-server overlap problem that first-match-wins template routing
|
|
7
|
+
// had before: two upstreams exposing `file:///{path}` are now distinct
|
|
8
|
+
// `mcp+host://hostA/srvA/file:///{path}` and `mcp+host://hostB/srvB/...`
|
|
9
|
+
// strings the agent cannot conflate.
|
|
10
|
+
//
|
|
11
|
+
// Format: `mcp+host://<hostId>/<serverName>/<originalUri>` — the original
|
|
12
|
+
// URI is appended verbatim. hostId and serverName are validated upstream
|
|
13
|
+
// to match `[A-Za-z0-9._-]+` (see SERVER_NAME_PATTERN in shared/protocol),
|
|
14
|
+
// so they cannot contain `/` and the parse is unambiguous.
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.wrapResourceUri = wrapResourceUri;
|
|
17
|
+
exports.unwrapResourceUri = unwrapResourceUri;
|
|
18
|
+
const SCHEME = "mcp+host://";
|
|
19
|
+
function wrapResourceUri(hostId, serverName, original) {
|
|
20
|
+
return `${SCHEME}${hostId}/${serverName}/${original}`;
|
|
21
|
+
}
|
|
22
|
+
function unwrapResourceUri(wrapped) {
|
|
23
|
+
if (!wrapped.startsWith(SCHEME))
|
|
24
|
+
return null;
|
|
25
|
+
const rest = wrapped.slice(SCHEME.length);
|
|
26
|
+
const firstSlash = rest.indexOf("/");
|
|
27
|
+
if (firstSlash <= 0)
|
|
28
|
+
return null;
|
|
29
|
+
const hostId = rest.slice(0, firstSlash);
|
|
30
|
+
const afterHost = rest.slice(firstSlash + 1);
|
|
31
|
+
const secondSlash = afterHost.indexOf("/");
|
|
32
|
+
if (secondSlash <= 0)
|
|
33
|
+
return null;
|
|
34
|
+
const serverName = afterHost.slice(0, secondSlash);
|
|
35
|
+
const originalUri = afterHost.slice(secondSlash + 1);
|
|
36
|
+
if (!originalUri)
|
|
37
|
+
return null;
|
|
38
|
+
return { hostId, serverName, originalUri };
|
|
39
|
+
}
|