@silver886/mcp-proxy 0.1.4 → 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.
Files changed (69) hide show
  1. package/README.md +62 -17
  2. package/dist/host/agent.d.ts +22 -0
  3. package/dist/host/agent.js +314 -0
  4. package/dist/host/cli.d.ts +1 -0
  5. package/dist/host/cli.js +83 -0
  6. package/dist/host/constants.d.ts +4 -0
  7. package/dist/host/constants.js +16 -0
  8. package/dist/host/session.d.ts +21 -0
  9. package/dist/host/session.js +204 -0
  10. package/dist/host/tunnel.d.ts +5 -0
  11. package/dist/host/tunnel.js +82 -0
  12. package/dist/host.js +8 -0
  13. package/dist/proxy/core/constants.d.ts +13 -0
  14. package/dist/proxy/core/constants.js +39 -0
  15. package/dist/proxy/core/fetch-timeout.d.ts +1 -0
  16. package/dist/proxy/core/fetch-timeout.js +15 -0
  17. package/dist/proxy/core/state.d.ts +25 -0
  18. package/dist/proxy/core/state.js +90 -0
  19. package/dist/proxy/core/types.d.ts +57 -0
  20. package/dist/proxy/core/types.js +5 -0
  21. package/dist/proxy/discovery/client.d.ts +42 -0
  22. package/dist/proxy/discovery/client.js +283 -0
  23. package/dist/proxy/discovery/runner.d.ts +21 -0
  24. package/dist/proxy/discovery/runner.js +319 -0
  25. package/dist/proxy/pairing/config.d.ts +9 -0
  26. package/dist/proxy/pairing/config.js +130 -0
  27. package/dist/proxy/pairing/controller.d.ts +19 -0
  28. package/dist/proxy/pairing/controller.js +327 -0
  29. package/dist/proxy/pairing/http.d.ts +70 -0
  30. package/dist/proxy/pairing/http.js +155 -0
  31. package/dist/proxy/pairing/static-assets.d.ts +4 -0
  32. package/dist/proxy/pairing/static-assets.js +13 -0
  33. package/dist/proxy/pairing/tunnel.d.ts +13 -0
  34. package/dist/proxy/pairing/tunnel.js +130 -0
  35. package/dist/proxy/pairing/validation.d.ts +2 -0
  36. package/dist/proxy/pairing/validation.js +62 -0
  37. package/dist/proxy/routing/filtering.d.ts +13 -0
  38. package/dist/proxy/routing/filtering.js +116 -0
  39. package/dist/proxy/routing/router.d.ts +17 -0
  40. package/dist/proxy/routing/router.js +74 -0
  41. package/dist/proxy/routing/uri.d.ts +7 -0
  42. package/dist/proxy/routing/uri.js +39 -0
  43. package/dist/proxy/runtime/forwarder.d.ts +15 -0
  44. package/dist/proxy/runtime/forwarder.js +265 -0
  45. package/dist/proxy/runtime/handlers.d.ts +48 -0
  46. package/dist/proxy/runtime/handlers.js +329 -0
  47. package/dist/proxy/runtime/sse.d.ts +19 -0
  48. package/dist/proxy/runtime/sse.js +169 -0
  49. package/dist/proxy/runtime/upstream-bridge.d.ts +27 -0
  50. package/dist/proxy/runtime/upstream-bridge.js +133 -0
  51. package/dist/proxy/server.d.ts +15 -0
  52. package/dist/proxy/server.js +167 -0
  53. package/dist/proxy.js +5 -0
  54. package/{mcp/dist → dist}/shared/protocol.d.ts +15 -3
  55. package/dist/shared/protocol.js +183 -0
  56. package/dist/wrapper.d.ts +2 -0
  57. package/dist/wrapper.js +72 -0
  58. package/package.json +15 -7
  59. package/static/setup.css +233 -0
  60. package/static/setup.html +57 -0
  61. package/static/setup.js +711 -0
  62. package/static/style.css +208 -0
  63. package/mcp/dist/host.js +0 -307
  64. package/mcp/dist/proxy.js +0 -377
  65. package/mcp/dist/shared/generated.d.ts +0 -2
  66. package/mcp/dist/shared/generated.js +0 -5
  67. package/mcp/dist/shared/protocol.js +0 -79
  68. /package/{mcp/dist → dist}/host.d.ts +0 -0
  69. /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,4 @@
1
+ export declare const SETUP_HTML: NonSharedBuffer;
2
+ export declare const SETUP_CSS: NonSharedBuffer;
3
+ export declare const SETUP_PAGE_CSS: NonSharedBuffer;
4
+ export declare const SETUP_JS: NonSharedBuffer;
@@ -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,2 @@
1
+ export declare function allowedTunnelUrl(raw: string): URL | null;
2
+ export declare function isUnknownSessionError(body: string): boolean;
@@ -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
+ }