Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@silver886/mcp-proxy

Package Overview
Dependencies
Maintainers
1
Versions
11
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@silver886/mcp-proxy - npm Package Compare versions

Comparing version
0.1.4
to
0.2.0
+2
dist/host.d.ts
#!/usr/bin/env node
export {};
#!/usr/bin/env node
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const cli_js_1 = require("./host/cli.js");
(0, cli_js_1.main)().catch((err) => {
console.error(`Host agent failed to start: ${err.message}`);
process.exit(1);
});
export declare class HostAgent {
private config;
private sessions;
private timeout;
private authToken;
private gcTimer;
private server;
private boundHost;
private boundPort;
constructor(configPath: string, timeout: number, overrides?: {
host?: string;
port?: number;
});
get port(): number;
start(): Promise<void>;
shutdown(): void;
private sweepIdleSessions;
private handleRequest;
private authorized;
private handleMcpPost;
private handleSse;
}
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.HostAgent = void 0;
const node_crypto_1 = require("node:crypto");
const node_fs_1 = require("node:fs");
const protocol_js_1 = require("../shared/protocol.js");
const constants_js_1 = require("./constants.js");
const session_js_1 = require("./session.js");
function sendSessionMismatchError(res, session, serverName) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: `Session belongs to server '${session.serverName}', not '${serverName}'` }));
}
// HTTP-to-stdio bridge. Owns the session map, handles the auth check, and
// dispatches by method:
// GET / — list available servers (proxy discovery)
// POST /servers/:name — JSON-RPC request (initialize spawns a session;
// anything else against an unknown id is rejected
// with 404 + JSON error so the proxy can re-init)
// GET /servers/:name — SSE stream for that session's notifications
// DELETE /servers/:name — explicit session close
class HostAgent {
config;
sessions = new Map();
timeout;
authToken;
gcTimer = null;
server = null;
boundHost;
boundPort;
constructor(configPath, timeout, overrides) {
const raw = (0, node_fs_1.readFileSync)(configPath, "utf-8");
this.config = JSON.parse(raw);
this.timeout = timeout;
this.authToken = (0, node_crypto_1.randomBytes)(32).toString("base64url"); // 256-bit token
this.boundHost = overrides?.host ?? this.config.host ?? protocol_js_1.DEFAULT_HOST;
// port 0 = let the OS pick. Resolved to the real bound port in start().
this.boundPort = overrides?.port ?? this.config.port ?? protocol_js_1.DEFAULT_PORT;
// Reject server names that the proxy/page would silently drop later.
// Authoritative at config-load time so misnamed servers surface as a
// startup error instead of disappearing during discovery.
const invalid = [];
for (const name of Object.keys(this.config.servers)) {
const reason = (0, protocol_js_1.validateServerName)(name);
if (reason)
invalid.push(` - "${name}": ${reason}`);
}
if (invalid.length > 0) {
throw new Error(`Invalid server name(s) in ${configPath}:\n${invalid.join("\n")}\n` +
`Rename the entries in config.json so they match the policy.`);
}
}
get port() {
return this.boundPort;
}
// Resolves once the listener is bound. Tunnel mode passes port 0 and
// needs the real port back before it can start cloudflared, so callers
// must await this rather than racing the synchronous return.
//
// On a bind failure (EADDRINUSE, EACCES, …) we tear down everything we
// installed before rejecting: the GC interval, the listener reference,
// and any half-open server. Without this cleanup, a caller that catches
// the rejection and discards the agent leaks a running interval and a
// dangling server reference until process exit — invisible to the CLI
// (which just process.exits) but a real leak for library/test usage.
start() {
return new Promise((resolveP, rejectP) => {
const srv = (0, protocol_js_1.createServer)((req, res) => this.handleRequest(req, res));
this.server = srv;
this.gcTimer = setInterval(() => this.sweepIdleSessions(), constants_js_1.SESSION_GC_INTERVAL_MS);
const onError = (err) => {
if (this.gcTimer) {
clearInterval(this.gcTimer);
this.gcTimer = null;
}
this.server = null;
try {
srv.close();
}
catch { /* never bound */ }
rejectP(err);
};
srv.once("error", onError);
srv.listen(this.boundPort, this.boundHost, () => {
const addr = srv.address();
if (addr && typeof addr === "object")
this.boundPort = addr.port;
console.log(`MCP Host Agent listening on http://${this.boundHost}:${this.boundPort}`);
console.log(`Available servers: ${Object.keys(this.config.servers).join(", ")}`);
console.error(`Auth token: ${this.authToken}`);
srv.off("error", onError);
resolveP();
});
});
}
// Stop the GC timer, tear down every active session, and release the HTTP
// listener. Safe to call more than once. The listener close is what
// matters for library users — the CLI path immediately process.exits, but
// an embedder that re-creates the agent (tests, hot-reload, etc.) would
// otherwise leak the bound port. closeAllConnections() is required because
// the SSE handler keeps long-lived responses open; close() alone would
// wait for them to drain naturally and never resolve.
shutdown() {
if (this.gcTimer) {
clearInterval(this.gcTimer);
this.gcTimer = null;
}
for (const [id, session] of this.sessions) {
session.destroy();
this.sessions.delete(id);
}
if (this.server) {
const srv = this.server;
this.server = null;
srv.closeAllConnections();
srv.close();
}
}
sweepIdleSessions() {
const now = Date.now();
for (const [id, session] of this.sessions) {
if (!session.isAlive) {
this.sessions.delete(id);
continue;
}
if (now - session.lastActivity > constants_js_1.SESSION_IDLE_TIMEOUT_MS) {
console.log(`[${session.serverName}] Idle session ${id} closed after ${constants_js_1.SESSION_IDLE_TIMEOUT_MS}ms`);
session.destroy();
this.sessions.delete(id);
}
}
}
async handleRequest(req, res) {
if (!this.authorized(req)) {
res.writeHead(401, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Unauthorized" }));
return;
}
// Parse once so the route check ignores the query string and we can
// anchor on pathname — `/servers/foo/extra` must 404, not silently
// route to `foo` (the proxy's forwarder rejects it, so accepting it
// here would create a contract mismatch).
const { pathname } = new URL(req.url ?? "/", "http://h");
if (req.method === "GET" && pathname === "/") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
service: "mcp-proxy-host",
servers: Object.keys(this.config.servers),
}));
return;
}
const match = pathname.match(/^\/servers\/([^/]+)$/);
if (!match) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Not found. Use /servers/<name>" }));
return;
}
const serverName = match[1];
const serverConfig = this.config.servers[serverName];
if (!serverConfig) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({
error: `Unknown server: ${serverName}`,
available: Object.keys(this.config.servers),
}));
return;
}
if (req.method === "POST") {
await this.handleMcpPost(req, res, serverName, serverConfig);
return;
}
if (req.method === "GET") {
this.handleSse(req, res, serverName);
return;
}
if (req.method === "DELETE") {
const sessionId = req.headers["mcp-session-id"];
if (sessionId && this.sessions.has(sessionId)) {
const session = this.sessions.get(sessionId);
if (session.serverName !== serverName) {
sendSessionMismatchError(res, session, serverName);
return;
}
session.destroy();
this.sessions.delete(sessionId);
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
return;
}
res.writeHead(405);
res.end();
}
authorized(req) {
const auth = req.headers.authorization ?? "";
const expected = `Bearer ${this.authToken}`;
const authBuf = Buffer.from(auth);
const expectedBuf = Buffer.from(expected);
return authBuf.length === expectedBuf.length && (0, node_crypto_1.timingSafeEqual)(authBuf, expectedBuf);
}
async handleMcpPost(req, res, serverName, serverConfig) {
const body = await (0, protocol_js_1.readBody)(req);
const headerSessionId = req.headers["mcp-session-id"];
// Peek the JSON-RPC method without consuming the body. Only `initialize`
// may run without an existing session — anything else against an
// unknown id is stale (post-GC, post-restart) or wrong, and silently
// spawning a fresh uninitialized child for it would violate the MCP
// handshake.
let method;
try {
method = JSON.parse(body).method;
}
catch {
// Unparseable body falls through as non-initialize → 404 below.
}
const isInitialize = method === "initialize";
let existing;
if (headerSessionId) {
existing = this.sessions.get(headerSessionId);
if (existing) {
if (existing.serverName !== serverName) {
sendSessionMismatchError(res, existing, serverName);
return;
}
if (!existing.isAlive) {
// Reaped under us — drop the dead entry; treat as no session.
this.sessions.delete(headerSessionId);
existing = undefined;
}
}
}
let session;
let activeSessionId;
if (existing && headerSessionId) {
session = existing;
activeSessionId = headerSessionId;
}
else {
if (!isInitialize) {
// Mirror handleSse: refuse to bind work to an id we don't know.
// The proxy is expected to re-`initialize` and retry on this 404.
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({
error: headerSessionId
? `Unknown session: ${headerSessionId}`
: "Mcp-Session-Id header required",
}));
return;
}
activeSessionId = (0, node_crypto_1.randomBytes)(16).toString("hex");
session = new session_js_1.McpSession(serverName, serverConfig, this.timeout);
this.sessions.set(activeSessionId, session);
}
const response = await session.sendRequest(body);
if (!response) {
// Client notification — no response body
res.writeHead(202, { "Mcp-Session-Id": activeSessionId });
res.end();
return;
}
res.writeHead(200, {
"Content-Type": "application/json",
"Mcp-Session-Id": activeSessionId,
});
res.end(response);
}
handleSse(req, res, serverName) {
const sessionId = req.headers["mcp-session-id"];
const session = sessionId ? this.sessions.get(sessionId) : undefined;
// Reject SSE attaches that do not point at a live session for this
// server. Returning 200 with an empty stream would let the proxy sit
// on a dead pipe forever instead of reconnecting to a fresh session
// id — the drain interval below would close the stream on its first
// tick once it noticed `session.isAlive === false`, but by then the
// 200 has already convinced the proxy that the session is good and
// it won't re-initialize on its own. The 404 here is the signal the
// proxy needs to throw the stale id away and start a fresh session.
if (sessionId && session && !session.isAlive) {
// Clear the dead entry on the way out so the next attach sees a
// clean miss instead of repeating this dance.
this.sessions.delete(sessionId);
}
if (!sessionId || !session || !session.isAlive) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({
error: sessionId ? `Unknown session: ${sessionId}` : "Mcp-Session-Id header required",
}));
return;
}
if (session.serverName !== serverName) {
sendSessionMismatchError(res, session, serverName);
return;
}
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"Mcp-Session-Id": sessionId,
});
res.write(": connected\n\n");
const interval = setInterval(() => {
if (!session.isAlive) {
clearInterval(interval);
res.end();
return;
}
const notifications = session.drainNotifications();
for (const n of notifications) {
res.write(`data: ${n}\n\n`);
}
}, constants_js_1.SSE_DRAIN_INTERVAL_MS);
req.on("close", () => clearInterval(interval));
}
}
exports.HostAgent = HostAgent;
export declare function main(): Promise<void>;
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.main = main;
const protocol_js_1 = require("../shared/protocol.js");
const agent_js_1 = require("./agent.js");
const tunnel_js_1 = require("./tunnel.js");
// Entry point: parse flags, start the agent, optionally bring up a quick
// tunnel, install signal handlers. Kept separate from agent.ts so unit
// tests / library users can import HostAgent without invoking process.exit
// or cloudflared.
async function main() {
const configPath = (0, protocol_js_1.getArg)("--config") ?? "config.json";
const timeoutRaw = (0, protocol_js_1.getArg)("--timeout") ?? "120000"; // 2min default
const timeout = Number(timeoutRaw);
// setTimeout(fn, NaN | <=0) fires immediately, making every MCP request
// appear to time out. Reject bad input at startup instead of silently
// breaking the agent.
if (!Number.isInteger(timeout) || timeout <= 0) {
console.error(`Invalid --timeout "${timeoutRaw}": must be a positive integer (milliseconds)`);
process.exit(2);
}
const useTunnel = process.argv.includes("--tunnel");
// In tunnel mode the listener is internal-only — cloudflared is the sole
// caller — so we ignore config.host/port and force loopback + an
// OS-assigned port. That removes the foot-gun where a user-provided port
// collides with another local service, and prevents accidentally
// exposing the unauthenticated-from-the-LAN listener on a routable
// interface when the bearer token is meant to ride only over the tunnel.
const overrides = useTunnel ? { host: "127.0.0.1", port: 0 } : undefined;
const agent = new agent_js_1.HostAgent(configPath, timeout, overrides);
await agent.start();
let tunnel = null;
if (useTunnel) {
console.log("Starting Cloudflare tunnel...");
try {
tunnel = await (0, tunnel_js_1.startTunnel)(agent.port, (reason) => {
// Runtime failure after the tunnel was already serving — keep the
// local agent alive so loopback clients can still reach it, but
// make the situation loud so the operator knows the public URL is
// dead and needs a restart.
console.error(`Cloudflare tunnel exited unexpectedly: ${reason}`);
console.error("The public URL is no longer reachable. Restart the host to bring up a new tunnel.");
});
}
catch (err) {
// Pre-ready tunnel failure (binary missing, network down, auth
// failure, startup timeout). Without this the user previously saw
// only "Starting Cloudflare tunnel..." and an apparently healthy
// host with no URL. Now we tear the agent down and exit non-zero so
// the failure is visible to whatever launched us.
console.error(`Cloudflare tunnel failed to start: ${err.message}`);
try {
agent.shutdown();
}
catch { /* ignore */ }
process.exit(1);
}
}
let shuttingDown = false;
const shutdown = (signal) => {
if (shuttingDown)
return;
shuttingDown = true;
console.log(`Received ${signal}, shutting down...`);
try {
agent.shutdown();
}
catch (err) {
console.error(`Agent shutdown error: ${err.message}`);
}
if (tunnel) {
try {
tunnel.stop();
}
catch (err) {
console.error(`Tunnel stop error: ${err.message}`);
}
}
process.exit(0);
};
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
}
export declare const MAX_QUEUED_NOTIFICATIONS = 1000;
export declare const SESSION_IDLE_TIMEOUT_MS: number;
export declare const SESSION_GC_INTERVAL_MS: number;
export declare const SSE_DRAIN_INTERVAL_MS = 100;
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SSE_DRAIN_INTERVAL_MS = exports.SESSION_GC_INTERVAL_MS = exports.SESSION_IDLE_TIMEOUT_MS = exports.MAX_QUEUED_NOTIFICATIONS = void 0;
// Cap on queued notifications per session. Drains happen each time the
// proxy's SSE reader polls (every 100ms in handleSse). With a sane upstream
// this stays at a handful of entries; the cap is here to bound memory when
// the SSE reader is dead/slow and the child is chatty. On overflow we drop
// the oldest entries — the lost notifications are progress/log noise; any
// id-bearing response is matched to a pending request before it ever reaches
// this queue, so request correctness is unaffected.
exports.MAX_QUEUED_NOTIFICATIONS = 1000;
// Idle session GC: close sessions that haven't been used in this many ms.
exports.SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
exports.SESSION_GC_INTERVAL_MS = 60 * 1000; // sweep every minute
// SSE poll interval — how often handleSse drains queued notifications.
exports.SSE_DRAIN_INTERVAL_MS = 100;
import { type ServerConfig } from "../shared/protocol.js";
export declare class McpSession {
private name;
private timeout;
private process;
private stdoutBuffer;
private pending;
private notifications;
private notificationsDropped;
private orphansDropped;
private destroyed;
lastActivity: number;
constructor(name: string, config: ServerConfig, timeout: number);
private failPending;
private handleLine;
sendRequest(jsonRpcLine: string): Promise<string>;
drainNotifications(): string[];
get serverName(): string;
get isAlive(): boolean;
destroy(): void;
}
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.McpSession = void 0;
const node_child_process_1 = require("node:child_process");
const protocol_js_1 = require("../shared/protocol.js");
const constants_js_1 = require("./constants.js");
// One McpSession owns a single MCP server child process and matches its
// JSON-RPC stdout responses to outstanding requests. Notifications (no id)
// are queued for the SSE poller in HostAgent. Lifetime is tied to the host's
// session map: HostAgent.sweepIdleSessions reaps after SESSION_IDLE_TIMEOUT_MS,
// shutdown destroys all entries, and stdin/process errors fail every pending
// request so callers don't sit through their per-request timeout.
class McpSession {
name;
timeout;
process;
stdoutBuffer = new protocol_js_1.LineBuffer();
pending = new Map();
notifications = [];
notificationsDropped = 0;
// Late responses to already-timed-out requests: counted separately so the
// drain log can attribute "child answered after we gave up" distinctly
// from notification-queue overflow.
orphansDropped = 0;
destroyed = false;
lastActivity = Date.now();
constructor(name, config, timeout) {
this.name = name;
this.timeout = timeout;
console.log(`[${name}] Spawning: ${config.command} ${config.args.join(" ")}`);
this.process = (0, node_child_process_1.spawn)(config.command, config.args, {
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, ...config.env },
shell: config.shell ?? false,
});
this.process.stdout.on("data", (chunk) => {
const lines = this.stdoutBuffer.push(chunk.toString("utf-8"));
for (const line of lines) {
this.handleLine(line);
}
});
this.process.stderr.on("data", (chunk) => {
console.error(`[${name}] stderr: ${chunk.toString("utf-8").trimEnd()}`);
});
this.process.on("exit", (code) => {
console.log(`[${name}] Process exited (code=${code})`);
this.destroyed = true;
this.failPending(protocol_js_1.ErrorCode.PROCESS_EXITED, `code=${code}`);
});
this.process.on("error", (err) => {
console.error(`[${name}] Process error: ${err.message}`);
this.destroyed = true;
// Spawn failures (ENOENT, EACCES, …) emit 'error' and may emit
// 'exit' only later or not at all. Without failing pending here,
// any in-flight request blocks until its per-request timeout.
this.failPending(protocol_js_1.ErrorCode.PROCESS_NOT_RUNNING, err.message);
});
// EPIPE / write-after-close on the child's stdin shows up as an 'error'
// on the stream itself, distinct from the process error.
this.process.stdin?.on("error", (err) => {
console.error(`[${name}] stdin error: ${err.message}`);
this.destroyed = true;
this.failPending(protocol_js_1.ErrorCode.PROCESS_NOT_RUNNING, `stdin: ${err.message}`);
});
}
failPending(code, detail) {
for (const [id, p] of this.pending) {
clearTimeout(p.timer);
p.resolve((0, protocol_js_1.jsonRpcError)(code, detail, id));
}
this.pending.clear();
}
handleLine(line) {
let parsed;
try {
parsed = JSON.parse(line);
}
catch {
return; // Not valid JSON, skip
}
// Response shape: id present, no method. (Server-initiated requests
// also have an id, but they carry a method too and belong on the
// notification path so the SSE reader can route them to the bridge.)
const isResponse = parsed.id !== undefined && typeof parsed.method !== "string";
if (isResponse) {
const p = this.pending.get(parsed.id);
if (p) {
// Matched delivery is real activity, so refresh lastActivity. We
// deliberately do NOT refresh it for queued notifications below —
// if the SSE reader is gone, an upstream that chatters
// notifications would otherwise keep this session alive forever
// AND grow the notifications queue. Streams with an active reader
// still bump lastActivity via drainNotifications().
this.lastActivity = Date.now();
clearTimeout(p.timer);
this.pending.delete(parsed.id);
p.resolve(line);
return;
}
// Orphan: a response that arrived after the request already timed
// out (and was answered with REQUEST_TIMEOUT) or carries an id we
// don't know. Dropping is the only correct action — without this
// it would be queued as a notification, evicting real progress/log
// notifications from the bounded ring buffer and adding spurious
// SSE traffic. Counted so the next drain can log a single line.
this.orphansDropped++;
return;
}
// Notification (no id) or server-initiated request (id + method) —
// both belong on the SSE drain path. Bounded ring buffer so a dead/
// slow SSE reader can't grow this without limit; drop the oldest
// entry (FIFO discipline) and account for the loss.
if (this.notifications.length >= constants_js_1.MAX_QUEUED_NOTIFICATIONS) {
this.notifications.shift();
this.notificationsDropped++;
}
this.notifications.push(line);
}
sendRequest(jsonRpcLine) {
if (this.destroyed || !this.process.stdin?.writable) {
return Promise.resolve((0, protocol_js_1.jsonRpcError)(protocol_js_1.ErrorCode.PROCESS_NOT_RUNNING));
}
this.lastActivity = Date.now();
// Inspect the JSON-RPC shape to decide how to handle the body.
let parsed = {};
try {
parsed = JSON.parse(jsonRpcLine);
}
catch {
// Not parseable — fall through and forward verbatim, no matching.
}
// Notification: no id. Response from client for a server-initiated
// request: has id but no method (and result/error). Both are
// fire-and-forget from the host's perspective.
const isNotification = parsed.id === undefined;
const isResponse = !isNotification && typeof parsed.method !== "string";
if (isNotification || isResponse) {
try {
this.process.stdin.write(jsonRpcLine + "\n");
}
catch (err) {
// Best effort — no caller is waiting on a result here.
console.error(`[${this.name}] stdin write failed: ${err.message}`);
}
return Promise.resolve("");
}
const id = parsed.id;
return new Promise((resolve) => {
const timer = setTimeout(() => {
this.pending.delete(id);
resolve((0, protocol_js_1.jsonRpcError)(protocol_js_1.ErrorCode.REQUEST_TIMEOUT, undefined, id));
}, this.timeout);
// Register pending FIRST so a 'error' / stdin 'error' handler that
// fires synchronously during stdin.write() can find this entry via
// failPending() and resolve it. Writing first would leave a tiny
// window where the entry isn't yet registered when the listener
// drains this.pending, leading to a hung promise.
this.pending.set(id, { resolve, timer });
try {
this.process.stdin.write(jsonRpcLine + "\n");
}
catch (err) {
// Synchronous EPIPE on a half-dead child. The async stdin 'error'
// listener may or may not fire for this — fail this entry now so
// the caller doesn't sit through the full request timeout.
if (this.pending.get(id)?.timer === timer) {
clearTimeout(timer);
this.pending.delete(id);
resolve((0, protocol_js_1.jsonRpcError)(protocol_js_1.ErrorCode.PROCESS_NOT_RUNNING, err.message, id));
}
}
});
}
drainNotifications() {
const n = this.notifications;
this.notifications = [];
// SSE listener actively reading — also a sign of life.
if (n.length > 0)
this.lastActivity = Date.now();
if (this.notificationsDropped > 0) {
console.error(`[${this.name}] dropped ${this.notificationsDropped} queued notification(s) (cap=${constants_js_1.MAX_QUEUED_NOTIFICATIONS}); SSE reader was behind`);
this.notificationsDropped = 0;
}
if (this.orphansDropped > 0) {
console.error(`[${this.name}] dropped ${this.orphansDropped} late/unmatched response(s); requests already timed out`);
this.orphansDropped = 0;
}
return n;
}
get serverName() {
return this.name;
}
get isAlive() {
return !this.destroyed;
}
destroy() {
if (this.destroyed)
return;
this.destroyed = true;
if (!this.process.killed)
this.process.kill();
}
}
exports.McpSession = McpSession;
export interface HostTunnel {
url: string;
stop: () => void;
}
export declare function startTunnel(port: number, onUnexpectedExit?: (reason: string) => void): Promise<HostTunnel>;
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.startTunnel = startTunnel;
const cloudflared_1 = require("cloudflared");
// Bound how long we wait for cloudflared to advertise the public URL.
// Anything slower than this is almost always a configuration / network /
// account issue we want to surface to the user instead of hanging silently.
const TUNNEL_STARTUP_TIMEOUT_MS = 30_000;
// Start a quick cloudflared tunnel and resolve once cloudflared advertises
// the public URL. Rejects with a descriptive error if cloudflared:
// - errors out before becoming ready (binary missing, network unreachable,
// account auth failure, etc.),
// - exits before advertising a URL,
// - or doesn't surface a URL within TUNNEL_STARTUP_TIMEOUT_MS.
//
// `onUnexpectedExit` fires only AFTER the URL was advertised — i.e., a
// runtime failure once the tunnel was healthy. Pre-ready failures already
// reject the start() promise, so the caller learns about those
// synchronously and can decide whether to keep the host running locally or
// shut it down.
function startTunnel(port, onUnexpectedExit) {
const tunnel = cloudflared_1.Tunnel.quick(`http://localhost:${port}`);
// Capture the most recent cloudflared error so a subsequent exit/timeout
// can include the real cause in the rejection instead of "did not produce
// a URL".
let lastErrorMessage = null;
tunnel.on("error", (err) => {
lastErrorMessage = err.message;
process.stderr.write(`Tunnel error: ${err.message}\n`);
});
return new Promise((resolveP, rejectP) => {
let settled = false;
let urlReady = false;
const finishStartup = (fn) => {
if (settled)
return;
settled = true;
clearTimeout(startupTimer);
fn();
};
const startupTimer = setTimeout(() => {
finishStartup(() => {
try {
tunnel.stop();
}
catch { /* already gone */ }
const cause = lastErrorMessage ? ` (last error: ${lastErrorMessage})` : "";
rejectP(new Error(`Cloudflare tunnel did not produce a URL within ${TUNNEL_STARTUP_TIMEOUT_MS / 1000}s${cause}`));
});
}, TUNNEL_STARTUP_TIMEOUT_MS);
tunnel.once("url", (url) => {
urlReady = true;
console.log(`Tunnel URL: ${url}`);
finishStartup(() => {
resolveP({
url,
stop: () => { try {
tunnel.stop();
}
catch { /* already stopped */ } },
});
});
});
tunnel.on("exit", (code, signal) => {
const detail = code !== null
? `code ${code}`
: signal !== null ? `signal ${signal}` : "unknown reason";
const errBit = lastErrorMessage ? ` (${lastErrorMessage})` : "";
const reason = `cloudflared exited (${detail})${errBit}`;
if (!urlReady) {
finishStartup(() => rejectP(new Error(reason)));
return;
}
// URL was already advertised — this is a runtime failure. Surface
// through the caller's hook so cli.ts (or whoever owns the lifecycle)
// can log it and decide what to do.
process.stderr.write(`Tunnel ${reason}\n`);
if (onUnexpectedExit)
onUnexpectedExit(reason);
});
});
}
#!/usr/bin/env node
export {};
#!/usr/bin/env node
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const server_js_1 = require("./proxy/server.js");
(0, server_js_1.main)();
import type { Prompt, Tool } from "./types.js";
export declare const TOOL_SEPARATOR = "__";
export declare const TUNNEL_STARTUP_TIMEOUT_MS = 30000;
export declare const PAIRING_WINDOW_MS: number;
export declare const UPSTREAM_REQUEST_TIMEOUT_MS = 120000;
export declare const DISCOVERY_FETCH_TIMEOUT_MS = 15000;
export declare const TOOL_FORWARD_TIMEOUT_MS: number;
export declare const SESSION_DELETE_TIMEOUT_MS = 5000;
export declare const SSE_BACKOFF_INITIAL_MS = 500;
export declare const SSE_BACKOFF_MAX_MS = 10000;
export declare const SSE_CONNECT_TIMEOUT_MS = 15000;
export declare const CONFIGURE_TOOL: Tool;
export declare const CONFIGURE_PROMPT: Prompt;
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CONFIGURE_PROMPT = exports.CONFIGURE_TOOL = exports.SSE_CONNECT_TIMEOUT_MS = exports.SSE_BACKOFF_MAX_MS = exports.SSE_BACKOFF_INITIAL_MS = exports.SESSION_DELETE_TIMEOUT_MS = exports.TOOL_FORWARD_TIMEOUT_MS = exports.DISCOVERY_FETCH_TIMEOUT_MS = exports.UPSTREAM_REQUEST_TIMEOUT_MS = exports.PAIRING_WINDOW_MS = exports.TUNNEL_STARTUP_TIMEOUT_MS = exports.TOOL_SEPARATOR = void 0;
const protocol_js_1 = require("../../shared/protocol.js");
exports.TOOL_SEPARATOR = protocol_js_1.TOOL_NAME_SEPARATOR;
// Pairing-tunnel + bridging budgets.
exports.TUNNEL_STARTUP_TIMEOUT_MS = 30_000; // bring up cloudflared
exports.PAIRING_WINDOW_MS = 10 * 60 * 1000; // hard expiry per pairing
exports.UPSTREAM_REQUEST_TIMEOUT_MS = 120_000; // server→client bridge
// Per-fetch budgets. Discovery + pairing-forward are short — anything that
// can't answer the MCP handshake in this many milliseconds is broken enough
// to surface to the user. Tool calls / prompt gets / resource reads ride a
// much longer budget because they are user-bound (long shell commands,
// large filesystem reads, etc.).
exports.DISCOVERY_FETCH_TIMEOUT_MS = 15_000;
exports.TOOL_FORWARD_TIMEOUT_MS = 5 * 60 * 1000;
exports.SESSION_DELETE_TIMEOUT_MS = 5_000;
exports.SSE_BACKOFF_INITIAL_MS = 500;
exports.SSE_BACKOFF_MAX_MS = 10_000;
// Bound the connect+headers phase only. A blackholed tunnel would otherwise
// leave fetch() waiting on the OS connect timeout (Linux ~127s) before the
// loop could fall through to backoff, freezing list_changed and server-
// initiated requests for that session. The streaming body is NOT bounded
// by this timeout — once headers arrive, the read loop runs under the
// lifecycle signal alone.
exports.SSE_CONNECT_TIMEOUT_MS = 15_000;
// Local tool: always advertised so a client can re-pair without a process
// restart, even if discovery returned zero upstream tools.
exports.CONFIGURE_TOOL = {
name: "configure",
description: "Set up or reconfigure the MCP proxy connection. Returns the setup URL.",
inputSchema: { type: "object", properties: {} },
};
// Local prompt counterpart. Surfaced even when no upstream prompts exist so
// an agent can always pull up the setup URL through prompts/get.
exports.CONFIGURE_PROMPT = {
name: "configure",
description: "Set up or reconfigure the MCP proxy connection.",
};
export declare function timeoutSignal(ms: number, parent?: AbortSignal): AbortSignal;
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.timeoutSignal = timeoutSignal;
// Combine a per-request timeout with an optional caller signal (e.g., a
// session-scoped AbortController). AbortSignal.any() is the floor (Node
// 20.3+); package.json's engines.node enforces it at install time so we
// don't need a polyfill or a runtime guard here.
//
// We use this for every upstream fetch so a wedged tunnel can't pin the
// proxy indefinitely. Discovery uses DISCOVERY_FETCH_TIMEOUT_MS (15s),
// runtime tool forwards use TOOL_FORWARD_TIMEOUT_MS (5min); see constants.ts.
function timeoutSignal(ms, parent) {
const t = AbortSignal.timeout(ms);
return parent ? AbortSignal.any([parent, t]) : t;
}
import { ResourceRouter } from "../routing/router.js";
import type { HostConfig, HostState, PairingConfig, ToolRoute } from "./types.js";
export declare class ProxyState {
config: PairingConfig | null;
hosts: Map<string, HostState>;
toolRoute: Map<string, ToolRoute>;
promptRoute: Map<string, ToolRoute>;
resources: ResourceRouter;
templateRoutes: Array<{
uriTemplate: string;
route: ToolRoute;
}>;
clientCapabilities: Record<string, unknown>;
clientInfo: {
name: string;
version: string;
};
discoveryInflight: Promise<void> | null;
configGeneration: number;
inflight: Map<string | number, ToolRoute>;
progressTokens: Map<string | number, ToolRoute>;
hostHeaders(host: HostConfig): Record<string, string>;
installConfig(config: PairingConfig, hosts: HostConfig[]): void;
resetAfterClose(): void;
}
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ProxyState = void 0;
const protocol_js_1 = require("../../shared/protocol.js");
const router_js_1 = require("../routing/router.js");
// Mutable proxy-wide state. Holds the paired config, host map, route maps,
// and the captured client identity. All long-running components
// (DiscoveryRunner, Forwarder, RequestHandlers, PairingController) read
// and write through this single object so the proxy has exactly one source
// of truth for "what is paired right now". Mutating helpers
// (`installConfig`, `resetAfterClose`) keep the swap atomic — replacing
// every Map at once so an in-flight discovery from a prior pairing can
// detect supersession by comparing identity / generation rather than
// racing in-place mutations.
class ProxyState {
config = null;
hosts = new Map();
toolRoute = new Map();
promptRoute = new Map();
resources = new router_js_1.ResourceRouter();
templateRoutes = [];
// Client-declared capabilities + info, captured at initialize and
// forwarded upstream when we open each MCP session. This is what makes
// sampling / elicitation / roots actually work end-to-end: the upstream
// server sees the real client's feature flags, not an empty object.
// Pairing-time discovery uses these too, so the setup UI sees the same
// capability set the runtime path will see.
clientCapabilities = {};
clientInfo = { name: protocol_js_1.PACKAGE_NAME, version: protocol_js_1.PACKAGE_VERSION };
// Single-flight guard: every caller of discoverServers awaits the same
// run. Two passes racing would double-spawn upstream sessions and let
// their session-id rotations clobber each other's toolRoute writes.
discoveryInflight = null;
// Supersession token for config/hosts. handleComplete is the sole writer
// and bumps this on every swap; long-running readers (discovery, etc.)
// snapshot it at the start and refuse to write back to shared state if
// the value has moved on.
configGeneration = 0;
// requestId → route for in-flight agent→server requests. Used to route
// notifications/cancelled to the originating session.
inflight = new Map();
// progressToken → route. Populated when the agent issues a request whose
// params._meta carries a progressToken; consulted on
// notifications/progress so the proxy forwards the update to the right
// upstream session instead of dropping or broadcasting it.
progressTokens = new Map();
hostHeaders(host) {
return {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
Authorization: `Bearer ${host.authToken}`,
};
}
// Atomic swap on re-pair. Bumps the generation token, then replaces the
// host map with brand-new Maps so any in-flight discovery from a prior
// pairing can detect it has been superseded by comparing identity /
// token rather than racing in-place mutations. discoveryInflight is
// nulled too, otherwise the next discoverServers() call would reuse the
// prior pairing's promise and skip its own run.
installConfig(config, hosts) {
this.configGeneration++;
this.config = config;
const newHosts = new Map();
for (const h of hosts) {
newHosts.set(h.id, {
config: h,
servers: new Map(),
sseControllers: new Map(),
listed: false,
pendingServers: new Set(),
});
}
this.hosts = newHosts;
this.toolRoute = new Map();
this.promptRoute = new Map();
this.resources.clear();
this.templateRoutes = [];
this.discoveryInflight = null;
}
// Drop every host/route after sessions have been closed. Called from
// PairingController.closeAllSessions on its way out of an old pairing.
resetAfterClose() {
this.hosts = new Map();
this.toolRoute = new Map();
this.promptRoute = new Map();
this.resources.clear();
this.templateRoutes = [];
}
}
exports.ProxyState = ProxyState;
export interface Tool {
name: string;
description?: string;
inputSchema?: unknown;
}
export interface Prompt {
name: string;
description?: string;
arguments?: unknown;
}
export interface Resource {
uri: string;
name?: string;
description?: string;
mimeType?: string;
}
export interface ResourceTemplate {
uriTemplate: string;
name?: string;
description?: string;
mimeType?: string;
}
export interface HostConfig {
id: string;
tunnelUrl: string;
authToken: string;
label?: string;
}
export interface PairingConfig {
hosts: HostConfig[];
selectedServers?: string[];
selectedTools?: string[];
sealed: boolean;
}
export interface ServerState {
sessionId?: string;
tools: Tool[];
prompts: Prompt[];
resources: Resource[];
resourceTemplates: ResourceTemplate[];
pendingPrompts: boolean;
pendingResources: boolean;
pendingResourceTemplates: boolean;
subscriptions: Set<string>;
}
export interface HostState {
config: HostConfig;
servers: Map<string, ServerState>;
sseControllers: Map<string, AbortController>;
listed: boolean;
pendingServers: Set<string>;
}
export interface ToolRoute {
hostId: string;
serverName: string;
originalName: string;
}
"use strict";
// Type definitions shared across proxy submodules. Kept in one place so the
// route map / pairing config / server state contracts are visible without
// chasing imports through every file.
Object.defineProperty(exports, "__esModule", { value: true });
import type { Prompt, Resource, ResourceTemplate, Tool } from "../core/types.js";
interface InitResponse {
ok: boolean;
status: number;
sessionId?: string;
body: string;
}
export declare function initializeServer(targetUrl: string, baseHeaders: Record<string, string>, name: string, clientCapabilities: Record<string, unknown>, clientInfo: {
name: string;
version: string;
}): Promise<InitResponse>;
export declare function sendInitialized(targetUrl: string, sessionHeaders: Record<string, string>): Promise<void>;
export declare function fetchTools(targetUrl: string, headers: Record<string, string>, name: string): Promise<Tool[]>;
export declare function fetchPromptsStrict(targetUrl: string, headers: Record<string, string>, name: string): Promise<Prompt[]>;
export declare function fetchResourcesStrict(targetUrl: string, headers: Record<string, string>, name: string): Promise<Resource[]>;
export declare function fetchResourceTemplatesStrict(targetUrl: string, headers: Record<string, string>, name: string): Promise<ResourceTemplate[]>;
export interface ServerDiscoveryResult {
sessionId: string | undefined;
tools: Tool[];
prompts: Prompt[];
resources: Resource[];
resourceTemplates: ResourceTemplate[];
pendingPrompts: boolean;
pendingResources: boolean;
pendingResourceTemplates: boolean;
capErrors: {
prompts?: string;
resources?: string;
resourceTemplates?: string;
};
}
export declare class DiscoveryError extends Error {
readonly sessionId: string | undefined;
constructor(message: string, sessionId: string | undefined);
}
export declare function discoverServerCapabilities(targetUrl: string, baseHeaders: Record<string, string>, name: string, clientCapabilities: Record<string, unknown>, clientInfo: {
name: string;
version: string;
}, log?: (line: string) => void): Promise<ServerDiscoveryResult>;
export declare function deleteSession(targetUrl: string, headers: Record<string, string>): Promise<void>;
export declare function listHostServers(hostUrl: string, headers: Record<string, string>, log?: (line: string) => void): Promise<string[]>;
export {};
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.DiscoveryError = void 0;
exports.initializeServer = initializeServer;
exports.sendInitialized = sendInitialized;
exports.fetchTools = fetchTools;
exports.fetchPromptsStrict = fetchPromptsStrict;
exports.fetchResourcesStrict = fetchResourcesStrict;
exports.fetchResourceTemplatesStrict = fetchResourceTemplatesStrict;
exports.discoverServerCapabilities = discoverServerCapabilities;
exports.deleteSession = deleteSession;
exports.listHostServers = listHostServers;
const protocol_js_1 = require("../../shared/protocol.js");
const constants_js_1 = require("../core/constants.js");
const fetch_timeout_js_1 = require("../core/fetch-timeout.js");
async function initializeServer(targetUrl, baseHeaders, name, clientCapabilities, clientInfo) {
const resp = await fetch(targetUrl, {
method: "POST",
headers: baseHeaders,
signal: (0, fetch_timeout_js_1.timeoutSignal)(constants_js_1.DISCOVERY_FETCH_TIMEOUT_MS),
body: JSON.stringify({
jsonrpc: "2.0",
id: `init-${name}`,
method: "initialize",
params: {
protocolVersion: protocol_js_1.MCP_PROTOCOL_VERSION,
capabilities: clientCapabilities,
clientInfo,
},
}),
});
return {
ok: resp.ok,
status: resp.status,
sessionId: resp.headers.get("mcp-session-id") ?? undefined,
body: await resp.text(),
};
}
async function sendInitialized(targetUrl, sessionHeaders) {
await fetch(targetUrl, {
method: "POST",
headers: sessionHeaders,
signal: (0, fetch_timeout_js_1.timeoutSignal)(constants_js_1.DISCOVERY_FETCH_TIMEOUT_MS),
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized", params: {} }),
});
}
// Reject malformed envelopes (no `result` object, or expected field is not
// an array) with a thrown error rather than silently returning []. A broken
// upstream that answers a list call with `{}` or `{result: null}` would
// otherwise be indistinguishable from "feature absent" — the runtime proxy
// would mark the capability as healthy-but-empty, and the pairing UI would
// claim the server has zero tools/prompts/resources.
function extractListField(data, method, resultField) {
if (!data || typeof data !== "object") {
throw new Error(`${method} response is not a JSON object`);
}
const result = data.result;
if (!result || typeof result !== "object") {
throw new Error(`${method} response is missing the \`result\` object`);
}
const list = result[resultField];
if (!Array.isArray(list)) {
throw new Error(`${method} response is missing the \`result.${resultField}\` array`);
}
return list;
}
async function fetchTools(targetUrl, headers, name) {
const resp = await fetch(targetUrl, {
method: "POST",
headers,
signal: (0, fetch_timeout_js_1.timeoutSignal)(constants_js_1.DISCOVERY_FETCH_TIMEOUT_MS),
body: JSON.stringify({ jsonrpc: "2.0", id: `tools-${name}`, method: "tools/list", params: {} }),
});
if (!resp.ok)
throw new Error(`tools/list HTTP ${resp.status}: ${(await resp.text()).slice(0, 200)}`);
let data;
try {
data = await resp.json();
}
catch (err) {
throw new Error(`tools/list returned malformed JSON: ${err.message}`);
}
const error = data.error;
if (error)
throw new Error(`tools/list error: ${error.message ?? JSON.stringify(error)}`);
return extractListField(data, "tools/list", "tools");
}
// JSON-RPC METHOD_NOT_FOUND — what an MCP server returns when it doesn't
// support a capability. Treated as "feature absent" (empty list), distinct
// from a transport blip which the strict variants surface as a throw.
const METHOD_NOT_FOUND = -32601;
// Run a tools/prompts/resources-style list call against the upstream and
// extract the list field. Throws on any transport, parse, or JSON-RPC
// failure other than METHOD_NOT_FOUND, which collapses to []. Callers
// (initial discovery, capability retry, refresh paths) catch and decide
// what to do — preserve cached state, mark pending, fail outright.
// Centralising the request/response shape here keeps the four list calls
// from drifting from each other.
async function fetchListStrict(targetUrl, headers, reqId, method, resultField) {
const resp = await fetch(targetUrl, {
method: "POST",
headers,
signal: (0, fetch_timeout_js_1.timeoutSignal)(constants_js_1.DISCOVERY_FETCH_TIMEOUT_MS),
body: JSON.stringify({ jsonrpc: "2.0", id: reqId, method, params: {} }),
});
if (!resp.ok)
throw new Error(`${method} HTTP ${resp.status}: ${(await resp.text()).slice(0, 200)}`);
let data;
try {
data = await resp.json();
}
catch (err) {
throw new Error(`${method} returned malformed JSON: ${err.message}`);
}
const error = data.error;
if (error) {
if (error.code === METHOD_NOT_FOUND)
return [];
throw new Error(`${method} error: ${error.message ?? JSON.stringify(error)}`);
}
return extractListField(data, method, resultField);
}
// Strict variants: throw on transport / non-METHOD_NOT_FOUND JSON-RPC
// errors so the caller can decide whether to preserve cached state. Used
// by refresh paths that must NOT wipe a server's prompts/resources on a
// transient blip.
function fetchPromptsStrict(targetUrl, headers, name) {
return fetchListStrict(targetUrl, headers, `prompts-${name}`, "prompts/list", "prompts");
}
function fetchResourcesStrict(targetUrl, headers, name) {
return fetchListStrict(targetUrl, headers, `resources-${name}`, "resources/list", "resources");
}
function fetchResourceTemplatesStrict(targetUrl, headers, name) {
return fetchListStrict(targetUrl, headers, `templates-${name}`, "resources/templates/list", "resourceTemplates");
}
// Errors thrown out of the handshake carry the captured session id, if
// any, so the caller can DELETE the orphaned upstream session after a
// post-init failure (sendInitialized / tools/list). Without this, a
// transient JSON-RPC error on tools/list would leak a child process on
// the host until idle GC reaped it ~30 minutes later.
class DiscoveryError extends Error {
sessionId;
constructor(message, sessionId) {
super(message);
this.sessionId = sessionId;
this.name = "DiscoveryError";
}
}
exports.DiscoveryError = DiscoveryError;
// Single source of truth for the per-server MCP handshake: initialize →
// notifications/initialized → tools/list (required) → prompts / resources /
// templates (each optional, recorded as pending on failure). Used by both
// the runtime discovery path and the pairing-mediated discovery endpoint
// so the browser sees the same capability set the proxy will see at
// runtime — including using the real MCP client's capabilities/clientInfo
// rather than synthetic browser values.
async function discoverServerCapabilities(targetUrl, baseHeaders, name, clientCapabilities, clientInfo, log) {
let sessionId;
try {
const init = await initializeServer(targetUrl, baseHeaders, name, clientCapabilities, clientInfo);
// Capture the session id BEFORE parsing the body. Once the host returned
// 200 with a session header it has minted a child process, so the catch
// path below needs the id to clean it up even if the JSON payload is an
// error or malformed.
sessionId = init.sessionId;
if (!init.ok) {
throw new Error(`initialize HTTP ${init.status}: ${init.body.slice(0, 200)}`);
}
let initData;
try {
initData = JSON.parse(init.body);
}
catch (err) {
throw new Error(`initialize returned malformed JSON: ${err.message}`);
}
if (initData.error) {
throw new Error(`initialize error: ${initData.error.message ?? JSON.stringify(initData.error)}`);
}
const sessionHeaders = { ...baseHeaders };
if (sessionId)
sessionHeaders["Mcp-Session-Id"] = sessionId;
await sendInitialized(targetUrl, sessionHeaders);
const tools = await fetchTools(targetUrl, sessionHeaders, name);
// METHOD_NOT_FOUND on a capability is "feature absent" → empty list and
// no pending flag. Any other failure (transport, JSON-RPC error,
// malformed body) leaves the capability empty and sets the per-capability
// pending flag so the caller can either retry (runtime) or surface the
// failure to the user (pairing).
const [promptsResult, resourcesResult, templatesResult] = await Promise.allSettled([
fetchPromptsStrict(targetUrl, sessionHeaders, name),
fetchResourcesStrict(targetUrl, sessionHeaders, name),
fetchResourceTemplatesStrict(targetUrl, sessionHeaders, name),
]);
const capErrors = {};
const prompts = promptsResult.status === "fulfilled" ? promptsResult.value : [];
const pendingPrompts = promptsResult.status === "rejected";
if (pendingPrompts) {
capErrors.prompts = promptsResult.reason.message;
log?.(` [${name}] prompts/list failed (will retry): ${capErrors.prompts}`);
}
const resources = resourcesResult.status === "fulfilled" ? resourcesResult.value : [];
const pendingResources = resourcesResult.status === "rejected";
if (pendingResources) {
capErrors.resources = resourcesResult.reason.message;
log?.(` [${name}] resources/list failed (will retry): ${capErrors.resources}`);
}
const resourceTemplates = templatesResult.status === "fulfilled" ? templatesResult.value : [];
const pendingResourceTemplates = templatesResult.status === "rejected";
if (pendingResourceTemplates) {
capErrors.resourceTemplates = templatesResult.reason.message;
log?.(` [${name}] resources/templates/list failed (will retry): ${capErrors.resourceTemplates}`);
}
return {
sessionId,
tools,
prompts,
resources,
resourceTemplates,
pendingPrompts,
pendingResources,
pendingResourceTemplates,
capErrors,
};
}
catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new DiscoveryError(message, sessionId);
}
}
async function deleteSession(targetUrl, headers) {
try {
await fetch(targetUrl, {
method: "DELETE",
headers,
signal: (0, fetch_timeout_js_1.timeoutSignal)(constants_js_1.SESSION_DELETE_TIMEOUT_MS),
});
}
catch {
/* host unreachable — idle GC will reap eventually */
}
}
// List the servers a host advertises at GET /. Used by both runtime
// discovery (server.ts) and pairing-mediated discovery (the setup page,
// via the proxy's pairing endpoint). Filters server names through the
// shared validator so an upstream advertising a name the proxy/page
// can't safely route is dropped here rather than failing later in init —
// callers (runtime + pairing UI) get a single, consistent view of what
// the proxy will actually accept. Optional `log` surfaces dropped names
// to the operator on the runtime path; pairing leaves it unset so the
// UI just doesn't render unroutable rows.
async function listHostServers(hostUrl, headers, log) {
const resp = await fetch(`${hostUrl}/`, {
method: "GET",
headers,
signal: (0, fetch_timeout_js_1.timeoutSignal)(constants_js_1.DISCOVERY_FETCH_TIMEOUT_MS),
});
if (!resp.ok) {
throw new Error(`list HTTP ${resp.status}: ${(await resp.text()).slice(0, 200)}`);
}
let data;
try {
data = await resp.json();
}
catch (err) {
throw new Error(`list returned malformed JSON: ${err.message}`);
}
const servers = data.servers;
if (!Array.isArray(servers)) {
throw new Error("list response is missing the `servers` array");
}
const out = [];
for (const s of servers) {
if (typeof s !== "string")
continue;
const reason = (0, protocol_js_1.validateServerName)(s);
if (reason) {
log?.(` [${s}] skipped: ${reason}`);
continue;
}
out.push(s);
}
return out;
}
import type { ProxyState } from "../core/state.js";
import type { HostState, ServerState } from "../core/types.js";
import type { SseReader } from "../runtime/sse.js";
export declare class DiscoveryRunner {
private readonly state;
private readonly sse;
private readonly log;
constructor(state: ProxyState, sse: SseReader, log: (line: string) => void);
captureSessionId(host: HostState, serverName: string, server: ServerState, newId: string | null): void;
private static hasPendingCapabilities;
retryDiscoveryIfNeeded(): Promise<void>;
discoverServers(): Promise<void>;
private runDiscovery;
private discoverHost;
private retryPendingCapabilities;
initServer(host: HostState, name: string): Promise<void>;
rebuildToolRoute(): void;
refreshTools(host: HostState, serverName: string): Promise<void>;
refreshPrompts(host: HostState, serverName: string): Promise<void>;
refreshResources(host: HostState, serverName: string): Promise<void>;
}
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.DiscoveryRunner = void 0;
const constants_js_1 = require("../core/constants.js");
const filtering_js_1 = require("../routing/filtering.js");
const client_js_1 = require("./client.js");
// Owns the proxy's discovery + refresh + per-server init logic. One pass
// runs at a time (single-flighted via state.discoveryInflight); each
// individual server is initialised through the shared
// discoverServerCapabilities helper so pairing-time and runtime use the
// exact same handshake.
class DiscoveryRunner {
state;
sse;
log;
constructor(state, sse, log) {
this.state = state;
this.sse = sse;
this.log = log;
}
// After every upstream POST: if the host returned a different session
// id, restart the SSE notification loop bound to the new id. Without
// this, notifications go to the old session's queue and are silently
// lost. Lives here because it owns both the server state mutation and
// the SSE handle — Forwarder/Bridge call into it through a callback.
captureSessionId(host, serverName, server, newId) {
if (!newId)
return;
if (server.sessionId === newId)
return;
server.sessionId = newId;
this.sse.start(host, serverName, newId);
}
// True if any server stored under this host still has a capability list
// that needs to be re-fetched. Drives both the discovery host filter
// and retryDiscoveryIfNeeded's gate.
static hasPendingCapabilities(host) {
for (const server of host.servers.values()) {
if (server.pendingPrompts || server.pendingResources || server.pendingResourceTemplates)
return true;
}
return false;
}
// Retry-on-demand: kick another pass if any host hasn't fully settled.
// "Fully settled" means listing succeeded AND every server it named has
// an entry in host.servers (pendingServers is empty) AND every stored
// server has all three capability lists committed (no pending* flags).
// discoverServers is single-flighted, runDiscovery skips fully-settled
// hosts, discoverHost only re-runs init for the residual pendingServers,
// and retryPendingCapabilities only refetches the capability lists that
// are still pending — so this stays cheap once the system has stabilised.
async retryDiscoveryIfNeeded() {
if (!this.state.config)
return;
if (Array.from(this.state.hosts.values()).some((h) => !h.listed || h.pendingServers.size > 0 || DiscoveryRunner.hasPendingCapabilities(h))) {
await this.discoverServers();
}
}
discoverServers() {
if (this.state.discoveryInflight)
return this.state.discoveryInflight;
if (!this.state.config)
return Promise.resolve();
const run = this.runDiscovery().finally(() => {
this.state.discoveryInflight = null;
});
this.state.discoveryInflight = run;
return run;
}
async runDiscovery() {
if (!this.state.config)
return;
// Snapshot the generation and the host references at the start. If a
// re-pair swaps in a new pairing while we're awaiting upstream calls,
// this run is superseded: discoverHost's writes go to detached host
// objects (harmless), and we skip rebuildToolRoute so we don't
// clobber the new pairing's state.
const gen = this.state.configGeneration;
const hostsSnapshot = Array.from(this.state.hosts.values());
// Parallel host discovery. One slow host no longer delays the others —
// each host's failures are caught inside discoverHost so a single
// rejected promise can't poison the batch (allSettled is still used
// for defensive symmetry). A host with listing done but lingering
// pendingServers gets re-entered so its residual inits retry.
await Promise.allSettled(hostsSnapshot
.filter((h) => !h.listed || h.pendingServers.size > 0 || DiscoveryRunner.hasPendingCapabilities(h))
.map((h) => this.discoverHost(h)));
if (gen !== this.state.configGeneration)
return;
this.rebuildToolRoute();
this.log(` Total tools: ${this.state.toolRoute.size}\n`);
}
async discoverHost(host) {
this.log(` Host [${host.config.id}] ${host.config.tunnelUrl}`);
if (!host.listed) {
let serverNames;
try {
// listHostServers itself runs each advertised name through the
// shared validator and logs anything dropped, so the result is
// already safe to route — no second filter needed here.
serverNames = await (0, client_js_1.listHostServers)(host.config.tunnelUrl, this.state.hostHeaders(host.config), this.log);
}
catch (err) {
this.log(` Discovery failed: ${err.message}`);
return;
}
// Drop deselected servers BEFORE we ever open a session. Without this
// the proxy spawns a child for every advertised server, forwards the
// real client capabilities upstream, and leaves an SSE loop attached
// — even for servers the user explicitly unchecked. selectedServers
// is a least-privilege boundary, so it has to gate side-effects, not
// just the agent-facing surface.
const selected = serverNames.filter((name) => {
if ((0, filtering_js_1.isServerSelected)(this.state.config, host.config.id, name))
return true;
this.log(` [${name}] skipped: not in selectedServers`);
return false;
});
this.log(` discovered: ${selected.join(", ") || "(none)"}`);
for (const name of selected)
host.pendingServers.add(name);
host.listed = true;
}
else if (host.pendingServers.size > 0) {
this.log(` retrying inits: ${Array.from(host.pendingServers).join(", ")}`);
}
// Snapshot first — initServer mutates pendingServers on success, and
// iterating a Set we're deleting from is footgun-territory. Servers
// within a host stay sequential: they share a session lifecycle and
// ordering keeps stderr readable.
for (const name of Array.from(host.pendingServers)) {
await this.initServer(host, name);
}
await this.retryPendingCapabilities(host);
}
// Re-fetch any capability list that failed during init for an
// already-stored server. Each per-capability flag is independent: a
// server with healthy tools but a transient prompts/list failure stays
// online and serves tools, and only the failed list is retried here.
// Strict variants keep the cached value on failure (preserve-on-failure)
// so a transient blip on the retry doesn't wipe what we already have.
async retryPendingCapabilities(host) {
for (const [serverName, server] of host.servers) {
if (!server.sessionId)
continue;
if (!server.pendingPrompts && !server.pendingResources && !server.pendingResourceTemplates)
continue;
const target = `${host.config.tunnelUrl}/servers/${serverName}`;
const headers = { ...this.state.hostHeaders(host.config), "Mcp-Session-Id": server.sessionId };
if (server.pendingPrompts) {
try {
server.prompts = await (0, client_js_1.fetchPromptsStrict)(target, headers, serverName);
server.pendingPrompts = false;
this.log(` [${host.config.id}/${serverName}] prompts retry ok: ${server.prompts.length}`);
}
catch (err) {
this.log(` [${host.config.id}/${serverName}] prompts retry failed: ${err.message}`);
}
}
if (server.pendingResources) {
try {
server.resources = await (0, client_js_1.fetchResourcesStrict)(target, headers, serverName);
server.pendingResources = false;
this.log(` [${host.config.id}/${serverName}] resources retry ok: ${server.resources.length}`);
}
catch (err) {
this.log(` [${host.config.id}/${serverName}] resources retry failed: ${err.message}`);
}
}
if (server.pendingResourceTemplates) {
try {
server.resourceTemplates = await (0, client_js_1.fetchResourceTemplatesStrict)(target, headers, serverName);
server.pendingResourceTemplates = false;
this.log(` [${host.config.id}/${serverName}] templates retry ok: ${server.resourceTemplates.length}`);
}
catch (err) {
this.log(` [${host.config.id}/${serverName}] templates retry failed: ${err.message}`);
}
}
}
}
async initServer(host, name) {
const targetUrl = `${host.config.tunnelUrl}/servers/${name}`;
const headers = this.state.hostHeaders(host.config);
let result;
try {
result = await (0, client_js_1.discoverServerCapabilities)(targetUrl, headers, name, this.state.clientCapabilities, this.state.clientInfo, (line) => this.log(line));
}
catch (err) {
// Leave name in pendingServers so the next on-demand discovery pass
// retries the init. The list call already succeeded — only the per-
// server init is in residue. Best-effort cleanup of any orphaned
// upstream session the failed handshake left behind.
const dErr = err;
this.log(` [${name}] init failed: ${dErr.message}`);
if (dErr.sessionId) {
void (0, client_js_1.deleteSession)(targetUrl, { ...headers, "Mcp-Session-Id": dErr.sessionId });
}
return;
}
const state = {
sessionId: result.sessionId,
tools: result.tools,
prompts: result.prompts,
resources: result.resources,
resourceTemplates: result.resourceTemplates,
pendingPrompts: result.pendingPrompts,
pendingResources: result.pendingResources,
pendingResourceTemplates: result.pendingResourceTemplates,
// Fresh session starts with no subscriptions. Stale-session recovery
// in Forwarder snapshots the prior set BEFORE calling initServer and
// replays it onto the new state, so an init that runs as part of
// recovery still ends up with the right subscriptions populated.
subscriptions: new Set(),
};
host.servers.set(name, state);
host.pendingServers.delete(name);
if (result.sessionId)
this.sse.start(host, name, result.sessionId);
this.log(` [${name}] ${result.tools.length} tools, ${result.prompts.length} prompts, ${result.resources.length} resources, ${result.resourceTemplates.length} templates`);
}
rebuildToolRoute() {
this.state.toolRoute.clear();
this.state.promptRoute.clear();
this.state.resources.clear();
for (const host of this.state.hosts.values()) {
for (const [serverName, state] of host.servers) {
const route = (originalName) => ({ hostId: host.config.id, serverName, originalName });
for (const tool of state.tools) {
const prefixed = `${host.config.id}${constants_js_1.TOOL_SEPARATOR}${serverName}${constants_js_1.TOOL_SEPARATOR}${tool.name}`;
this.state.toolRoute.set(prefixed, route(tool.name));
}
for (const prompt of state.prompts) {
const prefixed = `${host.config.id}${constants_js_1.TOOL_SEPARATOR}${serverName}${constants_js_1.TOOL_SEPARATOR}${prompt.name}`;
this.state.promptRoute.set(prefixed, route(prompt.name));
}
const collisions = this.state.resources.add(host.config.id, serverName, state.resources, state.resourceTemplates);
for (const line of collisions)
this.log(` ${line}`);
}
}
this.state.templateRoutes = this.state.resources.templateEntries();
}
// --- Refresh on list_changed ---
async refreshTools(host, serverName) {
const server = host.servers.get(serverName);
if (!server || !server.sessionId)
return;
const target = `${host.config.tunnelUrl}/servers/${serverName}`;
const headers = { ...this.state.hostHeaders(host.config), "Mcp-Session-Id": server.sessionId };
try {
server.tools = await (0, client_js_1.fetchTools)(target, headers, serverName);
}
catch {
return;
}
this.rebuildToolRoute();
this.log(` [${host.config.id}/${serverName}] tools refreshed: ${server.tools.length}`);
}
async refreshPrompts(host, serverName) {
const server = host.servers.get(serverName);
if (!server || !server.sessionId)
return;
const target = `${host.config.tunnelUrl}/servers/${serverName}`;
const headers = { ...this.state.hostHeaders(host.config), "Mcp-Session-Id": server.sessionId };
// Strict + preserve-on-failure: a transient HTTP/JSON-RPC blip during
// refresh used to wipe the cached prompt list and hide prompts until
// another list_changed arrived. Now we only commit the new list when
// the fetch actually succeeds.
let next;
try {
next = await (0, client_js_1.fetchPromptsStrict)(target, headers, serverName);
}
catch (err) {
this.log(` [${host.config.id}/${serverName}] prompts refresh failed (keeping cached ${server.prompts.length}): ${err.message}`);
return;
}
server.prompts = next;
this.rebuildToolRoute();
this.log(` [${host.config.id}/${serverName}] prompts refreshed: ${server.prompts.length}`);
}
async refreshResources(host, serverName) {
const server = host.servers.get(serverName);
if (!server || !server.sessionId)
return;
const target = `${host.config.tunnelUrl}/servers/${serverName}`;
const headers = { ...this.state.hostHeaders(host.config), "Mcp-Session-Id": server.sessionId };
// Refresh both lists in parallel — the SSE notification only says
// "something changed", not whether it's the concrete list or the
// templates. At this scale a double-fetch is cheaper than waiting on
// a sequential chain. Each side is strict + preserve-on-failure so a
// transient failure on one list doesn't take the other down with it.
const [resourcesResult, templatesResult] = await Promise.allSettled([
(0, client_js_1.fetchResourcesStrict)(target, headers, serverName),
(0, client_js_1.fetchResourceTemplatesStrict)(target, headers, serverName),
]);
let resourcesChanged = false;
let templatesChanged = false;
if (resourcesResult.status === "fulfilled") {
server.resources = resourcesResult.value;
resourcesChanged = true;
}
else {
this.log(` [${host.config.id}/${serverName}] resources refresh failed (keeping cached ${server.resources.length}): ${resourcesResult.reason.message}`);
}
if (templatesResult.status === "fulfilled") {
server.resourceTemplates = templatesResult.value;
templatesChanged = true;
}
else {
this.log(` [${host.config.id}/${serverName}] templates refresh failed (keeping cached ${server.resourceTemplates.length}): ${templatesResult.reason.message}`);
}
if (!resourcesChanged && !templatesChanged)
return;
this.rebuildToolRoute();
this.log(` [${host.config.id}/${serverName}] resources refreshed: ${server.resources.length} concrete, ${server.resourceTemplates.length} templates`);
}
}
exports.DiscoveryRunner = DiscoveryRunner;
import type { HostConfig, PairingConfig } from "../core/types.js";
export type PairingConfigValidation = {
ok: true;
hosts: HostConfig[];
} | {
ok: false;
error: string;
};
export declare function validatePairingConfig(cfg: PairingConfig): PairingConfigValidation;
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.validatePairingConfig = validatePairingConfig;
const protocol_js_1 = require("../../shared/protocol.js");
const constants_js_1 = require("../core/constants.js");
const validation_js_1 = require("./validation.js");
// Validate a PairingConfig submitted by the setup page. Pure: returns the
// canonicalised host list (tunnelUrl reduced to its origin so query strings
// /paths can't smuggle through) or an error string. Centralised here rather
// than inlined in handleComplete so the rules are reviewable independently
// of the orchestrator. Every failure mode produces a human-readable error
// the setup page surfaces back to the user.
function validatePairingConfig(cfg) {
if (!cfg || cfg.sealed !== true)
return { ok: false, error: "config must be sealed" };
if (!Array.isArray(cfg.hosts) || cfg.hosts.length === 0) {
return { ok: false, error: "hosts must be a non-empty array" };
}
if (cfg.selectedServers !== undefined) {
if (!Array.isArray(cfg.selectedServers)) {
return { ok: false, error: "selectedServers must be an array if provided" };
}
// `undefined` means "allow all", which is a valid distinct shape. An
// explicit empty array would seal a config that exposes zero servers —
// exactly what the UI's "must select at least one" rule prevents on the
// client. Reject it here so a direct caller of /pair/complete can't
// bypass that gate and persist a paired-but-empty proxy.
if (cfg.selectedServers.length === 0) {
return { ok: false, error: "selectedServers must not be empty if provided" };
}
}
if (cfg.selectedTools !== undefined && !Array.isArray(cfg.selectedTools)) {
return { ok: false, error: "selectedTools must be an array if provided" };
}
const seen = new Set();
const validatedHosts = [];
for (const h of cfg.hosts) {
if (!h || typeof h !== "object")
return { ok: false, error: "each host must be an object" };
if (typeof h.id !== "string" || !h.id)
return { ok: false, error: "host.id is required" };
const reason = (0, protocol_js_1.validateServerName)(h.id);
if (reason)
return { ok: false, error: `host.id "${h.id}" ${reason}` };
if (seen.has(h.id))
return { ok: false, error: `duplicate host.id "${h.id}"` };
seen.add(h.id);
if (typeof h.tunnelUrl !== "string" || !h.tunnelUrl) {
return { ok: false, error: `host "${h.id}".tunnelUrl is required` };
}
if (typeof h.authToken !== "string" || !h.authToken) {
return { ok: false, error: `host "${h.id}".authToken is required` };
}
const validatedUrl = (0, validation_js_1.allowedTunnelUrl)(h.tunnelUrl);
if (!validatedUrl) {
return { ok: false, error: `host "${h.id}".tunnelUrl is not on the allowlist (must be https on a Cloudflare tunnel domain)` };
}
validatedHosts.push({
id: h.id,
tunnelUrl: validatedUrl.origin,
authToken: h.authToken,
...(typeof h.label === "string" && h.label ? { label: h.label } : {}),
});
}
// selectedServers entries must reference hosts we actually know about,
// otherwise an attacker (or a stale UI) could shape the config so the
// server-level allow list never triggered. Catching it here keeps the
// invariant on the way IN to ProxyServer state.
//
// Server names may themselves contain `__`, so we don't `split` — we
// take the prefix before the FIRST `__` as the hostId and require the
// remainder (the serverName) to be non-empty so a malformed entry like
// `host__` doesn't sneak through as a valid-looking allowlist line that
// can never match any real server.
const validHostIds = new Set(validatedHosts.map((h) => h.id));
if (cfg.selectedServers) {
for (const entry of cfg.selectedServers) {
if (typeof entry !== "string") {
return { ok: false, error: `selectedServers entries must look like "<hostId>__<serverName>"` };
}
const sep = entry.indexOf(constants_js_1.TOOL_SEPARATOR);
if (sep <= 0 || sep + constants_js_1.TOOL_SEPARATOR.length >= entry.length) {
return { ok: false, error: `selectedServers entry "${entry}" must look like "<hostId>__<serverName>"` };
}
const hostId = entry.slice(0, sep);
if (!validHostIds.has(hostId)) {
return { ok: false, error: `selectedServers references unknown host "${hostId}"` };
}
}
}
// selectedTools entries are full prefixed tool names
// `<hostId>__<serverName>__<toolName>`. We can't validate <toolName>
// here (tools are discovered post-pair), but the prefix MUST point at
// an exposed server AND the entry must contain enough segments to
// actually carry a tool name: an entry whose server prefix isn't in
// selectedServers (when defined) — or whose hostId isn't a known host
// (when selectedServers is omitted) — is unreachable noise that hides
// bugs in the UI or stale configs. Tool/server names may themselves
// contain `__`, so we prefix-match the allowed scope rather than
// splitting on the separator. Shape is enforced separately by requiring
// at least two `__` occurrences — the prefix-match alone degraded to a
// hostId-only check when `selectedServers` was omitted, letting entries
// like `host__bogus` survive without a tool segment.
if (cfg.selectedTools) {
const allowedServerPrefixes = cfg.selectedServers
? cfg.selectedServers.map((s) => `${s}${constants_js_1.TOOL_SEPARATOR}`)
: Array.from(validHostIds).map((id) => `${id}${constants_js_1.TOOL_SEPARATOR}`);
const scopeLabel = cfg.selectedServers ? "selectedServers" : "any known host";
for (const entry of cfg.selectedTools) {
if (typeof entry !== "string" || !entry) {
return { ok: false, error: "selectedTools entries must be non-empty strings" };
}
const firstSep = entry.indexOf(constants_js_1.TOOL_SEPARATOR);
const secondSep = firstSep < 0 ? -1 : entry.indexOf(constants_js_1.TOOL_SEPARATOR, firstSep + constants_js_1.TOOL_SEPARATOR.length);
if (firstSep < 0 || secondSep < 0) {
return { ok: false, error: `selectedTools entry "${entry}" must look like "<hostId>__<serverName>__<toolName>"` };
}
const matchedPrefix = allowedServerPrefixes.find((p) => entry.startsWith(p));
if (!matchedPrefix) {
return { ok: false, error: `selectedTools entry "${entry}" does not reference a server in ${scopeLabel}` };
}
// Must have at least one character after the matched server prefix
// — i.e. an actual tool segment, not the prefix on its own.
if (entry.length <= matchedPrefix.length) {
return { ok: false, error: `selectedTools entry "${entry}" is missing the tool name` };
}
}
}
return { ok: true, hosts: validatedHosts };
}
import type { ProxyState } from "../core/state.js";
import type { DiscoveryRunner } from "../discovery/runner.js";
import type { UpstreamBridge } from "../runtime/upstream-bridge.js";
export declare class PairingController {
private readonly state;
private readonly runner;
private readonly bridge;
private readonly log;
private readonly sendNotification;
private pairing;
constructor(state: ProxyState, runner: DiscoveryRunner, bridge: UpstreamBridge, log: (line: string) => void, sendNotification: (method: string) => void);
handleConfigure(): Promise<string>;
teardownPairing(): void;
private validateHostCreds;
private handleListServers;
private handleDiscover;
private handleComplete;
closeAllSessions(): Promise<void>;
}
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.PairingController = void 0;
const node_crypto_1 = require("node:crypto");
const protocol_js_1 = require("../../shared/protocol.js");
const constants_js_1 = require("../core/constants.js");
const client_js_1 = require("../discovery/client.js");
const config_js_1 = require("./config.js");
const http_js_1 = require("./http.js");
const tunnel_js_1 = require("./tunnel.js");
const validation_js_1 = require("./validation.js");
// Owns the pairing flow end-to-end: brings up the temporary HTTP server +
// cloudflared tunnel, serves the setup page, mediates discovery on the
// browser's behalf (so pairing-time and runtime use the same client
// capabilities/clientInfo), validates the submitted config, and atomically
// installs it into ProxyState. Also closes any prior pairing's sessions
// before the next pairing's discovery starts.
class PairingController {
state;
runner;
bridge;
log;
sendNotification;
pairing = null;
constructor(state, runner, bridge, log, sendNotification) {
this.state = state;
this.runner = runner;
this.bridge = bridge;
this.log = log;
this.sendNotification = sendNotification;
}
async handleConfigure() {
this.teardownPairing();
const bearer = (0, node_crypto_1.randomBytes)(32).toString("base64url");
const http = new http_js_1.PairingHttpServer(bearer, {
listServers: (req) => this.handleListServers(req),
discover: (req) => this.handleDiscover(req),
complete: (cfg) => this.handleComplete(cfg),
// Surface the existing PairingConfig so a reconfigure flow can
// pre-fill the setup page. The endpoint is already gated by the
// pairing bearer token, so disclosing the auth tokens here is
// bounded by the same trust boundary as a fresh pairing.
info: () => ({
name: protocol_js_1.PACKAGE_NAME,
version: protocol_js_1.PACKAGE_VERSION,
...(this.state.config ? {
current: {
hosts: this.state.config.hosts.map((h) => ({
id: h.id,
tunnelUrl: h.tunnelUrl,
authToken: h.authToken,
...(h.label ? { label: h.label } : {}),
})),
...(this.state.config.selectedServers ? { selectedServers: [...this.state.config.selectedServers] } : {}),
...(this.state.config.selectedTools ? { selectedTools: [...this.state.config.selectedTools] } : {}),
},
} : {}),
}),
});
const port = await http.listen();
const tunnel = new tunnel_js_1.PairingTunnel();
let tunnelUrl;
try {
tunnelUrl = await tunnel.start(port, (reason) => {
// cloudflared/wrapper died after advertising a URL. The setup link
// we already returned points at a dead tunnel; tear pairing down so
// a subsequent `configure` call can re-bootstrap instead of waiting
// out PAIRING_WINDOW_MS. The reason carries whatever cloudflared
// last reported so the operator knows whether to retry or
// investigate.
this.log(` Pairing tunnel exited unexpectedly (${reason}); setup URL invalidated`);
this.teardownPairing();
});
}
catch (err) {
http.close();
tunnel.stop();
throw new Error(`Failed to start pairing tunnel: ${err.message}`);
}
// Setup page is served from the pairing tunnel itself — same origin as
// the /pair/* endpoints, so no CORS is involved. Token rides in the
// URL fragment so it never leaves the browser as Referer / origin log.
const setupUrl = `${tunnelUrl.replace(/\/+$/, "")}/#token=${bearer}`;
const expiryTimer = setTimeout(() => {
this.log(` Pairing window expired (${constants_js_1.PAIRING_WINDOW_MS / 1000}s); tunnel closed`);
this.teardownPairing();
}, constants_js_1.PAIRING_WINDOW_MS);
expiryTimer.unref();
this.pairing = { tunnel, http, setupUrl, expiryTimer };
this.log(`\n Configure at: ${setupUrl}\n`);
return `Open this URL in your browser to set up the MCP Proxy:\n\n${setupUrl}\n\nThe proxy will connect automatically once setup is complete.`;
}
teardownPairing() {
if (!this.pairing)
return;
const { tunnel, http, expiryTimer } = this.pairing;
this.pairing = null;
clearTimeout(expiryTimer);
try {
http.close();
}
catch { /* already closed */ }
try {
tunnel.stop();
}
catch { /* already stopped */ }
}
// Validate the host credentials the browser submitted on a /pair/* call.
// Single source of truth so list-servers and discover share the same
// gating rules — without this the two endpoints could disagree about
// what counts as a valid tunnel URL or what the canonicalised origin is.
validateHostCreds(req) {
if (!req.tunnelUrl || !req.authToken) {
return { ok: false, error: "tunnelUrl and authToken are required" };
}
const url = (0, validation_js_1.allowedTunnelUrl)(req.tunnelUrl);
if (!url) {
return {
ok: false,
error: "tunnelUrl is not on the allowlist. Use an https URL on a Cloudflare tunnel domain (.trycloudflare.com / .cfargotunnel.com), or extend MCP_TUNNEL_HOST_SUFFIXES / MCP_ALLOW_LOCAL on the proxy environment.",
};
}
return {
ok: true,
origin: url.origin,
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
Authorization: `Bearer ${req.authToken}`,
},
};
}
// GET / on the host agent — list of advertised server names. Surfaces
// an explicit 401 status to the browser so the setup page can show
// "invalid auth token" rather than a generic upstream error.
async handleListServers(req) {
const v = this.validateHostCreds(req);
if (!v.ok)
return { ok: false, error: v.error, status: 400 };
try {
const servers = await (0, client_js_1.listHostServers)(v.origin, v.headers);
return { ok: true, servers };
}
catch (err) {
const msg = err.message;
if (msg.startsWith("list HTTP 401")) {
return { ok: false, error: "invalid auth token", status: 401 };
}
return { ok: false, error: msg };
}
}
// Per-server discovery on behalf of the setup page. Uses the captured
// clientCapabilities/clientInfo from the live MCP session — same values
// the runtime path will use — so capability-gated upstreams cannot look
// empty here and rich at runtime (or vice versa). Always closes the
// host-side session afterwards: pairing is read-only inspection, not
// an active session.
async handleDiscover(req) {
// Local validation failures must surface as HTTP 400, matching
// handleListServers — without `status` the http layer defaults to 502,
// which mislabels caller input errors as upstream failures and makes
// the two pairing endpoints disagree about the same class of problem.
const v = this.validateHostCreds(req);
if (!v.ok)
return { ok: false, error: v.error, status: 400 };
if (!req.serverName)
return { ok: false, error: "serverName is required", status: 400 };
const reason = (0, protocol_js_1.validateServerName)(req.serverName);
if (reason)
return { ok: false, error: `serverName ${reason}`, status: 400 };
const targetUrl = `${v.origin}/servers/${req.serverName}`;
let sessionId;
try {
const result = await (0, client_js_1.discoverServerCapabilities)(targetUrl, v.headers, req.serverName, this.state.clientCapabilities, this.state.clientInfo);
sessionId = result.sessionId;
const out = {
ok: true,
tools: result.tools,
prompts: result.prompts,
resources: result.resources,
resourceTemplates: result.resourceTemplates,
};
if (Object.keys(result.capErrors).length > 0)
out.capErrors = result.capErrors;
return out;
}
catch (err) {
const dErr = err;
sessionId = dErr.sessionId;
// Propagate auth failures the same way list-servers does so the UI
// can show "invalid auth token" instead of a generic 502. Discovery
// errors come from initialize / tools/list / prompts/list / etc and
// all spell their HTTP status as "<method> HTTP <status>: …".
if (/HTTP 401\b/.test(dErr.message ?? "")) {
return { ok: false, error: "invalid auth token", status: 401 };
}
return { ok: false, error: dErr.message };
}
finally {
if (sessionId) {
void (0, client_js_1.deleteSession)(targetUrl, { ...v.headers, "Mcp-Session-Id": sessionId });
}
}
}
async handleComplete(cfg) {
const validation = (0, config_js_1.validatePairingConfig)(cfg);
if (!validation.ok)
return { ok: false, error: validation.error };
// Snapshot the active pairing before tearing it down so we can roll
// back if the new pairing fails to land any server. closeAllSessions
// is unconditional, so without this snapshot a bad submit would leave
// the proxy paired-but-empty with no path back to the prior config.
const previousConfig = this.state.config;
const previousHosts = previousConfig?.hosts ?? null;
await this.closeAllSessions();
this.state.installConfig({
hosts: validation.hosts,
selectedServers: cfg.selectedServers,
selectedTools: cfg.selectedTools,
sealed: true,
}, validation.hosts);
// Wait for discovery to settle before responding so the operator
// gets a real success/failure signal instead of a cheerful ok on a
// paired-but-empty proxy. Discovery is bounded by per-host fetch
// timeouts; the browser is fine to wait that long.
await this.runner.discoverServers();
// Strict completion check — same rule the UI enforces at submit:
// every selectedServers entry must have ended up in state.hosts after
// discovery, otherwise we'd persist a partially broken pairing that
// the browser path would have rejected. Without this, a direct caller
// of the bearer-auth endpoint (UI gates client-side, but /pair/complete
// is callable directly) ends up paired with missing servers and the
// proxy reports `ok: true`. Mirror it server-side so behaviour is the
// same whichever path lands the config.
//
// selectedServers is optional in PairingConfig (allow-all). When it's
// omitted we can't enumerate which servers were "expected" — fall back
// to "every advertised host must land at least one server".
const missing = [];
if (cfg.selectedServers) {
// Server names may contain `__`, so prefix-match on the FIRST `__`
// — same parsing rule used in validatePairingConfig.
for (const key of cfg.selectedServers) {
const sep = key.indexOf("__");
if (sep <= 0)
continue; // shape already gated by validatePairingConfig
const hostId = key.slice(0, sep);
const serverName = key.slice(sep + 2);
if (!this.state.hosts.get(hostId)?.servers.has(serverName)) {
missing.push(key);
}
}
}
else {
for (const host of this.state.hosts.values()) {
if (host.servers.size === 0)
missing.push(`${host.config.id} (no servers)`);
}
}
if (missing.length > 0) {
// Cap the detail string so a wildly broken submit doesn't produce a
// multi-kilobyte error body; the operator only needs a few names to
// know which host to investigate.
const detail = missing.length > 5
? `${missing.slice(0, 5).join(", ")}, and ${missing.length - 5} more`
: missing.join(", ");
await this.closeAllSessions();
if (previousConfig && previousHosts) {
this.log(` New pairing missing servers (${detail}); restoring previous pairing`);
this.state.installConfig(previousConfig, previousHosts);
await this.runner.discoverServers();
return {
ok: false,
error: `Discovery did not complete for: ${detail}. The previous pairing has been restored — verify host reachability and retry.`,
};
}
// First-time pairing missed servers: drop back to unconfigured
// (closeAllSessions cleared hosts/routes already; null the config
// so the next configure call starts fresh).
this.log(` New pairing missing servers (${detail}) and no prior config to restore; reverting to unconfigured`);
this.state.config = null;
return {
ok: false,
error: `Discovery did not complete for: ${detail}. Verify host reachability and retry.`,
};
}
const summary = validation.hosts.map((h) => `${h.id}=${h.tunnelUrl}`).join(", ");
this.log(` Paired! hosts: ${summary}`);
// Discovery already populated the aggregated lists. Notify the agent
// so it re-fetches tools/prompts/resources instead of trusting any
// cached empty lists from before pairing.
this.sendNotification("notifications/tools/list_changed");
this.sendNotification("notifications/prompts/list_changed");
this.sendNotification("notifications/resources/list_changed");
// Defer pairing teardown until /pair/complete's response body has
// actually drained to the client — a fixed timer races slow clients
// (mobile data, congested tunnel) and they see a dropped connection
// on a successful pair.
return { ok: true, afterFlush: () => this.teardownPairing() };
}
async closeAllSessions() {
// Tear down SSE listeners first so loops don't reconnect after DELETE.
for (const host of this.state.hosts.values()) {
for (const ctrl of host.sseControllers.values())
ctrl.abort();
host.sseControllers.clear();
}
this.state.inflight.clear();
this.state.progressTokens.clear();
// Drain bridged requests BEFORE the DELETEs below so each upstream
// child gets a JSON-RPC error answer. The DELETE will then reap the
// session, but the upstream side has already stopped waiting.
await this.bridge.clear(this.state.hosts);
const closes = [];
for (const host of this.state.hosts.values()) {
for (const [serverName, state] of host.servers) {
if (!state.sessionId)
continue;
const headers = { ...this.state.hostHeaders(host.config), "Mcp-Session-Id": state.sessionId };
closes.push((0, client_js_1.deleteSession)(`${host.config.tunnelUrl}/servers/${serverName}`, headers));
}
}
await Promise.allSettled(closes);
this.state.resetAfterClose();
}
}
exports.PairingController = PairingController;
import type { PairingConfig, Prompt, Resource, ResourceTemplate, Tool } from "../core/types.js";
export interface ListServersRequest {
tunnelUrl: string;
authToken: string;
}
export interface ListServersResult {
ok: boolean;
servers?: string[];
error?: string;
status?: number;
}
export interface DiscoverServerRequest {
tunnelUrl: string;
authToken: string;
serverName: string;
}
export interface DiscoverServerResult {
ok: boolean;
tools?: Tool[];
prompts?: Prompt[];
resources?: Resource[];
resourceTemplates?: ResourceTemplate[];
capErrors?: {
prompts?: string;
resources?: string;
resourceTemplates?: string;
};
error?: string;
status?: number;
}
export type CompleteResult = {
ok: true;
afterFlush?: () => void;
} | {
ok: false;
error: string;
};
export interface PairingHandlers {
listServers: (req: ListServersRequest) => Promise<ListServersResult>;
discover: (req: DiscoverServerRequest) => Promise<DiscoverServerResult>;
complete: (cfg: PairingConfig) => Promise<CompleteResult>;
info: () => {
name: string;
version: string;
current?: {
hosts: Array<{
id: string;
tunnelUrl: string;
authToken: string;
label?: string;
}>;
selectedServers?: string[];
selectedTools?: string[];
};
};
}
export declare class PairingHttpServer {
private readonly bearer;
private readonly handlers;
private server;
private bearerExpected;
constructor(bearer: string, handlers: PairingHandlers);
listen(): Promise<number>;
close(): void;
private authorized;
private sendJson;
private sendStatic;
private readJson;
private handle;
}
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.PairingHttpServer = void 0;
const node_crypto_1 = require("node:crypto");
const protocol_js_1 = require("../../shared/protocol.js");
const static_assets_js_1 = require("./static-assets.js");
class PairingHttpServer {
bearer;
handlers;
server = null;
bearerExpected;
constructor(bearer, handlers) {
this.bearer = bearer;
this.handlers = handlers;
this.bearerExpected = Buffer.from(`Bearer ${bearer}`);
}
listen() {
return new Promise((resolveP, rejectP) => {
const srv = (0, protocol_js_1.createServer)((req, res) => this.handle(req, res));
srv.once("error", rejectP);
srv.listen(0, "127.0.0.1", () => {
const addr = srv.address();
if (typeof addr !== "object" || !addr) {
rejectP(new Error("Could not bind pairing HTTP server"));
return;
}
this.server = srv;
resolveP(addr.port);
});
});
}
close() {
if (!this.server)
return;
this.server.close();
this.server = null;
}
authorized(req) {
const got = Buffer.from(req.headers.authorization ?? "");
if (got.length !== this.bearerExpected.length)
return false;
return (0, node_crypto_1.timingSafeEqual)(got, this.bearerExpected);
}
sendJson(res, status, body) {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(typeof body === "string" ? body : JSON.stringify(body));
}
sendStatic(res, contentType, body) {
res.writeHead(200, {
"Content-Type": contentType,
"Cache-Control": "no-store",
"Content-Length": String(body.length),
});
res.end(body);
}
// Read the request body and JSON.parse it. On failure, write a 400 to the
// response and return null so the handler can early-return without a
// separate try/catch ladder. readBody itself is awaited outside so a
// BodyTooLargeError propagates up to createServer (→ 413) instead of
// being misreported as "Invalid JSON".
async readJson(req, res) {
const raw = await (0, protocol_js_1.readBody)(req);
try {
return JSON.parse(raw);
}
catch {
this.sendJson(res, 400, { error: "Invalid JSON" });
return null;
}
}
async handle(req, res) {
const url = new URL(req.url ?? "/", "http://localhost");
// Static routes are public — they're the setup page itself, served on
// the same origin as the API so no CORS is involved. The bearer gate
// only matters for /pair/* endpoints (which the page calls with the
// token from its URL fragment).
if (req.method === "GET") {
if (url.pathname === "/" || url.pathname === "/setup.html") {
this.sendStatic(res, "text/html; charset=utf-8", static_assets_js_1.SETUP_HTML);
return;
}
if (url.pathname === "/style.css") {
this.sendStatic(res, "text/css; charset=utf-8", static_assets_js_1.SETUP_CSS);
return;
}
if (url.pathname === "/setup.css") {
this.sendStatic(res, "text/css; charset=utf-8", static_assets_js_1.SETUP_PAGE_CSS);
return;
}
if (url.pathname === "/setup.js") {
this.sendStatic(res, "application/javascript; charset=utf-8", static_assets_js_1.SETUP_JS);
return;
}
}
if (!this.authorized(req)) {
this.sendJson(res, 401, { error: "Unauthorized" });
return;
}
if (req.method === "GET" && url.pathname === "/pair/info") {
this.sendJson(res, 200, this.handlers.info());
return;
}
if (req.method === "POST" && url.pathname === "/pair/list-servers") {
const parsed = await this.readJson(req, res);
if (!parsed)
return;
try {
const result = await this.handlers.listServers(parsed);
const status = result.status ?? (result.ok ? 200 : 502);
this.sendJson(res, status, result);
}
catch (err) {
this.sendJson(res, 502, { ok: false, error: `Upstream unreachable: ${err.message}` });
}
return;
}
if (req.method === "POST" && url.pathname === "/pair/discover") {
const parsed = await this.readJson(req, res);
if (!parsed)
return;
try {
const result = await this.handlers.discover(parsed);
const status = result.status ?? (result.ok ? 200 : 502);
this.sendJson(res, status, result);
}
catch (err) {
this.sendJson(res, 502, { ok: false, error: `Upstream unreachable: ${err.message}` });
}
return;
}
if (req.method === "POST" && url.pathname === "/pair/complete") {
const cfg = await this.readJson(req, res);
if (!cfg)
return;
const out = await this.handlers.complete(cfg);
if (!out.ok) {
this.sendJson(res, 400, { error: out.error });
return;
}
// Only run afterFlush once the response body has actually drained.
// The pairing tunnel teardown rides this callback so a slow client
// (mobile data, congested tunnel) doesn't lose the success body to
// a tunnel that closed on a fixed timer.
const afterFlush = out.afterFlush;
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }), () => {
if (afterFlush)
afterFlush();
});
return;
}
this.sendJson(res, 404, { error: "Not found" });
}
}
exports.PairingHttpServer = PairingHttpServer;
export declare const SETUP_HTML: NonSharedBuffer;
export declare const SETUP_CSS: NonSharedBuffer;
export declare const SETUP_PAGE_CSS: NonSharedBuffer;
export declare const SETUP_JS: NonSharedBuffer;
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SETUP_JS = exports.SETUP_PAGE_CSS = exports.SETUP_CSS = exports.SETUP_HTML = void 0;
const node_fs_1 = require("node:fs");
const node_path_1 = require("node:path");
// dist/proxy/pairing/static-assets.js → ../../../static (project root).
// Resolved at module load so we fail fast at startup rather than on the
// first browser hit.
const STATIC_DIR = (0, node_path_1.resolve)(__dirname, "..", "..", "..", "static");
exports.SETUP_HTML = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(STATIC_DIR, "setup.html"));
exports.SETUP_CSS = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(STATIC_DIR, "style.css"));
exports.SETUP_PAGE_CSS = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(STATIC_DIR, "setup.css"));
exports.SETUP_JS = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(STATIC_DIR, "setup.js"));
export declare class PairingTunnel {
private wrapper;
private url;
private lastError;
private waiters;
private onUnexpectedExit;
start(port: number, onUnexpectedExit?: (reason: string) => void): Promise<string>;
private handleUrl;
private handleExit;
private failWaiters;
private urlReady;
stop(): void;
}
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.PairingTunnel = void 0;
const node_child_process_1 = require("node:child_process");
const node_path_1 = require("node:path");
const protocol_js_1 = require("../../shared/protocol.js");
const constants_js_1 = require("../core/constants.js");
// Owns the cloudflared child via wrapper.js. The wrapper guarantees that
// cloudflared cannot outlive the proxy: it watches stdin EOF and kills the
// child on either side dying, with 0ms detection latency on every supported
// OS. From the proxy's side we only need to start, await the URL, and stop.
class PairingTunnel {
wrapper = null;
url = null;
// Most recent cloudflared error, captured from wrapper "ERR" lines. Lets
// us include the actual cause in any rejection / unexpected-exit log so
// the user sees something more useful than "exited before becoming
// ready".
lastError = null;
waiters = [];
// Fired only when the wrapper dies AFTER the URL was advertised — i.e.,
// a runtime crash, not a startup failure. Pre-ready exits already reject
// the start() promise, so the caller learns about those synchronously.
// The reason string carries whatever cloudflared error we last saw, so
// the caller can log a meaningful line (e.g., "tunnel: connection
// refused") instead of a generic "exited unexpectedly".
onUnexpectedExit = null;
start(port, onUnexpectedExit) {
if (this.wrapper)
return this.urlReady();
this.onUnexpectedExit = onUnexpectedExit ?? null;
// dist/proxy/pairing/tunnel.js → ../../wrapper.js. Two `..` segments
// because tunnel lives two directories below dist/, not one — keep this
// aligned with the emitted layout if the file ever moves again.
const wrapperPath = (0, node_path_1.resolve)(__dirname, "..", "..", "wrapper.js");
const child = (0, node_child_process_1.spawn)(process.execPath, [wrapperPath, String(port)], {
stdio: ["pipe", "pipe", "inherit"],
});
this.wrapper = child;
const buf = new protocol_js_1.LineBuffer();
child.stdout.on("data", (chunk) => {
for (const line of buf.push(chunk.toString("utf-8"))) {
if (line.startsWith("URL "))
this.handleUrl(line.slice(4).trim());
else if (line.startsWith("ERR "))
this.lastError = line.slice(4).trim();
}
});
child.on("exit", () => this.handleExit());
child.on("error", (err) => {
// The spawn itself failed (binary missing, EPERM, etc.) — capture as
// the cause so the rejection isn't blank.
if (!this.lastError)
this.lastError = err.message;
this.failWaiters(new Error(`Pairing tunnel wrapper failed: ${err.message}`));
});
return this.urlReady();
}
handleUrl(url) {
this.url = url;
for (const w of this.waiters) {
clearTimeout(w.timer);
w.resolve(url);
}
this.waiters = [];
}
handleExit() {
const wasReady = this.url !== null;
const reason = this.lastError
? `Pairing tunnel exited: ${this.lastError}`
: "Pairing tunnel exited before becoming ready";
this.failWaiters(new Error(reason));
this.wrapper = null;
this.url = null;
if (wasReady) {
const cb = this.onUnexpectedExit;
this.onUnexpectedExit = null;
cb?.(this.lastError ?? "exit without error message");
}
this.lastError = null;
}
failWaiters(err) {
for (const w of this.waiters) {
clearTimeout(w.timer);
w.reject(err);
}
this.waiters = [];
}
urlReady() {
if (this.url)
return Promise.resolve(this.url);
return new Promise((resolveP, rejectP) => {
const timer = setTimeout(() => {
this.waiters = this.waiters.filter((w) => w.timer !== timer);
const cause = this.lastError ? ` (last error: ${this.lastError})` : "";
rejectP(new Error(`Pairing tunnel startup timed out after ${constants_js_1.TUNNEL_STARTUP_TIMEOUT_MS / 1000}s${cause}`));
}, constants_js_1.TUNNEL_STARTUP_TIMEOUT_MS);
this.waiters.push({ resolve: resolveP, reject: rejectP, timer });
});
}
stop() {
const child = this.wrapper;
if (!child)
return;
this.wrapper = null;
this.url = null;
this.lastError = null;
// Caller-initiated stop; suppress the unexpected-exit callback so a
// teardownPairing() chain doesn't reenter via the exit handler.
this.onUnexpectedExit = null;
try {
child.stdin?.write("stop\n");
}
catch { /* already closed */ }
try {
child.stdin?.end();
}
catch { /* already closed */ }
// Hard-kill fallback if the wrapper does not exit promptly.
setTimeout(() => {
if (!child.killed) {
try {
child.kill("SIGKILL");
}
catch { /* already dead */ }
}
}, 5000).unref();
}
}
exports.PairingTunnel = PairingTunnel;
export declare function allowedTunnelUrl(raw: string): URL | null;
export declare function isUnknownSessionError(body: string): boolean;
"use strict";
// Validation for pairing-time inputs: which tunnel hostnames the proxy is
// willing to dial out to from /pair/* endpoints. Centralised here so the
// browser can't trick the proxy into hitting arbitrary URLs even if the
// pairing bearer leaks. Kept structurally identical to the runtime path
// (handleComplete validates host configs through the same predicate) so
// what passes pairing-time discovery is exactly what passes pairing-time
// save.
Object.defineProperty(exports, "__esModule", { value: true });
exports.allowedTunnelUrl = allowedTunnelUrl;
exports.isUnknownSessionError = isUnknownSessionError;
// Tunnel-URL allowlist for /pair/* discovery endpoints. The bearer token
// alone must not authorize SSRF — without this, anyone holding the token
// (browser extension, XSS, leaked terminal scrollback) could pivot the
// proxy to any URL, including http://127.0.0.1 on the host's LAN.
const DEFAULT_TUNNEL_HOST_SUFFIXES = [".trycloudflare.com", ".cfargotunnel.com"];
const LOCAL_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
function tunnelHostSuffixes() {
const extra = (process.env.MCP_TUNNEL_HOST_SUFFIXES ?? "")
.split(",")
.map((s) => s.trim().toLowerCase())
.filter((s) => s.startsWith("."));
return [...new Set([...DEFAULT_TUNNEL_HOST_SUFFIXES, ...extra])];
}
function allowedTunnelUrl(raw) {
let url;
try {
url = new URL(raw);
}
catch {
return null;
}
if (url.username || url.password)
return null;
const host = url.hostname.toLowerCase();
const allowLocal = process.env.MCP_ALLOW_LOCAL === "true";
if (allowLocal && LOCAL_HOSTNAMES.has(host)) {
if (url.protocol !== "http:" && url.protocol !== "https:")
return null;
return url;
}
if (url.protocol !== "https:")
return null;
const suffixes = tunnelHostSuffixes();
const ok = suffixes.some((suffix) => host.endsWith(suffix) && host.length > suffix.length);
return ok ? url : null;
}
// Host returns 404 + JSON error when a forwarded request points at a session
// it doesn't know about — typically because the host GC'd it after 30 min idle
// or the host process restarted. Matched here so the proxy can re-init + retry
// instead of bubbling the failure up to the client.
function isUnknownSessionError(body) {
try {
const e = JSON.parse(body).error;
if (typeof e !== "string")
return false;
return e.startsWith("Unknown session") || e === "Mcp-Session-Id header required";
}
catch {
return false;
}
}
import type { HostState, PairingConfig, Prompt, Resource, ResourceTemplate, Tool, ToolRoute } from "../core/types.js";
export declare function serverKey(hostId: string, serverName: string): string;
export declare function isServerSelected(config: PairingConfig | null, hostId: string, serverName: string): boolean;
export declare function getFilteredTools(config: PairingConfig | null, hosts: Map<string, HostState>, toolRoute: Map<string, ToolRoute>): Tool[];
export declare function getAggregatedPrompts(config: PairingConfig | null, hosts: Map<string, HostState>, promptRoute: Map<string, ToolRoute>): Prompt[];
export declare function getAggregatedResources(config: PairingConfig | null, hosts: Map<string, HostState>, exact: Array<{
uri: string;
route: ToolRoute;
}>): Resource[];
export declare function getAggregatedResourceTemplates(config: PairingConfig | null, hosts: Map<string, HostState>, templates: Array<{
uriTemplate: string;
route: ToolRoute;
}>): ResourceTemplate[];
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.serverKey = serverKey;
exports.isServerSelected = isServerSelected;
exports.getFilteredTools = getFilteredTools;
exports.getAggregatedPrompts = getAggregatedPrompts;
exports.getAggregatedResources = getAggregatedResources;
exports.getAggregatedResourceTemplates = getAggregatedResourceTemplates;
const constants_js_1 = require("../core/constants.js");
const uri_js_1 = require("./uri.js");
// One key per server in `selectedServers`. Centralised so the format never
// drifts between the filter, the setup page, and validation.
function serverKey(hostId, serverName) {
return `${hostId}${constants_js_1.TOOL_SEPARATOR}${serverName}`;
}
// Server-level allow check. undefined = no filter (every server exposed);
// array = only the listed entries (each `<hostId>__<serverName>`). This is
// the single gate that hides ALL of a server's capabilities — tools,
// prompts, resources, templates, and the routed methods that read them.
// Without it a server with all tools deselected still leaked prompts and
// resources through the proxy (CR-01).
function isServerSelected(config, hostId, serverName) {
if (!config)
return false;
if (config.selectedServers === undefined)
return true;
return config.selectedServers.includes(serverKey(hostId, serverName));
}
// Build the agent-facing tools list. Filters by both selectedServers
// (server-level) and selectedTools (tool-level within an allowed server).
// Description is prefixed with origin so two servers with same-named tools
// don't confuse the agent.
function getFilteredTools(config, hosts, toolRoute) {
if (!config)
return [];
const selectedToolSet = config.selectedTools !== undefined ? new Set(config.selectedTools) : null;
const tools = [];
for (const [prefixed, route] of toolRoute) {
if (!isServerSelected(config, route.hostId, route.serverName))
continue;
if (selectedToolSet && !selectedToolSet.has(prefixed))
continue;
const server = hosts.get(route.hostId)?.servers.get(route.serverName);
const original = server?.tools.find((t) => t.name === route.originalName);
if (!original)
continue;
tools.push({
...original,
name: prefixed,
description: `[${route.hostId}/${route.serverName}] ${original.description ?? ""}`.trim(),
});
}
return tools;
}
// Prompts have no per-prompt filter today — only the server-level gate.
// Description prefixed with origin for the same disambiguation reason as
// tools.
function getAggregatedPrompts(config, hosts, promptRoute) {
if (!config)
return [];
const prompts = [];
for (const [prefixed, route] of promptRoute) {
if (!isServerSelected(config, route.hostId, route.serverName))
continue;
const server = hosts.get(route.hostId)?.servers.get(route.serverName);
const original = server?.prompts.find((p) => p.name === route.originalName);
if (!original)
continue;
prompts.push({
...original,
name: prefixed,
description: `[${route.hostId}/${route.serverName}] ${original.description ?? ""}`.trim(),
});
}
return prompts;
}
// Resources are namespaced on the way out: every URI is wrapped with the
// owning (hostId, serverName) so two upstream servers exposing the same
// raw URI (or overlapping templates) are unambiguous to the agent and to
// the routing path. Server-level gate hides every URI from a deselected
// server. Round-trip is symmetric — handleResourceMethod unwraps on the
// way back in.
function getAggregatedResources(config, hosts, exact) {
if (!config)
return [];
const out = [];
for (const { uri, route } of exact) {
if (!isServerSelected(config, route.hostId, route.serverName))
continue;
const server = hosts.get(route.hostId)?.servers.get(route.serverName);
const original = server?.resources.find((r) => r.uri === uri);
if (!original)
continue;
out.push({ ...original, uri: (0, uri_js_1.wrapResourceUri)(route.hostId, route.serverName, original.uri) });
}
return out;
}
function getAggregatedResourceTemplates(config, hosts, templates) {
if (!config)
return [];
const out = [];
for (const { uriTemplate, route } of templates) {
if (!isServerSelected(config, route.hostId, route.serverName))
continue;
const server = hosts.get(route.hostId)?.servers.get(route.serverName);
const original = server?.resourceTemplates.find((t) => t.uriTemplate === uriTemplate);
if (!original)
continue;
// Wrapping the template is safe: RFC 6570 expansion is left-to-right
// substitution of `{...}` literals, and the prefix has no braces, so
// an agent expanding the wrapped template yields a URI the proxy can
// structurally unwrap on the way back.
out.push({ ...original, uriTemplate: (0, uri_js_1.wrapResourceUri)(route.hostId, route.serverName, original.uriTemplate) });
}
return out;
}
import type { Resource, ResourceTemplate, ToolRoute } from "../core/types.js";
export declare class ResourceRouter {
private exact;
private exactByUri;
private templates;
private templatesByUri;
clear(): void;
add(hostId: string, serverName: string, resources: Resource[], templates: ResourceTemplate[]): string[];
exactEntries(): Array<{
uri: string;
route: ToolRoute;
}>;
templateEntries(): Array<{
uriTemplate: string;
route: ToolRoute;
}>;
}
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ResourceRouter = void 0;
// ResourceRouter is now a bookkeeping store — routing itself is structural
// (see resource-uri.ts: every URI the agent sees is wrapped with its
// owning hostId/serverName, so unwrap → route is a pure parse, no map
// lookup, no template engine).
//
// What this class still owns:
// - The exact-URI list per (host, server), used by getAggregatedResources
// to render the agent-facing resources/list (with origin-wrapped URIs).
// Stored as an array, not a URI-keyed map, because two servers can
// legitimately expose the same concrete URI — both must surface under
// their own envelopes (hence the wrap step in filtering.ts).
// - The template list per (host, server), used the same way for
// resources/templates/list.
// - Cross-origin collision logging when two servers happen to expose
// the same raw URI or template string. The wrap step makes them
// distinct on the wire, so this is informational only — no longer
// load-bearing for routing correctness.
class ResourceRouter {
exact = [];
exactByUri = new Map();
templates = [];
templatesByUri = new Map();
clear() {
this.exact = [];
this.exactByUri.clear();
this.templates = [];
this.templatesByUri.clear();
}
// Add one server's resources + templates. Returns a list of human-readable
// collision messages that the caller should write to stderr — kept out of
// this module so logging stays at the orchestrator layer.
add(hostId, serverName, resources, templates) {
const log = [];
const route = (originalName) => ({ hostId, serverName, originalName });
for (const r of resources) {
const existing = this.exactByUri.get(r.uri);
if (existing && (existing.hostId !== hostId || existing.serverName !== serverName)) {
log.push(`Resource URI also exposed by ${hostId}/${serverName}: ${r.uri} (already advertised by ${existing.hostId}/${existing.serverName}); both surfaced under their own envelopes`);
}
const rt = route(r.uri);
this.exact.push({ uri: r.uri, route: rt });
// First-writer wins for the dedup map — only used to detect repeats.
// The list above is the source of truth for the agent-facing listing.
if (!existing)
this.exactByUri.set(r.uri, rt);
}
for (const t of templates) {
const existing = this.templatesByUri.get(t.uriTemplate);
if (existing && (existing.hostId !== hostId || existing.serverName !== serverName)) {
log.push(`Resource template also exposed by ${hostId}/${serverName}: ${t.uriTemplate} (already advertised by ${existing.hostId}/${existing.serverName}); both surfaced under their own envelopes`);
}
const r = route(t.uriTemplate);
this.templates.push({ uriTemplate: t.uriTemplate, route: r });
if (!existing)
this.templatesByUri.set(t.uriTemplate, r);
}
return log;
}
// For getAggregatedResources / templates list. Iteration order matches
// insertion (array semantics), which matches the order servers were added
// in rebuildToolRoute — stable across runs. Two servers exposing the same
// raw URI yield two distinct entries; wrapResourceUri disambiguates them
// on the way out.
exactEntries() {
return this.exact.map(({ uri, route }) => ({ uri, route }));
}
templateEntries() {
return this.templates.map(({ uriTemplate, route }) => ({ uriTemplate, route }));
}
}
exports.ResourceRouter = ResourceRouter;
export declare function wrapResourceUri(hostId: string, serverName: string, original: string): string;
export interface UnwrappedResourceUri {
hostId: string;
serverName: string;
originalUri: string;
}
export declare function unwrapResourceUri(wrapped: string): UnwrappedResourceUri | null;
"use strict";
// Resource URIs are namespaced with the upstream's (hostId, serverName) so
// routing is structural — every URI the agent ever sees encodes its origin
// server, and `resources/read` / `subscribe` / completion can be dispatched
// without consulting any routing table or template engine. This eliminates
// the cross-server overlap problem that first-match-wins template routing
// had before: two upstreams exposing `file:///{path}` are now distinct
// `mcp+host://hostA/srvA/file:///{path}` and `mcp+host://hostB/srvB/...`
// strings the agent cannot conflate.
//
// Format: `mcp+host://<hostId>/<serverName>/<originalUri>` — the original
// URI is appended verbatim. hostId and serverName are validated upstream
// to match `[A-Za-z0-9._-]+` (see SERVER_NAME_PATTERN in shared/protocol),
// so they cannot contain `/` and the parse is unambiguous.
Object.defineProperty(exports, "__esModule", { value: true });
exports.wrapResourceUri = wrapResourceUri;
exports.unwrapResourceUri = unwrapResourceUri;
const SCHEME = "mcp+host://";
function wrapResourceUri(hostId, serverName, original) {
return `${SCHEME}${hostId}/${serverName}/${original}`;
}
function unwrapResourceUri(wrapped) {
if (!wrapped.startsWith(SCHEME))
return null;
const rest = wrapped.slice(SCHEME.length);
const firstSlash = rest.indexOf("/");
if (firstSlash <= 0)
return null;
const hostId = rest.slice(0, firstSlash);
const afterHost = rest.slice(firstSlash + 1);
const secondSlash = afterHost.indexOf("/");
if (secondSlash <= 0)
return null;
const serverName = afterHost.slice(0, secondSlash);
const originalUri = afterHost.slice(secondSlash + 1);
if (!originalUri)
return null;
return { hostId, serverName, originalUri };
}
import type { ProxyState } from "../core/state.js";
import type { ToolRoute } from "../core/types.js";
import type { DiscoveryRunner } from "../discovery/runner.js";
export declare class Forwarder {
private readonly state;
private readonly runner;
private readonly log;
private readonly sendError;
private readonly writeOut;
constructor(state: ProxyState, runner: DiscoveryRunner, log: (line: string) => void, sendError: (code: number, detail: string | undefined, id: string | number | null) => void, writeOut: (line: string) => void);
forwardRoutedRequest(id: string | number, route: ToolRoute, method: string, upstreamParams: unknown): Promise<void>;
private replaySubscriptions;
forwardNotification(route: ToolRoute, method: string, params: unknown): Promise<void>;
broadcastSetLogLevel(params: Record<string, unknown>): Promise<void>;
}
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Forwarder = void 0;
const protocol_js_1 = require("../../shared/protocol.js");
const constants_js_1 = require("../core/constants.js");
const fetch_timeout_js_1 = require("../core/fetch-timeout.js");
const validation_js_1 = require("../pairing/validation.js");
const uri_js_1 = require("../routing/uri.js");
// All agent→server (and proxy→server) HTTP traffic flows through this
// class. It owns the request/response shape, stale-session 404 retry,
// resources/read URI wrapping, and the per-request id bookkeeping that
// notifications/cancelled relies on. It does NOT own discovery/init —
// when a session is reaped under us it asks DiscoveryRunner to re-init.
class Forwarder {
state;
runner;
log;
sendError;
writeOut;
constructor(state, runner, log, sendError, writeOut) {
this.state = state;
this.runner = runner;
this.log = log;
this.sendError = sendError;
this.writeOut = writeOut;
}
async forwardRoutedRequest(id, route, method, upstreamParams) {
const host = this.state.hosts.get(route.hostId);
const server = host?.servers.get(route.serverName);
if (!host || !server) {
this.sendError(protocol_js_1.ErrorCode.INTERNAL, `route stale: ${route.hostId}/${route.serverName}`, id);
return;
}
const targetUrl = `${host.config.tunnelUrl}/servers/${route.serverName}`;
const buildHeaders = (sessionId) => {
const h = this.state.hostHeaders(host.config);
if (sessionId)
h["Mcp-Session-Id"] = sessionId;
return h;
};
const body = JSON.stringify({ jsonrpc: "2.0", id, method, params: upstreamParams });
this.state.inflight.set(id, route);
const progressToken = (upstreamParams?._meta?.progressToken);
if (progressToken !== undefined)
this.state.progressTokens.set(progressToken, route);
try {
let upstream = await fetch(targetUrl, {
method: "POST",
headers: buildHeaders(server.sessionId),
signal: (0, fetch_timeout_js_1.timeoutSignal)(constants_js_1.TOOL_FORWARD_TIMEOUT_MS),
body,
});
this.runner.captureSessionId(host, route.serverName, server, upstream.headers.get("mcp-session-id"));
let responseBody = await upstream.text();
// Stale session recovery: the host now refuses unknown ids with 404
// instead of silently spawning a fresh, uninitialized child. Re-run
// the MCP handshake for this one server and retry the call exactly
// once.
if (upstream.status === 404 && (0, validation_js_1.isUnknownSessionError)(responseBody)) {
this.log(`[${route.hostId}/${route.serverName}] session lost, re-initializing`);
const before = host.servers.get(route.serverName);
// Snapshot subscriptions BEFORE initServer overwrites the state
// with a fresh empty set. The new session has no record of any
// subscribe call the agent made on the old one, so we replay
// each URI after init lands.
const priorSubscriptions = before ? Array.from(before.subscriptions) : [];
await this.runner.initServer(host, route.serverName);
const refreshed = host.servers.get(route.serverName);
if (!refreshed || refreshed === before) {
this.sendError(protocol_js_1.ErrorCode.HOST_UNREACHABLE, `re-init failed for ${route.hostId}/${route.serverName}`, id);
return;
}
if (priorSubscriptions.length > 0) {
await this.replaySubscriptions(host, route.serverName, refreshed, priorSubscriptions);
}
upstream = await fetch(targetUrl, {
method: "POST",
headers: buildHeaders(refreshed.sessionId),
signal: (0, fetch_timeout_js_1.timeoutSignal)(constants_js_1.TOOL_FORWARD_TIMEOUT_MS),
body,
});
this.runner.captureSessionId(host, route.serverName, refreshed, upstream.headers.get("mcp-session-id"));
responseBody = await upstream.text();
}
if (!upstream.ok) {
this.sendError(protocol_js_1.ErrorCode.HOST_UNREACHABLE, `host returned ${upstream.status}: ${responseBody.slice(0, 200)}`, id);
return;
}
let parsed;
try {
parsed = JSON.parse(responseBody);
}
catch {
this.sendError(protocol_js_1.ErrorCode.INTERNAL, `host returned non-JSON body: ${responseBody.slice(0, 200)}`, id);
return;
}
const isJsonRpc = parsed.jsonrpc === "2.0" && (parsed.result !== undefined || parsed.error !== undefined);
if (!isJsonRpc) {
this.sendError(protocol_js_1.ErrorCode.INTERNAL, "host returned non-JSON-RPC body", id);
return;
}
// Track subscribe/unsubscribe state on success only — a JSON-RPC
// error means the upstream rejected the call and the subscription
// state didn't actually change. Use the live `host.servers.get()`
// result rather than the captured `server` so a re-init mid-call
// commits to the post-recovery state.
if (parsed.error === undefined && (method === "resources/subscribe" || method === "resources/unsubscribe")) {
const uri = upstreamParams?.uri;
if (typeof uri === "string") {
const live = host.servers.get(route.serverName);
if (live) {
if (method === "resources/subscribe")
live.subscriptions.add(uri);
else
live.subscriptions.delete(uri);
}
}
}
// resources/read response carries its own list of `contents[i].uri`,
// which the upstream emits in its own URI namespace (e.g., reading a
// directory returns concrete file URIs the agent never saw in
// resources/list). Wrap each so the agent sees a URI it can route
// back through the proxy on a subsequent read/subscribe.
if (method === "resources/read" && parsed.result !== undefined) {
const result = parsed.result;
if (Array.isArray(result.contents)) {
result.contents = result.contents.map((entry) => {
if (entry && typeof entry === "object" && typeof entry.uri === "string") {
const e = entry;
return { ...e, uri: (0, uri_js_1.wrapResourceUri)(route.hostId, route.serverName, e.uri) };
}
return entry;
});
}
}
parsed.id = id;
this.writeOut(JSON.stringify(parsed));
}
catch (err) {
this.sendError(protocol_js_1.ErrorCode.HOST_UNREACHABLE, err.message, id);
}
finally {
this.state.inflight.delete(id);
if (progressToken !== undefined)
this.state.progressTokens.delete(progressToken);
}
}
// Re-issue resources/subscribe for each URI the agent had subscribed on
// the prior session. Best-effort: a URI that fails to re-subscribe is
// dropped from the new set so we don't claim a subscription we don't
// actually hold. Logged for visibility but not surfaced to the agent —
// the agent never saw the session rotate.
async replaySubscriptions(host, serverName, refreshed, uris) {
const targetUrl = `${host.config.tunnelUrl}/servers/${serverName}`;
const headers = { ...this.state.hostHeaders(host.config), "Mcp-Session-Id": refreshed.sessionId };
await Promise.allSettled(uris.map(async (uri) => {
try {
const resp = await fetch(targetUrl, {
method: "POST",
headers,
signal: (0, fetch_timeout_js_1.timeoutSignal)(constants_js_1.TOOL_FORWARD_TIMEOUT_MS),
body: JSON.stringify({
jsonrpc: "2.0",
id: `resub-${host.config.id}-${serverName}-${Date.now()}-${uri}`,
method: "resources/subscribe",
params: { uri },
}),
});
this.runner.captureSessionId(host, serverName, refreshed, resp.headers.get("mcp-session-id"));
if (!resp.ok) {
this.log(` [${host.config.id}/${serverName}] resubscribe ${uri} HTTP ${resp.status}`);
return;
}
const text = await resp.text();
try {
const payload = JSON.parse(text);
if (payload.error) {
this.log(` [${host.config.id}/${serverName}] resubscribe ${uri} rejected: ${payload.error.message ?? "(no message)"}`);
return;
}
}
catch {
// unparseable body — treat as best-effort success
}
refreshed.subscriptions.add(uri);
}
catch (err) {
this.log(` [${host.config.id}/${serverName}] resubscribe ${uri} failed: ${err.message}`);
}
}));
}
async forwardNotification(route, method, params) {
const host = this.state.hosts.get(route.hostId);
const server = host?.servers.get(route.serverName);
if (!host || !server || !server.sessionId)
return;
const target = `${host.config.tunnelUrl}/servers/${route.serverName}`;
const headers = { ...this.state.hostHeaders(host.config), "Mcp-Session-Id": server.sessionId };
const resp = await fetch(target, {
method: "POST",
headers,
signal: (0, fetch_timeout_js_1.timeoutSignal)(constants_js_1.TOOL_FORWARD_TIMEOUT_MS),
body: JSON.stringify({ jsonrpc: "2.0", method, params }),
});
this.runner.captureSessionId(host, route.serverName, server, resp.headers.get("mcp-session-id"));
}
// logging/setLevel has no per-server addressing in the protocol. Issue
// the same level to every paired session in parallel; aggregate failures
// into stderr and return success to the agent — partial setLevel is
// still a meaningful change.
async broadcastSetLogLevel(params) {
const targets = [];
for (const host of this.state.hosts.values()) {
for (const [serverName, server] of host.servers) {
if (!server.sessionId)
continue;
targets.push({ host, serverName, server });
}
}
await Promise.allSettled(targets.map(async ({ host, serverName, server }) => {
const target = `${host.config.tunnelUrl}/servers/${serverName}`;
const headers = { ...this.state.hostHeaders(host.config), "Mcp-Session-Id": server.sessionId };
try {
const resp = await fetch(target, {
method: "POST",
headers,
signal: (0, fetch_timeout_js_1.timeoutSignal)(constants_js_1.TOOL_FORWARD_TIMEOUT_MS),
body: JSON.stringify({
jsonrpc: "2.0",
id: `loglevel-${host.config.id}-${serverName}-${Date.now()}`,
method: "logging/setLevel",
params,
}),
});
this.runner.captureSessionId(host, serverName, server, resp.headers.get("mcp-session-id"));
if (!resp.ok) {
this.log(` [${host.config.id}/${serverName}] logging/setLevel HTTP ${resp.status}`);
return;
}
// HTTP 200 can still wrap a JSON-RPC error (invalid level, server
// rejection). Read the body and surface it — silently ignoring an
// upstream's "no thanks" makes invalid levels look applied.
const text = await resp.text();
if (!text)
return;
let payload;
try {
payload = JSON.parse(text);
}
catch {
return; // unparseable — not our concern, treat as best-effort success
}
if (payload?.error) {
const code = payload.error.code ?? "?";
const message = payload.error.message ?? "(no message)";
this.log(` [${host.config.id}/${serverName}] logging/setLevel rejected: ${code} ${message}`);
}
}
catch (err) {
this.log(` [${host.config.id}/${serverName}] logging/setLevel failed: ${err.message}`);
}
}));
}
}
exports.Forwarder = Forwarder;
import type { ProxyState } from "../core/state.js";
import type { DiscoveryRunner } from "../discovery/runner.js";
import type { PairingController } from "../pairing/controller.js";
import type { Forwarder } from "./forwarder.js";
import type { UpstreamBridge } from "./upstream-bridge.js";
export declare class RequestHandlers {
private readonly state;
private readonly runner;
private readonly forwarder;
private readonly pairing;
private readonly bridge;
private readonly sendResult;
private readonly sendError;
constructor(state: ProxyState, runner: DiscoveryRunner, forwarder: Forwarder, pairing: PairingController, bridge: UpstreamBridge, sendResult: (id: string | number | null, result: unknown) => void, sendError: (code: number, detail: string | undefined, id: string | number | null) => void);
handleInitialize(id: string | number, params: {
capabilities?: Record<string, unknown>;
clientInfo?: {
name?: string;
version?: string;
};
} | undefined): void;
handleToolsList(id: string | number): Promise<void>;
handlePromptsList(id: string | number): Promise<void>;
handlePromptDispatch(id: string | number, params: {
name?: string;
arguments?: Record<string, unknown>;
} | undefined): Promise<void>;
handleResourcesList(id: string | number): Promise<void>;
handleResourceTemplatesList(id: string | number): Promise<void>;
handleResourceMethod(id: string | number, method: string, params: {
uri?: string;
} | undefined): Promise<void>;
handleLoggingSetLevel(id: string | number, params: Record<string, unknown>): Promise<void>;
handleCompletion(id: string | number, params: {
ref?: {
type?: string;
name?: string;
uri?: string;
};
argument?: unknown;
}): Promise<void>;
handleToolDispatch(id: string | number, params: {
name: string;
arguments?: Record<string, unknown>;
_meta?: unknown;
}): Promise<void>;
handleClientNotification(method: string, params: Record<string, unknown>): Promise<void>;
}
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.RequestHandlers = void 0;
const protocol_js_1 = require("../../shared/protocol.js");
const constants_js_1 = require("../core/constants.js");
const filtering_js_1 = require("../routing/filtering.js");
const uri_js_1 = require("../routing/uri.js");
// One method per JSON-RPC verb the agent can send. Each handler is
// responsible for: parameter validation, looking up the route from the
// (already discovered) state, and either answering locally or delegating
// to Forwarder. Lives here rather than on ProxyServer so the router file
// stays a thin dispatcher and the per-method logic doesn't have to share
// a 1k-line class with discovery, pairing, and forwarding.
class RequestHandlers {
state;
runner;
forwarder;
pairing;
bridge;
sendResult;
sendError;
constructor(state, runner, forwarder, pairing, bridge, sendResult, sendError) {
this.state = state;
this.runner = runner;
this.forwarder = forwarder;
this.pairing = pairing;
this.bridge = bridge;
this.sendResult = sendResult;
this.sendError = sendError;
}
handleInitialize(id, params) {
this.state.clientCapabilities = params?.capabilities ?? {};
if (params?.clientInfo?.name) {
this.state.clientInfo = {
name: params.clientInfo.name,
version: params.clientInfo.version ?? "unknown",
};
}
this.sendResult(id, {
protocolVersion: protocol_js_1.MCP_PROTOCOL_VERSION,
// listChanged is honest in both directions: SSE listeners relay
// notifications/{tools,prompts,resources}/list_changed from each
// upstream server with a cache refresh in between, so the agent's
// follow-up list call sees fresh data.
capabilities: {
tools: { listChanged: true },
prompts: { listChanged: true },
resources: { listChanged: true, subscribe: true },
logging: {},
completions: {},
},
serverInfo: { name: protocol_js_1.PACKAGE_NAME, version: protocol_js_1.PACKAGE_VERSION },
});
}
async handleToolsList(id) {
if (!this.state.config) {
this.sendResult(id, { tools: [constants_js_1.CONFIGURE_TOOL] });
return;
}
await this.runner.retryDiscoveryIfNeeded();
this.sendResult(id, {
tools: [constants_js_1.CONFIGURE_TOOL, ...(0, filtering_js_1.getFilteredTools)(this.state.config, this.state.hosts, this.state.toolRoute)],
});
}
async handlePromptsList(id) {
if (!this.state.config) {
this.sendResult(id, { prompts: [constants_js_1.CONFIGURE_PROMPT] });
return;
}
await this.runner.retryDiscoveryIfNeeded();
// Inject CONFIGURE_PROMPT first so re-pairing is always one prompt away
// regardless of upstream state.
this.sendResult(id, {
prompts: [constants_js_1.CONFIGURE_PROMPT, ...(0, filtering_js_1.getAggregatedPrompts)(this.state.config, this.state.hosts, this.state.promptRoute)],
});
}
async handlePromptDispatch(id, params) {
const promptName = params?.name;
if (promptName === "configure") {
let text;
try {
text = await this.pairing.handleConfigure();
}
catch (err) {
// handleConfigure throws when the pairing tunnel can't come up
// (cloudflared missing, network unreachable, startup timeout, etc.).
// Surface the cause as a JSON-RPC error so the agent doesn't hang
// waiting on a response that never arrives.
this.sendError(protocol_js_1.ErrorCode.INTERNAL, err.message, id);
return;
}
this.sendResult(id, {
messages: [
{ role: "user", content: { type: "text", text: "Show the MCP Proxy setup URL. Do not add any follow-up — do not ask me to let you know or report back." } },
{ role: "assistant", content: { type: "text", text } },
],
});
return;
}
if (!promptName) {
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, "name is required", id);
return;
}
if (!this.state.config) {
this.sendError(protocol_js_1.ErrorCode.PROXY_NOT_CONFIGURED, "Call the `configure` tool first.", id);
return;
}
const route = this.state.promptRoute.get(promptName);
if (!route || !(0, filtering_js_1.isServerSelected)(this.state.config, route.hostId, route.serverName)) {
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, `Unknown prompt: ${promptName}`, id);
return;
}
const upstream = { name: route.originalName };
if (params?.arguments !== undefined)
upstream.arguments = params.arguments;
const meta = params?._meta;
if (meta !== undefined)
upstream._meta = meta;
await this.forwarder.forwardRoutedRequest(id, route, "prompts/get", upstream);
}
async handleResourcesList(id) {
if (!this.state.config) {
this.sendResult(id, { resources: [] });
return;
}
await this.runner.retryDiscoveryIfNeeded();
this.sendResult(id, {
resources: (0, filtering_js_1.getAggregatedResources)(this.state.config, this.state.hosts, this.state.resources.exactEntries()),
});
}
async handleResourceTemplatesList(id) {
if (!this.state.config) {
this.sendResult(id, { resourceTemplates: [] });
return;
}
await this.runner.retryDiscoveryIfNeeded();
this.sendResult(id, {
resourceTemplates: (0, filtering_js_1.getAggregatedResourceTemplates)(this.state.config, this.state.hosts, this.state.templateRoutes),
});
}
async handleResourceMethod(id, method, params) {
if (!this.state.config) {
this.sendError(protocol_js_1.ErrorCode.PROXY_NOT_CONFIGURED, "Call the `configure` tool first.", id);
return;
}
const uri = params?.uri;
if (!uri) {
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, "uri is required", id);
return;
}
const parsed = (0, uri_js_1.unwrapResourceUri)(uri);
if (!parsed) {
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, `Not a recognised resource URI: ${uri}`, id);
return;
}
if (!(0, filtering_js_1.isServerSelected)(this.state.config, parsed.hostId, parsed.serverName)) {
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, `No upstream server owns resource URI: ${uri}`, id);
return;
}
if (!this.state.hosts.get(parsed.hostId)?.servers.has(parsed.serverName)) {
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, `No upstream server owns resource URI: ${uri}`, id);
return;
}
// Deliberately NO per-URI allowlist here. The host's authority model is
// "anyone holding (tunnelUrl, authToken) can call any MCP method on any
// child server" — there is no resource-level ACL on the host side, no
// `selectedResources` field on PairingConfig, and no resource picker in
// the setup UI. The proxy's selection gates (selectedServers,
// selectedTools) constrain what the agent can reach THROUGH the proxy;
// a credentialed attacker bypasses the proxy entirely, so adding a
// proxy-side resource check would not raise the privilege floor. A
// discovered-set check would also reject legitimate dynamic URIs
// returned by resources/read on directory-style resources (see
// forwarder.ts wrapResourceUri block) — false positives with no
// matching security gain. handleCompletion's ref/resource branch
// intentionally mirrors this.
const route = {
hostId: parsed.hostId,
serverName: parsed.serverName,
originalName: parsed.originalUri,
};
await this.forwarder.forwardRoutedRequest(id, route, method, { ...(params ?? {}), uri: parsed.originalUri });
}
async handleLoggingSetLevel(id, params) {
if (!this.state.config) {
this.sendResult(id, {});
return;
}
await this.forwarder.broadcastSetLogLevel(params);
this.sendResult(id, {});
}
async handleCompletion(id, params) {
if (!this.state.config) {
this.sendError(protocol_js_1.ErrorCode.PROXY_NOT_CONFIGURED, "Call the `configure` tool first.", id);
return;
}
const ref = params.ref;
if (!ref || typeof ref.type !== "string") {
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, "ref.type is required", id);
return;
}
let route = null;
let upstreamRef = null;
if (ref.type === "ref/prompt") {
if (!ref.name) {
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, "ref.name is required for ref/prompt", id);
return;
}
route = this.state.promptRoute.get(ref.name) ?? null;
if (route)
upstreamRef = { type: ref.type, name: route.originalName };
}
else if (ref.type === "ref/resource") {
if (!ref.uri) {
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, "ref.uri is required for ref/resource", id);
return;
}
const parsed = (0, uri_js_1.unwrapResourceUri)(ref.uri);
// Server-level gate only — no per-URI allowlist. See the comment in
// handleResourceMethod for the threat-model reasoning.
if (parsed && this.state.hosts.get(parsed.hostId)?.servers.has(parsed.serverName)) {
route = {
hostId: parsed.hostId,
serverName: parsed.serverName,
originalName: parsed.originalUri,
};
upstreamRef = { type: ref.type, uri: parsed.originalUri };
}
}
else {
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, `Unknown ref.type: ${ref.type}`, id);
return;
}
if (!route || !upstreamRef || !(0, filtering_js_1.isServerSelected)(this.state.config, route.hostId, route.serverName)) {
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, `No upstream server matches ref`, id);
return;
}
await this.forwarder.forwardRoutedRequest(id, route, "completion/complete", {
ref: upstreamRef,
...(params.argument !== undefined ? { argument: params.argument } : {}),
});
}
async handleToolDispatch(id, params) {
if (params.name === "configure") {
let text;
try {
text = await this.pairing.handleConfigure();
}
catch (err) {
this.sendError(protocol_js_1.ErrorCode.INTERNAL, err.message, id);
return;
}
this.sendResult(id, { content: [{ type: "text", text }] });
return;
}
if (!this.state.config) {
this.sendError(protocol_js_1.ErrorCode.PROXY_NOT_CONFIGURED, "Call the `configure` tool first.", id);
return;
}
const route = this.state.toolRoute.get(params.name);
if (!route || !(0, filtering_js_1.isServerSelected)(this.state.config, route.hostId, route.serverName)) {
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, `Unknown tool: ${params.name}`, id);
return;
}
// selectedTools is a tool-level filter on top of the server-level gate.
if (this.state.config.selectedTools !== undefined && !this.state.config.selectedTools.includes(params.name)) {
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, `Unknown tool: ${params.name}`, id);
return;
}
// Preserve `_meta` so the upstream server still sees the agent's
// progressToken and can emit notifications/progress against it.
const upstream = { name: route.originalName, arguments: params.arguments };
if (params._meta !== undefined)
upstream._meta = params._meta;
await this.forwarder.forwardRoutedRequest(id, route, "tools/call", upstream);
}
async handleClientNotification(method, params) {
// Sent during initServer for each upstream session — never re-broadcast.
if (method === "notifications/initialized")
return;
if (method === "notifications/cancelled") {
const reqId = params.requestId;
if (reqId === undefined)
return;
// Two id namespaces. `inflight` covers requests we sent upstream
// (tools/call, prompts/get, resources/*). `bridge` covers
// server→client requests we forwarded out (sampling, elicitation,
// roots/list, ping): the client sees our synthetic id and cancels
// using that, so we translate it back to the upstream's original id
// before forwarding. Without this branch the upstream child waited
// the full UPSTREAM_REQUEST_TIMEOUT_MS for a response the client had
// already abandoned.
const route = this.state.inflight.get(reqId);
if (route) {
this.forwarder.forwardNotification(route, method, params).catch(() => { });
return;
}
const ctx = this.bridge.consumeForCancel(reqId);
if (ctx) {
const translated = { ...params, requestId: ctx.originalId };
this.forwarder.forwardNotification({ hostId: ctx.hostId, serverName: ctx.serverName, originalName: "" }, method, translated).catch(() => { });
}
return;
}
if (method === "notifications/roots/list_changed") {
const targets = [];
for (const host of this.state.hosts.values()) {
for (const serverName of host.servers.keys()) {
if (!(0, filtering_js_1.isServerSelected)(this.state.config, host.config.id, serverName))
continue;
targets.push({ hostId: host.config.id, serverName, originalName: "" });
}
}
await Promise.all(targets.map((t) => this.forwarder.forwardNotification(t, method, params).catch(() => { })));
return;
}
if (method === "notifications/progress") {
const progressToken = params.progressToken;
if (progressToken === undefined)
return;
const route = this.state.progressTokens.get(progressToken);
if (!route)
return;
this.forwarder.forwardNotification(route, method, params).catch(() => { });
return;
}
}
}
exports.RequestHandlers = RequestHandlers;
import type { HostState } from "../core/types.js";
export interface SseCallbacks {
isCurrent: (host: HostState, name: string, sessionId: string) => boolean;
onUpstreamRequest: (host: HostState, name: string, msg: {
id: string | number;
method: string;
params?: unknown;
}) => void;
onListChanged: (host: HostState, name: string, kind: "tools" | "prompts" | "resources") => Promise<void>;
onNotification: (msg: unknown) => void;
}
export declare class SseReader {
private readonly cb;
constructor(cb: SseCallbacks);
start(host: HostState, name: string, sessionId: string): void;
private loop;
private consume;
private dispatchData;
}
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SseReader = void 0;
const eventsource_parser_1 = require("eventsource-parser");
const constants_js_1 = require("../core/constants.js");
const uri_js_1 = require("../routing/uri.js");
function sleepCancellable(ms, signal) {
return new Promise((resolveP) => {
if (signal.aborted)
return resolveP();
const timer = setTimeout(resolveP, ms);
signal.addEventListener("abort", () => {
clearTimeout(timer);
resolveP();
}, { once: true });
});
}
class SseReader {
cb;
constructor(cb) {
this.cb = cb;
}
// Owner-side entry: ensure exactly one loop is active per (host, server)
// session id. Aborting the previous controller torpedoes a stale loop
// before its retry chain reconnects to a session id that's been rotated.
start(host, name, sessionId) {
const prev = host.sseControllers.get(name);
if (prev)
prev.abort();
const ctrl = new AbortController();
host.sseControllers.set(name, ctrl);
void this.loop(host, name, sessionId, ctrl).finally(() => {
if (host.sseControllers.get(name) === ctrl)
host.sseControllers.delete(name);
});
}
async loop(host, name, sessionId, ctrl) {
let backoff = constants_js_1.SSE_BACKOFF_INITIAL_MS;
while (!ctrl.signal.aborted) {
if (!this.cb.isCurrent(host, name, sessionId))
return;
const url = `${host.config.tunnelUrl}/servers/${name}`;
// Connect-phase abort wiring: a separate inner controller fires when
// EITHER the lifecycle signal aborts OR the connect budget elapses.
// We can't pass `AbortSignal.any([ctrl.signal, AbortSignal.timeout(N)])`
// straight to fetch(), because the same signal is then attached to
// the response body — meaning a 15 s timeout would also kill a
// healthy long-lived stream after 15 s. Instead we tear down the
// timer / lifecycle relay the moment fetch() resolves, leaving the
// body cancellation path in consume() to use ctrl.signal directly.
const connectCtrl = new AbortController();
const lifecycleRelay = () => connectCtrl.abort();
ctrl.signal.addEventListener("abort", lifecycleRelay, { once: true });
const connectTimer = setTimeout(() => connectCtrl.abort(), constants_js_1.SSE_CONNECT_TIMEOUT_MS);
try {
const resp = await fetch(url, {
method: "GET",
headers: {
Accept: "text/event-stream",
Authorization: `Bearer ${host.config.authToken}`,
"Mcp-Session-Id": sessionId,
},
signal: connectCtrl.signal,
});
clearTimeout(connectTimer);
ctrl.signal.removeEventListener("abort", lifecycleRelay);
if (resp.ok && resp.body) {
backoff = constants_js_1.SSE_BACKOFF_INITIAL_MS;
await this.consume(host, name, resp.body, ctrl.signal);
}
else if (resp.status === 401 || resp.status === 404) {
// Auth changed or session vanished — no point retrying this loop.
// The next upstream POST will rotate the session id and start a
// fresh loop via the orchestrator's captureSessionId.
return;
}
}
catch {
if (ctrl.signal.aborted)
return;
// Transient — fall through to backoff.
}
finally {
clearTimeout(connectTimer);
ctrl.signal.removeEventListener("abort", lifecycleRelay);
}
if (ctrl.signal.aborted)
return;
await sleepCancellable(backoff, ctrl.signal);
backoff = Math.min(backoff * 2, constants_js_1.SSE_BACKOFF_MAX_MS);
}
}
async consume(host, name, body, signal) {
const reader = body.getReader();
const onAbort = () => { reader.cancel().catch(() => { }); };
signal.addEventListener("abort", onAbort, { once: true });
const decoder = new TextDecoder();
// Hand the byte→event split to eventsource-parser. It correctly handles
// CRLF, BOM, retry: directives, comment lines, and event types — none
// of which we'd otherwise be reasoning about ourselves. Our only job
// here is to JSON-parse `data` and dispatch.
const parser = (0, eventsource_parser_1.createParser)({
onEvent: (evt) => this.dispatchData(host, name, evt.data),
});
try {
while (true) {
const { done, value } = await reader.read();
if (done)
return;
parser.feed(decoder.decode(value, { stream: true }));
}
}
finally {
signal.removeEventListener("abort", onAbort);
}
}
dispatchData(host, name, payload) {
let msg;
try {
msg = JSON.parse(payload);
}
catch {
return;
}
if (msg.jsonrpc !== "2.0")
return;
const hasMethod = typeof msg.method === "string";
const hasId = msg.id !== undefined && msg.id !== null;
// Server-initiated request: bridge to the client and let the orchestrator
// remember enough to route the response back to this session.
if (hasMethod && hasId) {
this.cb.onUpstreamRequest(host, name, msg);
return;
}
if (!hasMethod)
return; // stray response — not expected on this stream
// Notifications: list_changed events trigger a cache refresh BEFORE
// forwarding so the agent's follow-up list call sees fresh data.
// resources/updated carries the upstream's raw URI; we wrap it in the
// proxy's `mcp+host://` envelope so the agent sees the same namespaced
// URI it subscribed under. Other notifications (logging, progress,
// cancelled, roots/list_changed) don't carry resource URIs.
if (msg.method === "notifications/tools/list_changed") {
this.cb.onListChanged(host, name, "tools").catch(() => { })
.finally(() => this.cb.onNotification(msg));
return;
}
if (msg.method === "notifications/prompts/list_changed") {
this.cb.onListChanged(host, name, "prompts").catch(() => { })
.finally(() => this.cb.onNotification(msg));
return;
}
if (msg.method === "notifications/resources/list_changed") {
this.cb.onListChanged(host, name, "resources").catch(() => { })
.finally(() => this.cb.onNotification(msg));
return;
}
if (msg.method === "notifications/resources/updated") {
const params = (msg.params ?? {});
if (typeof params.uri === "string") {
const wrapped = (0, uri_js_1.wrapResourceUri)(host.config.id, name, params.uri);
this.cb.onNotification({ ...msg, params: { ...params, uri: wrapped } });
return;
}
}
this.cb.onNotification(msg);
}
}
exports.SseReader = SseReader;
import type { HostConfig, HostState } from "../core/types.js";
export declare class UpstreamBridge {
private readonly hostHeaders;
private readonly captureSessionId;
private readonly writeToAgent;
private requests;
private counter;
constructor(hostHeaders: (host: HostConfig) => Record<string, string>, captureSessionId: (host: HostState, serverName: string, newId: string | null) => void, writeToAgent: (line: string) => void);
bridge(host: HostState, serverName: string, msg: {
id: string | number;
method: string;
params?: unknown;
}): void;
routeResponse(hosts: Map<string, HostState>, id: string | number, msg: {
jsonrpc?: string;
id?: string | number | null;
result?: unknown;
error?: unknown;
}): void;
consumeForCancel(id: string | number): {
hostId: string;
serverName: string;
originalId: string | number;
} | null;
clear(hosts: Map<string, HostState>): Promise<void>;
private postResponse;
}
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.UpstreamBridge = void 0;
const protocol_js_1 = require("../../shared/protocol.js");
const constants_js_1 = require("../core/constants.js");
const fetch_timeout_js_1 = require("../core/fetch-timeout.js");
// Bridges server-initiated MCP requests (sampling/createMessage,
// elicitation/create, roots/list, ping, …) from an upstream session out to
// the agent and back. The agent sees a synthetic id (`proxy-srv-N`); the
// upstream sees its original id. Maintaining a separate id namespace from
// `inflight` (which covers agent→server requests) means a cancellation can
// always identify the correct half of the bridge.
class UpstreamBridge {
hostHeaders;
captureSessionId;
writeToAgent;
requests = new Map();
counter = 0;
constructor(hostHeaders, captureSessionId, writeToAgent) {
this.hostHeaders = hostHeaders;
this.captureSessionId = captureSessionId;
this.writeToAgent = writeToAgent;
}
// Register an upstream request and forward its synthetic-id form to the
// agent. UPSTREAM_REQUEST_TIMEOUT_MS later (default 120s) we tell the
// upstream we never got an answer so its child stops waiting.
bridge(host, serverName, msg) {
const server = host.servers.get(serverName);
if (!server || !server.sessionId)
return;
const newId = `proxy-srv-${++this.counter}`;
// Snapshot the originating session id. server.sessionId can rotate via
// captureSessionId between bridge() and the timeout firing; the timeout
// response must go to the session that asked, not whatever the host has
// most recently issued for this server.
const sessionId = server.sessionId;
const timer = setTimeout(() => {
this.requests.delete(newId);
void this.postResponse(host, serverName, sessionId, {
jsonrpc: "2.0",
id: msg.id,
error: { code: protocol_js_1.ErrorCode.REQUEST_TIMEOUT, message: "Client did not respond in time" },
});
}, constants_js_1.UPSTREAM_REQUEST_TIMEOUT_MS);
timer.unref();
this.requests.set(newId, {
hostId: host.config.id,
serverName,
sessionId,
originalId: msg.id,
timer,
});
this.writeToAgent(JSON.stringify({
jsonrpc: "2.0",
id: newId,
method: msg.method,
params: msg.params,
}));
}
// Agent's response arrives with our synthetic id; restore the original id
// and post it to the upstream session that asked.
routeResponse(hosts, id, msg) {
const ctx = this.requests.get(id);
if (!ctx)
return;
clearTimeout(ctx.timer);
this.requests.delete(id);
const host = hosts.get(ctx.hostId);
if (!host)
return;
const body = { jsonrpc: "2.0", id: ctx.originalId };
if (msg.error !== undefined)
body.error = msg.error;
else
body.result = msg.result ?? null;
void this.postResponse(host, ctx.serverName, ctx.sessionId, body);
}
// Used by handleClientNotification when the agent cancels a bridged
// request: clear our tracking, return the original id so the caller can
// forward `notifications/cancelled` upstream with the upstream's own id.
consumeForCancel(id) {
const ctx = this.requests.get(id);
if (!ctx)
return null;
clearTimeout(ctx.timer);
this.requests.delete(id);
return { hostId: ctx.hostId, serverName: ctx.serverName, originalId: ctx.originalId };
}
// Tear down on session close / re-pair. Two responsibilities:
// 1) cancel timers so the event loop can exit and so a stale timer
// doesn't post a REQUEST_TIMEOUT response after we've already
// answered with INTERNAL below;
// 2) proactively answer each pending upstream request with a JSON-RPC
// error so the upstream child stops waiting on its own
// UPSTREAM_REQUEST_TIMEOUT_MS (120s). closeAllSessions DELETEs the
// session right after, but DELETE is fired-and-forgotten — without
// this, a network blip on the DELETE leaves the child stalled until
// its own timeout. Map is cleared first so a late routeResponse
// becomes a no-op rather than a duplicate post.
async clear(hosts) {
const pending = Array.from(this.requests.values());
this.requests.clear();
for (const ctx of pending)
clearTimeout(ctx.timer);
await Promise.allSettled(pending.map((ctx) => {
const host = hosts.get(ctx.hostId);
if (!host)
return;
return this.postResponse(host, ctx.serverName, ctx.sessionId, {
jsonrpc: "2.0",
id: ctx.originalId,
error: { code: protocol_js_1.ErrorCode.INTERNAL, message: "proxy reconfigured before client responded" },
});
}));
}
async postResponse(host, serverName, sessionId, body) {
const target = `${host.config.tunnelUrl}/servers/${serverName}`;
const headers = { ...this.hostHeaders(host.config), "Mcp-Session-Id": sessionId };
try {
const resp = await fetch(target, {
method: "POST",
headers,
signal: (0, fetch_timeout_js_1.timeoutSignal)(constants_js_1.TOOL_FORWARD_TIMEOUT_MS),
body: JSON.stringify(body),
});
this.captureSessionId(host, serverName, resp.headers.get("mcp-session-id"));
}
catch {
// Upstream unreachable — server will time out on its end.
}
}
}
exports.UpstreamBridge = UpstreamBridge;
import type { HostConfig, Prompt, Resource, ResourceTemplate, Tool } from "./core/types.js";
export declare class ProxyServer {
private state;
private sse;
private bridge;
private runner;
private forwarder;
private pairing;
private handlers;
constructor();
start(): void;
private handleLine;
}
export declare function main(): void;
export type { HostConfig, Prompt, Resource, ResourceTemplate, Tool };
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ProxyServer = void 0;
exports.main = main;
const protocol_js_1 = require("../shared/protocol.js");
const state_js_1 = require("./core/state.js");
const runner_js_1 = require("./discovery/runner.js");
const controller_js_1 = require("./pairing/controller.js");
const forwarder_js_1 = require("./runtime/forwarder.js");
const handlers_js_1 = require("./runtime/handlers.js");
const sse_js_1 = require("./runtime/sse.js");
const upstream_bridge_js_1 = require("./runtime/upstream-bridge.js");
// Composition root + JSON-RPC line router. Holds no business logic of
// its own — just wires up the modules:
// ProxyState — data the rest share
// SseReader — upstream→agent notification stream
// UpstreamBridge — server-initiated request bridge
// DiscoveryRunner — discovery + refresh + per-server init
// Forwarder — agent→server forwarding + broadcasting
// PairingController — pairing flow + atomic config swap
// RequestHandlers — per-method JSON-RPC handlers
// stdin-line in, stdout-line out; everything else is module composition.
class ProxyServer {
state = new state_js_1.ProxyState();
sse;
bridge;
runner;
forwarder;
pairing;
handlers;
constructor() {
const writeOut = (line) => { process.stdout.write(line + "\n"); };
const log = (line) => { process.stderr.write(line + "\n"); };
const sendResult = (id, result) => {
writeOut(JSON.stringify({ jsonrpc: "2.0", id, result }));
};
const sendError = (code, detail, id) => {
writeOut((0, protocol_js_1.jsonRpcError)(code, detail, id));
};
const sendNotification = (method) => {
writeOut(JSON.stringify({ jsonrpc: "2.0", method }));
};
// Build the SSE/bridge pair first — they expose narrow callback
// interfaces that DiscoveryRunner / Forwarder need to wire into.
this.sse = new sse_js_1.SseReader({
isCurrent: (host, name, sessionId) => {
if (this.state.hosts.get(host.config.id) !== host)
return false;
const server = host.servers.get(name);
return !!server && server.sessionId === sessionId;
},
onUpstreamRequest: (host, name, msg) => this.bridge.bridge(host, name, msg),
onListChanged: async (host, name, kind) => {
if (kind === "tools")
await this.runner.refreshTools(host, name);
else if (kind === "prompts")
await this.runner.refreshPrompts(host, name);
else
await this.runner.refreshResources(host, name);
},
onNotification: (msg) => writeOut(JSON.stringify(msg)),
});
this.bridge = new upstream_bridge_js_1.UpstreamBridge((host) => this.state.hostHeaders(host), (host, serverName, newId) => {
const server = host.servers.get(serverName);
if (server)
this.runner.captureSessionId(host, serverName, server, newId);
}, writeOut);
this.runner = new runner_js_1.DiscoveryRunner(this.state, this.sse, log);
this.forwarder = new forwarder_js_1.Forwarder(this.state, this.runner, log, sendError, writeOut);
this.pairing = new controller_js_1.PairingController(this.state, this.runner, this.bridge, log, sendNotification);
this.handlers = new handlers_js_1.RequestHandlers(this.state, this.runner, this.forwarder, this.pairing, this.bridge, sendResult, sendError);
}
start() {
const stdinBuffer = new protocol_js_1.LineBuffer();
process.stdin.setEncoding("utf-8");
process.stdin.on("data", (chunk) => {
for (const line of stdinBuffer.push(chunk)) {
this.handleLine(line).catch((err) => {
process.stderr.write(`Proxy error: ${err.message}\n`);
});
}
});
const shutdown = (exitCode) => {
this.pairing.teardownPairing();
this.pairing.closeAllSessions().finally(() => process.exit(exitCode));
};
process.stdin.on("end", () => shutdown(0));
process.on("SIGINT", () => shutdown(0));
process.on("SIGTERM", () => shutdown(0));
process.stderr.write(`Proxy ready (idle). Call the \`configure\` tool to begin pairing.\n`);
}
async handleLine(line) {
let parsed;
try {
parsed = JSON.parse(line);
}
catch (err) {
// Per JSON-RPC: parse failure → reply with id:null since we couldn't
// recover one. Silent drop here would leave the agent waiting on a
// request it thinks is in flight.
process.stdout.write((0, protocol_js_1.jsonRpcError)(protocol_js_1.ErrorCode.PARSE_ERROR, err.message, null) + "\n");
return;
}
const hasMethod = typeof parsed.method === "string";
const hasId = parsed.id !== undefined && parsed.id !== null;
const isResponse = !hasMethod && hasId && (parsed.result !== undefined || parsed.error !== undefined);
// Response from the client to a server-initiated request we previously
// bridged out (sampling, elicitation, roots/list, ping, …). Route it
// back to the upstream session that asked. Require result/error so a
// bare `{id:N}` falls into the invalid-request path below instead of
// being silently swallowed by routeResponse's unknown-id no-op.
if (isResponse) {
this.bridge.routeResponse(this.state.hosts, parsed.id, parsed);
return;
}
if (!hasMethod) {
// Neither a request (no method), a notification (no method either),
// nor a well-formed response (no result/error). Reply per spec so
// the agent doesn't hang; carry parsed.id when we have one.
process.stdout.write((0, protocol_js_1.jsonRpcError)(protocol_js_1.ErrorCode.INVALID_REQUEST, "missing method", parsed.id ?? null) + "\n");
return;
}
if (!hasId) {
await this.handlers.handleClientNotification(parsed.method, parsed.params ?? {});
return;
}
const id = parsed.id;
switch (parsed.method) {
case "initialize":
return this.handlers.handleInitialize(id, parsed.params);
case "ping":
// MCP `ping` is a connection-liveness no-op between two endpoints.
// The agent's peer here is the proxy itself, so we answer locally
// — there is nothing to forward and no upstream to pick when many
// servers are paired.
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, result: {} }) + "\n");
return;
case "tools/list":
return this.handlers.handleToolsList(id);
case "prompts/list":
return this.handlers.handlePromptsList(id);
case "prompts/get":
return this.handlers.handlePromptDispatch(id, parsed.params);
case "resources/list":
return this.handlers.handleResourcesList(id);
case "resources/templates/list":
return this.handlers.handleResourceTemplatesList(id);
case "resources/read":
case "resources/subscribe":
case "resources/unsubscribe":
return this.handlers.handleResourceMethod(id, parsed.method, parsed.params);
case "logging/setLevel":
return this.handlers.handleLoggingSetLevel(id, (parsed.params ?? {}));
case "completion/complete":
return this.handlers.handleCompletion(id, (parsed.params ?? {}));
case "tools/call":
return this.handlers.handleToolDispatch(id, parsed.params);
default:
process.stdout.write((0, protocol_js_1.jsonRpcError)(protocol_js_1.ErrorCode.METHOD_NOT_FOUND, parsed.method, id) + "\n");
}
}
}
exports.ProxyServer = ProxyServer;
function main() {
const proxy = new ProxyServer();
proxy.start();
}
import type { IncomingMessage, ServerResponse } from "node:http";
export declare const PACKAGE_NAME: string;
export declare const PACKAGE_VERSION: string;
export declare const MCP_PROTOCOL_VERSION = "2024-11-05";
export declare const DEFAULT_HOST = "127.0.0.1";
export declare const DEFAULT_PORT = 6270;
export declare const MAX_BODY_BYTES: number;
export declare class BodyTooLargeError extends Error {
readonly limit: number;
constructor(limit: number);
}
export declare const ErrorCode: {
readonly PARSE_ERROR: -32700;
readonly INVALID_REQUEST: -32600;
readonly METHOD_NOT_FOUND: -32601;
readonly INVALID_PARAMS: -32602;
readonly INTERNAL: -32603;
readonly PROXY_NOT_CONFIGURED: -32001;
readonly HOST_UNREACHABLE: -32002;
readonly PROCESS_EXITED: -32003;
readonly PROCESS_NOT_RUNNING: -32004;
readonly REQUEST_TIMEOUT: -32005;
};
export declare const ErrorMessage: {
readonly [-32700]: "Parse error";
readonly [-32600]: "Invalid request";
readonly [-32601]: "Method not found";
readonly [-32602]: "Invalid params";
readonly [-32603]: "Internal error";
readonly [-32001]: "Proxy not configured";
readonly [-32002]: "Host agent unreachable";
readonly [-32003]: "Server process exited";
readonly [-32004]: "Server process not running";
readonly [-32005]: "Request timed out";
};
export declare function jsonRpcError(code: number, detail?: string, id?: string | number | null): string;
export declare function readBody(req: IncomingMessage, maxBytes?: number): Promise<string>;
export declare function getArg(name: string): string | undefined;
export declare function createServer(handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>): import("http").Server<typeof IncomingMessage, typeof ServerResponse>;
export declare class LineBuffer {
private buffer;
push(chunk: string): string[];
}
export declare const TOOL_NAME_SEPARATOR = "__";
export declare const SERVER_NAME_PATTERN: RegExp;
export declare function validateServerName(name: string): string | null;
export interface ServerConfig {
command: string;
args: string[];
env?: Record<string, string>;
shell?: boolean;
}
export interface HostAgentConfig {
servers: Record<string, ServerConfig>;
host?: string;
port?: number;
}
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SERVER_NAME_PATTERN = exports.TOOL_NAME_SEPARATOR = exports.LineBuffer = exports.ErrorMessage = exports.ErrorCode = exports.BodyTooLargeError = exports.MAX_BODY_BYTES = exports.DEFAULT_PORT = exports.DEFAULT_HOST = exports.MCP_PROTOCOL_VERSION = exports.PACKAGE_VERSION = exports.PACKAGE_NAME = void 0;
exports.jsonRpcError = jsonRpcError;
exports.readBody = readBody;
exports.getArg = getArg;
exports.createServer = createServer;
exports.validateServerName = validateServerName;
const node_fs_1 = require("node:fs");
const node_http_1 = require("node:http");
const node_path_1 = require("node:path");
// dist/shared/protocol.js → ../../package.json (project root, same as the
// installed npm package's root). package.json is always shipped in the
// tarball regardless of the `files` whitelist, so this works in both
// local dev and installed contexts.
const pkg = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.resolve)(__dirname, "..", "..", "package.json"), "utf-8"));
exports.PACKAGE_NAME = pkg.name;
exports.PACKAGE_VERSION = pkg.version;
exports.MCP_PROTOCOL_VERSION = "2024-11-05";
exports.DEFAULT_HOST = "127.0.0.1";
exports.DEFAULT_PORT = 6270;
// Cap on the size of any single inbound HTTP request body. Both the
// pairing endpoints and the host agent sit behind a Cloudflare tunnel
// gated by a bearer token; without a cap, a leaked token gives an
// attacker a trivial memory-DoS by streaming an arbitrarily large body.
// 4 MiB is comfortably above any plausible MCP JSON-RPC request (tool
// args, init handshakes) while keeping worst-case memory bounded.
exports.MAX_BODY_BYTES = 4 * 1024 * 1024;
class BodyTooLargeError extends Error {
limit;
constructor(limit) {
super(`request body exceeds ${limit} bytes`);
this.limit = limit;
this.name = "BodyTooLargeError";
}
}
exports.BodyTooLargeError = BodyTooLargeError;
// JSON-RPC error codes: -32700/-32600..-32603 = spec-defined, -32000..-32099 = server-defined
exports.ErrorCode = {
PARSE_ERROR: -32700, // JSON-RPC spec: invalid JSON received
INVALID_REQUEST: -32600, // JSON-RPC spec: malformed request envelope
METHOD_NOT_FOUND: -32601, // JSON-RPC spec: method not found
INVALID_PARAMS: -32602, // JSON-RPC spec: invalid params
INTERNAL: -32603, // JSON-RPC spec: internal error
PROXY_NOT_CONFIGURED: -32001, // Proxy has not been paired yet
HOST_UNREACHABLE: -32002, // Cannot reach the host agent via tunnel
PROCESS_EXITED: -32003, // MCP server child process exited unexpectedly
PROCESS_NOT_RUNNING: -32004, // MCP server child process is not running
REQUEST_TIMEOUT: -32005, // MCP server did not respond in time
};
exports.ErrorMessage = {
[exports.ErrorCode.PARSE_ERROR]: "Parse error",
[exports.ErrorCode.INVALID_REQUEST]: "Invalid request",
[exports.ErrorCode.METHOD_NOT_FOUND]: "Method not found",
[exports.ErrorCode.INVALID_PARAMS]: "Invalid params",
[exports.ErrorCode.INTERNAL]: "Internal error",
[exports.ErrorCode.PROXY_NOT_CONFIGURED]: "Proxy not configured",
[exports.ErrorCode.HOST_UNREACHABLE]: "Host agent unreachable",
[exports.ErrorCode.PROCESS_EXITED]: "Server process exited",
[exports.ErrorCode.PROCESS_NOT_RUNNING]: "Server process not running",
[exports.ErrorCode.REQUEST_TIMEOUT]: "Request timed out",
};
// JSON-RPC error response helper
function jsonRpcError(code, detail, id = null) {
const base = exports.ErrorMessage[code] ?? "Unknown error";
const message = detail ? `${base}: ${detail}` : base;
return JSON.stringify({ jsonrpc: "2.0", error: { code, message }, id });
}
// Read full request body as string, rejecting with BodyTooLargeError once
// the running total exceeds maxBytes. We pause the request once over the
// limit so we stop accumulating into memory; the socket is torn down by
// createServer's 413 path after the response flushes (destroying it here
// races the response and the client never sees the 413).
//
// Settles on the first of: end (resolve), error (reject), close-without-end
// (reject as ECONNRESET-style), or oversize (reject as BodyTooLargeError).
// All four listeners are torn down on settle so a paused/aborted upload
// can't leave the closure (and the accumulated chunks) pinned in memory.
function readBody(req, maxBytes = exports.MAX_BODY_BYTES) {
return new Promise((resolve, reject) => {
const chunks = [];
let total = 0;
let settled = false;
const cleanup = () => {
req.off("data", onData);
req.off("end", onEnd);
req.off("error", onError);
req.off("close", onClose);
};
const settleResolve = (value) => {
if (settled)
return;
settled = true;
cleanup();
resolve(value);
};
const settleReject = (err) => {
if (settled)
return;
settled = true;
cleanup();
reject(err);
};
const onData = (c) => {
if (settled)
return;
total += c.length;
if (total > maxBytes) {
// Pause so we stop accumulating; rejection runs the 413 path in
// createServer, which destroys the socket after the response flushes.
req.pause();
settleReject(new BodyTooLargeError(maxBytes));
return;
}
chunks.push(c);
};
const onEnd = () => settleResolve(Buffer.concat(chunks).toString("utf-8"));
const onError = (err) => settleReject(err);
// 'close' fires after end OR after an abort. If end ran first we're
// already settled and this is a no-op; otherwise the client disconnected
// mid-upload and we must reject so the handler doesn't hang forever.
const onClose = () => settleReject(new Error("client closed connection before request body completed"));
req.on("data", onData);
req.on("end", onEnd);
req.on("error", onError);
req.on("close", onClose);
});
}
// Parse CLI argument by name: --flag value
function getArg(name) {
const idx = process.argv.indexOf(name);
return idx !== -1 && idx + 1 < process.argv.length ? process.argv[idx + 1] : undefined;
}
// Create HTTP server with async handler and error catching. BodyTooLargeError
// is special-cased to 413 + Connection: close so the client sees a clean
// "payload too large" instead of the catch-all 500. After the response
// flushes we destroy the socket so an attacker can't keep streaming bytes
// into the kernel buffer beyond the limit we just enforced.
function createServer(handler) {
return (0, node_http_1.createServer)((req, res) => {
handler(req, res).catch((err) => {
console.error(`Request handler error: ${err.message}`);
if (!res.headersSent) {
if (err instanceof BodyTooLargeError) {
res.writeHead(413, { "Content-Type": "application/json", Connection: "close" });
res.end(JSON.stringify({ error: err.message }), () => {
req.socket?.destroy();
});
return;
}
res.writeHead(500, { "Content-Type": "application/json" });
res.end(jsonRpcError(exports.ErrorCode.INTERNAL));
}
});
});
}
// Line-buffered reader: accumulates chunks and yields complete lines
class LineBuffer {
buffer = "";
push(chunk) {
this.buffer += chunk;
const parts = this.buffer.split("\n");
this.buffer = parts.pop(); // Keep incomplete trailing segment
return parts.filter((line) => line.trim().length > 0);
}
}
exports.LineBuffer = LineBuffer;
// Single source of truth for the server-name policy enforced everywhere
// (host config load, proxy discovery filter, and the Pages function path
// allowlist). Keeping these in lockstep prevents valid host config entries
// from silently disappearing during discovery.
exports.TOOL_NAME_SEPARATOR = "__";
exports.SERVER_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
// Returns null if the name is acceptable, else a human-readable reason.
function validateServerName(name) {
if (!exports.SERVER_NAME_PATTERN.test(name)) {
return `must match ${exports.SERVER_NAME_PATTERN} (letters, digits, '.', '_', '-')`;
}
if (name.includes(exports.TOOL_NAME_SEPARATOR)) {
return `must not contain '${exports.TOOL_NAME_SEPARATOR}' (reserved as tool-name separator)`;
}
return null;
}
#!/usr/bin/env node
export {};
#!/usr/bin/env node
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
// Cloudflared wrapper. Owns the cloudflared child for the proxy's pairing
// tunnel, and guarantees the child cannot outlive its parent (proxy).
//
// Lifecycle protocol on stdio:
// parent -> wrapper: writes "stop\n" to request a clean shutdown
// parent dies : stdin closes, wrapper sees EOF and tears the child down
// wrapper -> parent: prints "URL <tunnel-url>\n" once the tunnel is ready
// wrapper -> parent: prints "ERR <message>\n" for each cloudflared error
// so the parent can include the actual cause in any
// rejection / unexpected-exit log instead of a generic
// "exited before becoming ready"
// wrapper -> parent: prints "EXIT <code>\n" when cloudflared exits
//
// Detection latency for parent death is 0ms on Linux/macOS/Windows because
// stdin EOF is delivered by the kernel at the moment the parent's pipe FD
// is closed; no polling is needed.
const cloudflared_1 = require("cloudflared");
const protocol_js_1 = require("./shared/protocol.js");
function main() {
const port = parseInt(process.argv[2] ?? "", 10);
if (!port || Number.isNaN(port)) {
process.stderr.write("Usage: wrapper.js <port>\n");
process.exit(2);
}
const tunnel = cloudflared_1.Tunnel.quick(`http://localhost:${port}`);
let stopping = false;
const stop = (code = 0) => {
if (stopping)
return;
stopping = true;
try {
tunnel.stop();
}
catch { /* already stopped */ }
// Give cloudflared a moment to flush, then exit.
setTimeout(() => process.exit(code), 250).unref();
};
tunnel.once("url", (url) => {
process.stdout.write(`URL ${url}\n`);
});
tunnel.on("error", (err) => {
// Forward to parent on the structured channel (stdout) so PairingTunnel
// can include the cause in its rejection. Also keep the human-readable
// line on stderr — stdio is inherited, so operators tailing logs still
// see the full cloudflared diagnostic context.
const message = err.message.replace(/\r?\n/g, " ");
process.stdout.write(`ERR ${message}\n`);
process.stderr.write(`tunnel error: ${err.message}\n`);
});
tunnel.on("exit", (code) => {
process.stdout.write(`EXIT ${code ?? ""}\n`);
stop(typeof code === "number" ? code : 0);
});
// Detect parent death via stdin EOF; also accept a "stop" line for clean
// shutdown initiated by the parent after pairing completes.
const buf = new protocol_js_1.LineBuffer();
process.stdin.setEncoding("utf-8");
process.stdin.on("data", (chunk) => {
for (const line of buf.push(chunk)) {
if (line.trim() === "stop")
stop(0);
}
});
process.stdin.on("end", () => stop(0));
process.stdin.on("close", () => stop(0));
process.on("SIGINT", () => stop(0));
process.on("SIGTERM", () => stop(0));
}
main();
/* Setup page layout. Base styles (button, banner, hint, card, etc.) live in
style.css; this file owns layout that's specific to the pairing flow:
host rows in step 1, server/tool selection in step 2. */
.step {
display: none;
}
.step.active {
display: block;
}
/* Hosts flow as a grid so wider viewports can show multiple hosts side by
side. minmax(280px, 1fr) collapses to a single column on mobile (one
host = full width) and auto-fits extra columns when the card grows on
tablet/desktop. auto-fit (not auto-fill) collapses empty tracks so a
single host stretches to the full row instead of leaving a gap. */
#hosts-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 0.875rem;
margin-top: 0.875rem;
}
.host-row {
border: 1px solid #334155;
border-radius: 8px;
padding: 0.875rem 1rem 1rem;
}
.host-row .host-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.host-row .host-id {
font-weight: 600;
color: #38bdf8;
font-size: 0.95rem;
}
.host-row .host-remove {
background: transparent;
color: #f87171;
border: 1px solid #4b5563;
border-radius: 6px;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
width: auto;
margin: 0;
}
.host-row .host-remove:hover {
background: #3f1d1d;
}
.host-row label {
margin-top: 0.625rem;
}
.host-row .host-status {
font-size: 0.75rem;
margin-top: 0.5rem;
color: #94a3b8;
}
.host-row .host-status.error {
color: #fca5a5;
}
.host-row .host-status.partial {
color: #fbbf24;
}
.host-row .host-status.ok {
color: #6ee7b7;
}
#add-host-btn {
background: #334155;
margin-top: 0.75rem;
}
.step2-actions {
display: flex;
gap: 0.5rem;
margin-top: 1.5rem;
}
.step2-actions button {
margin-top: 0;
width: auto;
}
.step2-actions #back-btn {
flex: 0 0 auto;
background: #334155;
}
.step2-actions #save-btn {
flex: 1;
}
.host-block {
margin-top: 1.25rem;
padding-top: 0.75rem;
border-top: 1px solid #334155;
}
.host-block:first-of-type {
border-top: none;
margin-top: 0;
padding-top: 0;
}
.host-block > .host-block-head {
font-size: 0.8rem;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.25rem;
}
.server-group {
margin-top: 1rem;
padding-left: 0.25rem;
}
/* On wider viewports, lay each host's server groups out as a grid so
multiple servers can sit side by side. auto-fit collapses empty tracks
so a host with one server still stretches edge-to-edge. The block-head
spans the full width because it's the host-id label, not a server. */
@media (min-width: 768px) {
.host-block {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1rem;
align-items: start;
}
.host-block > .host-block-head {
grid-column: 1 / -1;
}
.server-group {
margin-top: 0;
}
}
.server-group h3 {
font-size: 0.95rem;
color: #e2e8f0;
word-break: break-all;
}
.server-group h3 .scope {
color: #38bdf8;
font-weight: 500;
}
.server-group .server-counts {
color: #64748b;
font-size: 0.8rem;
margin-top: 0.125rem;
margin-bottom: 0.5rem;
}
.server-group.disabled .tool-list {
opacity: 0.4;
pointer-events: none;
}
.server-group.disabled .select-actions {
opacity: 0.4;
pointer-events: none;
}
.server-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: #cbd5e1;
margin-bottom: 0.5rem;
}
.server-toggle input[type='checkbox'] {
accent-color: #38bdf8;
cursor: pointer;
width: auto;
flex: 0 0 auto;
}
.tool-list {
max-height: 280px;
overflow-y: auto;
border: 1px solid #334155;
border-radius: 6px;
}
.tool-item {
display: flex;
align-items: center;
border-bottom: 1px solid #1e293b;
}
.tool-item:last-child {
border-bottom: none;
}
.tool-item:hover {
background: #253348;
}
.tool-check {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem 0.625rem;
flex-shrink: 0;
}
.tool-check input[type='checkbox'] {
accent-color: #38bdf8;
cursor: pointer;
}
.tool-label {
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.125rem;
margin: 0;
padding: 0.5rem 0.75rem 0.5rem 0;
cursor: pointer;
font-size: 0.85rem;
flex: 1;
min-width: 0;
}
.tool-name {
font-weight: 600;
color: #e2e8f0;
}
.tool-desc {
font-size: 0.75rem;
color: #64748b;
line-height: 1.3;
}
.select-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.select-actions button {
flex: 1;
padding: 0.375rem;
font-size: 0.8rem;
background: #334155;
margin-top: 0;
}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MCP Proxy – Setup</title>
<link rel="stylesheet" href="/style.css" />
<link rel="stylesheet" href="/setup.css" />
</head>
<body>
<div class="card">
<h1>MCP Proxy Setup</h1>
<div id="step1" class="step active">
<p class="desc">
Add one or more host agents. Each host has its own tunnel URL and
auth token. Tools will be namespaced by host id when exposed to
the MCP client.
</p>
<form id="tunnel-form">
<div id="hosts-container"></div>
<button type="button" id="add-host-btn">
+ Add another host
</button>
<button type="submit" id="tunnel-btn">Discover Servers</button>
</form>
</div>
<div id="step2" class="step">
<p class="desc">
Select the servers and tools to expose through the proxy.
Unchecking a server hides every capability it offers — tools,
prompts, and resources alike.
</p>
<div id="servers-container"></div>
<p id="save-hint" class="hint">
Select at least one server to continue.
</p>
<div class="step2-actions">
<button type="button" id="back-btn">← Back</button>
<button id="save-btn" disabled>Complete Setup</button>
</div>
</div>
<div id="result-section" style="display: none"></div>
<div id="error-section" style="display: none">
<div class="banner error">
Invalid setup link. The URL must contain the proxy's bearer token
in the hash fragment (e.g. <code>/#token=...</code>).
</div>
</div>
</div>
<script src="/setup.js"></script>
</body>
</html>
// Pairing UI logic. Loaded by setup.html, talks to the proxy's pairing
// HTTP server (which is the same origin as this page — both are served by
// the proxy's ephemeral pairing tunnel, so no CORS dance). The bearer token
// rides in the URL fragment so it never appears in server access logs or
// Referer headers.
//
// Discovery is proxy-mediated: the page POSTs host credentials to
// /pair/list-servers and /pair/discover, and the proxy runs the same
// MCP handshake it will use at runtime — including the real client's
// captured capabilities/clientInfo. This is the single source of truth
// for what the user sees during setup vs. what the proxy will see at
// runtime; capability-gated upstreams cannot diverge between the two.
(function () {
'use strict';
// Per-fetch budget for everything we send through /pair/*. The proxy
// applies its own per-upstream budget on top of this; this guard is
// about not pinning the page on a hung pairing tunnel.
const DISCOVERY_TIMEOUT_MS = 30000;
const TOOL_SEPARATOR = '__';
const hashParams = new URLSearchParams(location.hash.slice(1));
const pairingToken = hashParams.get('token');
if (!pairingToken) {
document.getElementById('step1').classList.remove('active');
document.getElementById('error-section').style.display = 'block';
throw new Error('Missing token');
}
// hosts: in-order list of { uid, id, tunnelUrl, authToken, servers, errors, capWarnings, status }
// uid is a transient DOM key; id is the user-facing host slug.
// servers is a map of name → { tools, prompts, resources, templates }.
// errors is a map of name → fatal discovery error string (server is
// unusable: tools/list failed, transport blew up, init handshake
// never completed). Drives the save-gate refusal.
// capWarnings is a map of name → optional-capability error string
// (prompts/list, resources/list, or resources/templates/list failed
// for an otherwise-healthy server). The runtime proxy retries these
// independently — surfacing them loudly so the user sees what is
// wrong, but NOT blocking save: blocking would refuse pairings the
// backend is explicitly designed to tolerate.
const hosts = [];
let hostUidCounter = 0;
// Reconfigure pre-fill: when /pair/info reports an existing config, we
// remember the prior server/tool allowlists here so renderServerTools()
// can restore the checkbox state after re-discovery. undefined entries
// mean "no prior pairing" → fall back to the default-everything-checked
// behaviour.
const priorSelections = { selectedServers: undefined, selectedTools: undefined };
// Host ids captured at bootstrap. priorSelections is only consulted for
// hosts whose id is in this set — so adding a new host, removing one,
// or renaming an id falls back to default-everything-checked for that
// host, while in-place reconfigure (only tunnelUrl/authToken changed)
// still inherits priors correctly.
const bootstrapHostIds = new Set();
function pushHost(initial) {
const uid = `h${++hostUidCounter}`;
hosts.push({
uid,
id: initial?.id || '',
tunnelUrl: initial?.tunnelUrl || '',
authToken: initial?.authToken || '',
servers: {},
errors: {},
capWarnings: {},
status: '',
});
return uid;
}
function newHostRow(initialId) {
pushHost({ id: initialId });
renderHostRows();
}
function removeHost(uid) {
const idx = hosts.findIndex((h) => h.uid === uid);
if (idx === -1) return;
hosts.splice(idx, 1);
if (hosts.length === 0) newHostRow('host-1');
else renderHostRows();
}
function renderHostRows() {
const container = document.getElementById('hosts-container');
container.innerHTML = '';
for (const host of hosts) {
const row = document.createElement('div');
row.className = 'host-row';
row.dataset.uid = host.uid;
row.innerHTML = `
<div class="host-head">
<div class="host-id" data-role="title">Host ${esc(host.id || '(unnamed)')}</div>
<button type="button" class="host-remove" data-action="remove">Remove</button>
</div>
<label for="id">Host ID</label>
<input id="id" type="text" data-field="id" value="${esc(host.id)}" placeholder="dev-laptop" required pattern="(?!.*__)[A-Za-z0-9._\\-]+" title="Letters, digits, '.', '_', '-'. Must not contain '__' and must be unique across hosts." />
<label for="tunnelUrl">Tunnel URL</label>
<input id="tunnelUrl" type="url" data-field="tunnelUrl" value="${esc(host.tunnelUrl)}" placeholder="https://abc-xyz.trycloudflare.com" required />
<label for="authToken">Auth Token</label>
<input id="authToken" type="text" data-field="authToken" value="${esc(host.authToken)}" placeholder="Paste token from host agent" required />
<div class="host-status ${host.status.startsWith('Error') ? 'error' : host.status.startsWith('Partial') ? 'partial' : host.status ? 'ok' : ''}">${esc(host.status)}</div>
`;
container.appendChild(row);
}
// Hide remove button when there's only one row.
const removeBtns = container.querySelectorAll('[data-action="remove"]');
if (removeBtns.length === 1) removeBtns[0].style.display = 'none';
// Newly-added rows have no setCustomValidity state and removing a row
// can resolve a duplicate flag on another; re-sweep so the UI is in
// sync with the current id values.
validateHostIdUniqueness();
}
document.getElementById('hosts-container').addEventListener('input', (e) => {
const row = e.target.closest('.host-row');
if (!row) return;
const host = hosts.find((h) => h.uid === row.dataset.uid);
if (!host) return;
const field = e.target.dataset.field;
if (!field) return;
host[field] = e.target.value;
if (field === 'id') {
const title = row.querySelector('[data-role="title"]');
if (title) title.textContent = `Host ${host.id || '(unnamed)'}`;
// Cross-field constraint: re-evaluate the duplicate-id rule on
// every keystroke. The pattern/required attributes already cover
// single-input rules; this is the one check that needs to look at
// its siblings, so we surface it through setCustomValidity rather
// than waiting for submit.
validateHostIdUniqueness();
}
});
// Walk every host-id input and flag duplicates with setCustomValidity.
// Calling setCustomValidity('') first clears any previous custom error
// without disturbing the native pattern/required validation, so a field
// that was duplicate but became unique falls back to its real validity
// state (which may still be invalid for other reasons). The :user-invalid
// CSS rule paints the border red uniformly across native and custom
// failures.
function validateHostIdUniqueness() {
const inputs = document.querySelectorAll('input[data-field="id"]');
const seen = new Map();
for (const input of inputs) input.setCustomValidity('');
for (const input of inputs) {
const v = input.value.trim();
if (!v) continue;
const prev = seen.get(v);
if (prev) {
const msg = `Host id "${v}" is already used by another host`;
input.setCustomValidity(msg);
prev.setCustomValidity(msg);
} else {
seen.set(v, input);
}
}
}
document.getElementById('hosts-container').addEventListener('click', (e) => {
const btn = e.target.closest('[data-action="remove"]');
if (!btn) return;
const row = btn.closest('.host-row');
if (row) removeHost(row.dataset.uid);
});
document.getElementById('add-host-btn').addEventListener('click', () => {
newHostRow(`host-${hosts.length + 1}`);
});
// Bootstrap: ask the proxy whether it's already configured. If so,
// pre-fill the host inputs and remember the prior selections so the
// user can tweak instead of retyping everything. We render a single
// empty row immediately so the page is interactive even if /pair/info
// is slow or fails — the prefill swaps the rows in once the response
// lands.
newHostRow('host-1');
bootstrap().catch((err) => {
console.warn('Could not load existing pairing config:', err);
});
async function bootstrap() {
const resp = await pairingFetch('/pair/info', { method: 'GET' });
if (!resp.ok) return;
const info = await resp.json();
const current = info && info.current;
if (!current || !Array.isArray(current.hosts) || current.hosts.length === 0) return;
hosts.length = 0;
bootstrapHostIds.clear();
for (const h of current.hosts) {
pushHost({ id: h.id, tunnelUrl: h.tunnelUrl, authToken: h.authToken });
bootstrapHostIds.add(h.id);
}
if (Array.isArray(current.selectedServers)) {
priorSelections.selectedServers = new Set(current.selectedServers);
}
if (Array.isArray(current.selectedTools)) {
priorSelections.selectedTools = new Set(current.selectedTools);
}
renderHostRows();
}
async function pairingFetch(path, init = {}) {
const headers = {
'Authorization': `Bearer ${pairingToken}`,
'Content-Type': 'application/json',
...(init.headers || {}),
};
// Per-call timeout so a hung pairing tunnel can't lock up the page.
const signal = init.signal || AbortSignal.timeout(DISCOVERY_TIMEOUT_MS);
return fetch(path, { ...init, headers, signal });
}
async function pairPost(path, body) {
const resp = await pairingFetch(path, {
method: 'POST',
body: JSON.stringify(body),
});
let payload = null;
try {
payload = await resp.json();
} catch {
// Non-JSON body (transport-layer 502 from the pairing server, etc.).
}
return { resp, payload };
}
// --- Step 1: Discover servers across all configured hosts ---
document.getElementById('tunnel-form').addEventListener('submit', async (e) => {
e.preventDefault();
// Native form validation has already gated us — the browser blocks
// submit on any failing required/pattern/type=url constraint, and
// setCustomValidity wires the cross-field duplicate-id check into
// the same machinery. So by the time we get here every input is
// valid; we only need to normalise whitespace and run discovery.
const btn = document.getElementById('tunnel-btn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Discovering...';
for (const host of hosts) {
host.id = host.id.trim();
host.tunnelUrl = host.tunnelUrl.replace(/\/+$/, '');
host.authToken = host.authToken.trim();
}
// Parallel host discovery. discoverHost never throws — failures are
// recorded on host.status / host.errors so one bad host doesn't
// poison the batch. allSettled is defensive symmetry for the same
// reason.
await Promise.allSettled(hosts.map(discoverHost));
renderServerTools();
updateSaveState();
// Strict gate: every host must be reachable AND every server's
// tools/init must succeed. capWarnings (transient prompts/resources/
// templates failures) are intentionally NOT blocking — the runtime
// proxy retries them independently and the server stays online for
// tools regardless. They're surfaced loudly in the per-server banner
// so the user can see what was flaky, but they don't refuse the save
// gate the backend is explicitly designed to accept.
const failures = [];
for (const h of hosts) {
if (h.status.startsWith('Error')) {
failures.push(`${h.id}: ${h.status.replace(/^Error:\s*/, '')}`);
continue;
}
const serverNames = Object.keys(h.servers);
if (serverNames.length === 0) {
failures.push(`${h.id}: no servers exposed`);
continue;
}
for (const name of serverNames) {
if (h.errors[name]) failures.push(`${h.id}/${name}: ${h.errors[name]}`);
}
}
if (failures.length > 0) {
showError(`Fix these issues before continuing:\n• ${failures.join('\n• ')}`);
btn.disabled = false;
btn.textContent = 'Discover Servers';
return;
}
document.getElementById('step1').classList.remove('active');
document.getElementById('step2').classList.add('active');
btn.disabled = false;
btn.textContent = 'Discover Servers';
});
document.getElementById('back-btn').addEventListener('click', () => {
// Returning to step 1 keeps the host inputs (id/tunnelUrl/authToken)
// intact in the `hosts` array, so the user can correct one host's
// token without retyping the others. Discovered servers/errors are
// cleared because re-pressing "Discover" will repopulate them; we
// don't want stale error banners hanging around if the underlying
// host has since been fixed.
for (const h of hosts) {
h.servers = {};
h.errors = {};
h.capWarnings = {};
h.status = '';
}
document.getElementById('servers-container').innerHTML = '';
document.getElementById('step2').classList.remove('active');
document.getElementById('step1').classList.add('active');
renderHostRows();
});
async function discoverHost(host) {
host.servers = {};
host.errors = {};
host.capWarnings = {};
host.status = 'Discovering…';
renderHostRows();
// Step 1: list-servers. The proxy validates the tunnel URL allowlist
// and returns either the host's server names or a structured error
// (auth, transport, malformed body — distinguished server-side).
let listResult;
try {
const { resp, payload } = await pairPost('/pair/list-servers', {
tunnelUrl: host.tunnelUrl,
authToken: host.authToken,
});
if (!resp.ok || !payload || !payload.ok) {
const msg = (payload && payload.error)
|| (resp.status === 401 ? 'invalid auth token' : `proxy returned ${resp.status}`);
host.status = `Error: ${msg}`;
renderHostRows();
return;
}
listResult = payload;
} catch (err) {
host.status = `Error: ${err.message || 'unreachable'}`;
renderHostRows();
return;
}
const serverNames = Array.isArray(listResult.servers) ? listResult.servers : [];
if (serverNames.length === 0) {
host.status = 'No servers exposed';
renderHostRows();
return;
}
// Step 2: per-server discovery. Servers within a host stay sequential
// — the proxy mediates each call, so parallelising here just shifts
// load onto the pairing HTTP server and the host's own MCP children
// without speeding the user-perceived flow. Each server is recorded
// independently so a single failure surfaces as a per-server error
// banner without taking the host status with it.
for (const name of serverNames) {
await discoverServer(host, name);
}
const counts = aggregateCounts(host);
// host.servers always carries an entry per advertised name (failed
// discovery leaves a placeholder so the UI still surfaces the row);
// host.errors is the canonical list of per-server FATAL failures
// (tools/init failed); host.capWarnings tracks non-fatal optional-
// capability errors. Status is derived from errors only — caps are
// displayed but don't change the host's headline state.
const totalCount = Object.keys(host.servers).length;
const errorCount = Object.keys(host.errors).length;
const warnCount = Object.keys(host.capWarnings).length;
const warnSuffix = warnCount > 0 ? ` (${warnCount} with cap warning${warnCount === 1 ? '' : 's'})` : '';
if (errorCount === 0) {
host.status = `OK — ${totalCount} server(s), ${counts.tools} tool(s)${warnSuffix}`;
} else if (errorCount === totalCount) {
host.status = `Error: ${errorCount} server(s) failed discovery`;
} else {
host.status = `Partial — ${totalCount - errorCount} ok, ${errorCount} failed${warnSuffix}`;
}
renderHostRows();
}
function aggregateCounts(host) {
let tools = 0, prompts = 0, resources = 0, templates = 0;
for (const s of Object.values(host.servers)) {
tools += s.tools.length;
prompts += s.prompts.length;
resources += s.resources.length;
templates += s.templates.length;
}
return { tools, prompts, resources, templates };
}
async function discoverServer(host, name) {
// Empty placeholder so the server still surfaces in the UI on
// failure (with its banner) rather than disappearing entirely.
host.servers[name] = { tools: [], prompts: [], resources: [], templates: [] };
try {
const { resp, payload } = await pairPost('/pair/discover', {
tunnelUrl: host.tunnelUrl,
authToken: host.authToken,
serverName: name,
});
if (!resp.ok || !payload || !payload.ok) {
// Fatal: tools/list or the init handshake failed. /pair/discover
// now mirrors /pair/list-servers and returns 401 with
// "invalid auth token" for upstream auth failures, so trust
// the server-supplied error before falling back to the status.
const msg = (payload && payload.error)
|| (resp.status === 401 ? 'invalid auth token' : `proxy returned ${resp.status}`);
host.errors[name] = msg;
return;
}
host.servers[name] = {
tools: Array.isArray(payload.tools) ? payload.tools : [],
prompts: Array.isArray(payload.prompts) ? payload.prompts : [],
resources: Array.isArray(payload.resources) ? payload.resources : [],
templates: Array.isArray(payload.resourceTemplates) ? payload.resourceTemplates : [],
};
// Per-capability errors come back as a map. These are NON-FATAL —
// the runtime proxy retries each list independently and the
// server stays online for tools regardless. Mirror them onto
// host.capWarnings (separate from host.errors) so the user
// loudly sees which optional list failed without the save gate
// refusing the pairing.
if (payload.capErrors) {
const parts = [];
for (const k of ['prompts', 'resources', 'resourceTemplates']) {
if (payload.capErrors[k]) parts.push(`${k}: ${payload.capErrors[k]}`);
}
if (parts.length > 0) host.capWarnings[name] = parts.join('; ');
}
} catch (err) {
host.errors[name] = err.message || String(err);
}
}
function renderServerTools() {
const container = document.getElementById('servers-container');
container.innerHTML = '';
for (const host of hosts) {
const block = document.createElement('div');
block.className = 'host-block';
const head = document.createElement('div');
head.className = 'host-block-head';
head.textContent = `${host.id}`;
block.appendChild(head);
const serverNames = Object.keys(host.servers);
if (serverNames.length === 0) {
const hint = document.createElement('p');
hint.className = 'hint';
hint.textContent = 'No servers exposed';
block.appendChild(hint);
container.appendChild(block);
continue;
}
// priorSelections is only authoritative for hosts whose id existed
// at bootstrap. After the user adds, removes, or renames a host
// the saved allowlist no longer applies to that host — fall back
// to default-everything-checked instead of silently inheriting
// stale unchecked state.
const honorPriors = bootstrapHostIds.has(host.id);
for (const serverName of serverNames) {
const server = host.servers[serverName];
const error = host.errors[serverName];
const warning = host.capWarnings[serverName];
const group = document.createElement('div');
group.className = 'server-group';
const title = document.createElement('h3');
title.innerHTML = `<span class="scope">${esc(host.id)}/</span>${esc(serverName)}`;
group.appendChild(title);
const counts = document.createElement('div');
counts.className = 'server-counts';
counts.textContent = `${server.tools.length} tools, ${server.prompts.length} prompts, ${server.resources.length} resources, ${server.templates.length} templates`;
group.appendChild(counts);
// Server-level checkbox. Unchecked = the server is hidden
// completely: tools, prompts, resources, templates, and the
// routed methods that read them. Per-tool checkboxes act as
// a finer-grained filter ON TOP of this. Disabled only on
// FATAL errors — capWarnings (transient prompts/resources
// failures) are loud but non-blocking so the user can still
// pair a server whose tools succeeded. On a reconfigure for
// a host present at bootstrap, the prior allowlist wins so
// unchecked servers stay unchecked even if the host happens
// to discover new capabilities since last pairing.
const serverToggle = document.createElement('label');
serverToggle.className = 'server-toggle';
const serverCb = document.createElement('input');
serverCb.type = 'checkbox';
serverCb.dataset.role = 'server';
serverCb.dataset.host = host.id;
serverCb.dataset.server = serverName;
const hasAnything = server.tools.length + server.prompts.length + server.resources.length + server.templates.length > 0;
const serverKey = `${host.id}${TOOL_SEPARATOR}${serverName}`;
const priorServerChecked = honorPriors && priorSelections.selectedServers
? priorSelections.selectedServers.has(serverKey)
: hasAnything;
serverCb.checked = !error && priorServerChecked;
serverCb.disabled = !!error;
serverCb.addEventListener('change', () => {
group.classList.toggle('disabled', !serverCb.checked);
updateSaveState();
});
const labelText = document.createElement('span');
labelText.textContent = 'Expose this server through the proxy';
serverToggle.appendChild(serverCb);
serverToggle.appendChild(labelText);
group.appendChild(serverToggle);
if (error) {
const banner = document.createElement('div');
banner.className = 'banner error';
banner.style.fontSize = '0.8rem';
banner.style.marginBottom = '0.5rem';
banner.textContent = `Discovery failed: ${error}`;
group.appendChild(banner);
}
// Loud-but-non-blocking warning for non-fatal capability
// failures. The runtime proxy will retry these on its own
// schedule; we surface them here so the user understands
// why a server has fewer prompts/resources than expected,
// without refusing to pair the server's tools.
if (warning && !error) {
const wbanner = document.createElement('div');
wbanner.className = 'banner warning';
wbanner.style.fontSize = '0.8rem';
wbanner.style.marginBottom = '0.5rem';
wbanner.textContent = `Capability list(s) failed (will retry at runtime): ${warning}`;
group.appendChild(wbanner);
}
if (server.tools.length > 0) {
group.appendChild(buildToolList(host.id, serverName, server.tools));
} else if (!error) {
const hint = document.createElement('p');
hint.className = 'hint';
hint.textContent = server.prompts.length + server.resources.length + server.templates.length > 0
? 'No tools (prompts/resources only).'
: 'No exposable capabilities.';
group.appendChild(hint);
}
if (!serverCb.checked) group.classList.add('disabled');
block.appendChild(group);
}
container.appendChild(block);
}
}
function buildToolList(hostId, serverName, tools) {
// priorSelections is consulted only for hosts captured at bootstrap.
// For hosts the user added/renamed since, fall back to default-checked
// (same rule as the server-level checkbox).
const honorPriors = bootstrapHostIds.has(hostId);
const wrapper = document.createDocumentFragment();
const actions = document.createElement('div');
actions.className = 'select-actions';
const allBtn = document.createElement('button');
allBtn.type = 'button';
allBtn.textContent = 'Select all';
allBtn.addEventListener('click', () => toggleAllTools(hostId, serverName, true));
const noneBtn = document.createElement('button');
noneBtn.type = 'button';
noneBtn.textContent = 'Select none';
noneBtn.addEventListener('click', () => toggleAllTools(hostId, serverName, false));
actions.appendChild(allBtn);
actions.appendChild(noneBtn);
wrapper.appendChild(actions);
const list = document.createElement('div');
list.className = 'tool-list';
for (const tool of tools) {
const item = document.createElement('div');
item.className = 'tool-item';
const cbId = `tool-${hostId}-${serverName}-${tool.name}`;
const toolKey = `${hostId}${TOOL_SEPARATOR}${serverName}${TOOL_SEPARATOR}${tool.name}`;
const checked = honorPriors && priorSelections.selectedTools
? priorSelections.selectedTools.has(toolKey)
: true;
item.innerHTML = `
<div class="tool-check">
<input type="checkbox" id="${esc(cbId)}" data-role="tool" data-host="${esc(hostId)}" data-server="${esc(serverName)}" data-tool="${esc(tool.name)}"${checked ? ' checked' : ''}>
</div>
<label class="tool-label" for="${esc(cbId)}">
<span class="tool-name">${esc(tool.name)}</span>
${tool.description ? `<span class="tool-desc">${esc(tool.description)}</span>` : ''}
</label>
`;
list.appendChild(item);
}
wrapper.appendChild(list);
return wrapper;
}
function toggleAllTools(hostId, serverName, checked) {
const safeHost = CSS.escape(hostId);
const safeServer = CSS.escape(serverName);
document.querySelectorAll(`input[data-role="tool"][data-host="${safeHost}"][data-server="${safeServer}"]`).forEach((cb) => cb.checked = checked);
updateSaveState();
}
function updateSaveState() {
const serverChecked = document.querySelectorAll('input[data-role="server"]:checked').length;
const btn = document.getElementById('save-btn');
const hint = document.getElementById('save-hint');
btn.disabled = serverChecked === 0;
hint.textContent = serverChecked === 0
? 'Select at least one server to continue.'
: `${serverChecked} server${serverChecked === 1 ? '' : 's'} selected.`;
}
document.getElementById('servers-container').addEventListener('change', (e) => {
if (e.target instanceof HTMLInputElement && e.target.dataset.role) updateSaveState();
});
document.getElementById('save-btn').addEventListener('click', saveConfig);
async function saveConfig() {
const btn = document.getElementById('save-btn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Saving...';
// Build the server-level allow list. Each entry is
// `<hostId>__<serverName>` — the same shape the runtime proxy
// expects in PairingConfig.selectedServers.
const selectedServers = [];
const allowedKeys = new Set();
document.querySelectorAll('input[data-role="server"]:checked').forEach((cb) => {
const key = `${cb.dataset.host}${TOOL_SEPARATOR}${cb.dataset.server}`;
selectedServers.push(key);
allowedKeys.add(key);
});
// Tool-level allow list, scoped to allowed servers. Tools whose
// server isn't in selectedServers are dropped on the way out so
// an unchecked server can't smuggle a tool through.
const selectedTools = [];
document.querySelectorAll('input[data-role="tool"]:checked').forEach((cb) => {
const serverKey = `${cb.dataset.host}${TOOL_SEPARATOR}${cb.dataset.server}`;
if (!allowedKeys.has(serverKey)) return;
selectedTools.push(`${serverKey}${TOOL_SEPARATOR}${cb.dataset.tool}`);
});
const config = {
hosts: hosts.map((h) => ({ id: h.id, tunnelUrl: h.tunnelUrl, authToken: h.authToken })),
selectedServers,
selectedTools,
sealed: true,
};
try {
const resp = await pairingFetch('/pair/complete', {
method: 'POST',
body: JSON.stringify(config),
});
const result = await resp.json().catch(() => ({}));
if (resp.ok && result.ok) {
showSuccess(config);
} else {
showError(result.error || `Failed to save (${resp.status})`);
}
} catch (err) {
showError(err.message);
}
btn.disabled = false;
btn.textContent = 'Complete Setup';
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
function showSuccess(data) {
document.getElementById('step1').classList.remove('active');
document.getElementById('step2').classList.remove('active');
const r = document.getElementById('result-section');
r.style.display = 'block';
const hostList = data.hosts.map((h) => `${esc(h.id)} → <strong>${esc(h.tunnelUrl)}</strong>`).join('<br>');
r.innerHTML = `
<div class="banner ok">
Configuration applied!<br>
${hostList}<br>
Servers: ${data.selectedServers.length} selected<br>
Tools: ${data.selectedTools.length} selected<br><br>
Return to your terminal — the proxy is now connected.
The pairing tunnel has been torn down.
</div>
`;
}
function showError(msg) {
const r = document.getElementById('result-section');
r.style.display = 'block';
// Preserve newlines so multi-line gating errors render as a list
// instead of one wall of text. esc() runs first so the original
// string can't smuggle markup; then we convert just the newlines.
const html = esc(msg).replace(/\n/g, '<br>');
r.innerHTML = `<div class="banner error">${html}</div>`;
setTimeout(() => { r.style.display = 'none'; }, 4000);
}
})();
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family:
system-ui,
-apple-system,
sans-serif;
background: #0f172a;
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.card {
background: #1e293b;
border-radius: 12px;
padding: 2rem;
max-width: 480px;
width: 100%;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
}
/* Tablet / small desktop: wider card, more breathing room. */
@media (min-width: 768px) {
.card {
max-width: 720px;
padding: 2rem 2.5rem;
}
}
/* Desktop and up: stepped breakpoints so workstation-class displays
(1080p, 1440p, 4K) actually use their horizontal room instead of leaving
the host/server grids capped at 960px with a sea of empty space. The
inner grids are auto-fit, so growing the card naturally lets more host
rows / server groups sit side-by-side without per-grid changes. The
.desc paragraph has its own 65ch cap so prose stays readable even when
the card is very wide. */
@media (min-width: 1280px) {
.card {
max-width: 1100px;
}
}
@media (min-width: 1600px) {
.card {
max-width: 1400px;
padding: 2.25rem 3rem;
}
}
/* 1080p workstation territory. */
@media (min-width: 1920px) {
.card {
max-width: 1700px;
}
}
/* 1440p / entry-level 4K — let the card stretch but cap so the grids
don't end up so wide that one server group spans 600px+ of dead space. */
@media (min-width: 2560px) {
.card {
max-width: 2100px;
padding: 2.5rem 3.5rem;
}
}
/* True 4K (3840px+). Cap here — going wider just hurts scan distance more
than it adds layout value. */
@media (min-width: 3200px) {
.card {
max-width: 2600px;
}
}
h1 {
font-size: 1.25rem;
margin-bottom: 0.25rem;
}
.desc {
color: #94a3b8;
font-size: 0.875rem;
margin: 1rem 0;
max-width: 65ch;
}
label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.375rem;
margin-top: 1rem;
}
input,
select {
width: 100%;
padding: 0.625rem 0.75rem;
border-radius: 8px;
border: 1px solid #334155;
background: #0f172a;
color: #e2e8f0;
font-size: 0.9rem;
}
input:focus,
select:focus {
outline: none;
border-color: #38bdf8;
box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.25);
}
/* Live "fail early" validation. Two selectors layered:
- :not(:placeholder-shown):invalid — fires the moment the user types
something that violates a constraint (pattern / type=url /
setCustomValidity). It skips the empty case because :placeholder-shown
matches when the field is empty (every input here defines a
placeholder), so we don't paint a freshly-rendered required field red
before the user has even touched it.
- :user-invalid — covers the post-submit case where the user pressed
Discover with required fields still empty. :placeholder-shown wouldn't
have filtered those, so we still want them flagged at that point.
setCustomValidity (the duplicate-id cross-field check) plugs into the
same :invalid machinery, so styling is uniform across native and custom
failures. */
input:not(:placeholder-shown):invalid,
input:user-invalid {
border-color: #ef4444;
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2);
}
button {
margin-top: 1.5rem;
width: 100%;
padding: 0.75rem;
border: none;
border-radius: 8px;
background: #2563eb;
color: white;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
button:hover {
background: #1d4ed8;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.hint {
font-size: 0.75rem;
color: #64748b;
margin-top: 0.25rem;
}
.banner {
border-radius: 8px;
padding: 1rem;
margin-top: 1rem;
text-align: center;
}
.banner.ok {
background: #065f46;
border: 1px solid #10b981;
color: #d1fae5;
}
.banner.error {
background: #7f1d1d;
border: 1px solid #ef4444;
color: #fecaca;
}
.banner.warning {
background: #78350f;
border: 1px solid #f59e0b;
color: #fde68a;
}
.spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.6s linear infinite;
vertical-align: middle;
margin-right: 0.5rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
+15
-7
{
"name": "@silver886/mcp-proxy",
"version": "0.1.4",
"version": "0.2.0",
"description": "MCP proxy bridge: forward MCP requests across network boundaries via Cloudflare tunnel",

@@ -29,15 +29,23 @@ "repository": {

"bin": {
"host": "mcp/dist/host.js",
"proxy": "mcp/dist/proxy.js"
"host": "dist/host.js",
"proxy": "dist/proxy.js"
},
"files": [
"mcp/dist"
"dist",
"static"
],
"engines": {
"node": ">=20.3.0"
},
"dependencies": {
"cloudflared": "^0.7.1"
"cloudflared": "^0.7.1",
"eventsource-parser": "^3.0.8"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.5.0"
},
"scripts": {
"build": "pnpm --filter @silver886/mcp-proxy-mcp build",
"deploy:pages": "pnpm --filter @silver886/mcp-proxy-pages run deploy"
"build": "tsc"
}
}
+62
-17

@@ -74,10 +74,21 @@ # MCP Proxy

When the MCP client spawns the proxy, the proxy prints a setup URL to stderr:
The proxy starts idle. Ask your MCP client to call the `configure` tool (or
prompt) — the proxy then spins up an ephemeral pairing tunnel that serves
both the setup page and the pairing API on the same origin, and prints a
setup URL to stderr:
```
Configure at: https://mcp-proxy.pages.dev/setup.html#code=...&key=...
Configure at: https://abc-xyz.trycloudflare.com/#token=...
```
Open the URL in a browser. Enter the tunnel URL and auth token from step 1, discover servers, and select tools. The proxy picks up the config automatically and starts forwarding MCP requests.
Open the URL in a browser. Add one or more host agents — each row takes a
host id (a slug you choose), tunnel URL, and auth token — discover servers,
and select tools. The proxy applies the config and tears down the pairing
tunnel automatically.
A single proxy can fan out to multiple hosts at once. Tools are namespaced
as `<hostId>__<serverName>__<toolName>` so the same server name can appear
on more than one host without collision. (The host agent itself stays
single-proxy, in line with MCP's one-server-one-client model.)
## Architecture

@@ -89,17 +100,32 @@

|-----------|------|---------|
| **Host Agent** (`host`) | HTTP-to-stdio bridge. Spawns MCP servers, manages sessions, serves MCP Streamable HTTP. | Machine with resources |
| **Proxy Server** (`proxy`) | Stdio MCP server that forwards requests to the host agent via tunnel. | Machine with MCP client |
| **Config Page** (Cloudflare Pages) | Device-code pairing. Stores encrypted config in KV with 15-min TTL. | Cloudflare edge |
| **Host Agent** (`host`) | HTTP-to-stdio bridge. Spawns MCP servers, manages sessions, serves MCP Streamable HTTP over a long-lived Cloudflare tunnel. | Machine with resources |
| **Proxy Server** (`proxy`) | Stdio MCP server. Idle at startup; on `configure` it spins up an ephemeral pairing tunnel via the bundled wrapper, serves the setup page on that same tunnel, accepts the pairing handshake, then talks to the host's tunnel for ongoing MCP traffic. | Machine with MCP client |
### Pairing flow
### Pairing flow (lazy-start, single-origin)
```
1. MCP client spawns the proxy (stdio)
2. Proxy generates pairing code + encryption key, polls Pages RPC
3. User opens setup URL in browser (code + key in URL hash, never sent to server)
4. User enters tunnel URL + auth token, discovers servers, selects tools
5. Setup page encrypts config client-side, stores ciphertext in KV via RPC
6. Proxy polls, decrypts config, discovers servers, starts forwarding
1. MCP client spawns the proxy (stdio). Proxy is idle — no tunnel, no polling.
2. Agent calls the `configure` tool. Proxy spawns a Node wrapper that owns
a `cloudflared` quick tunnel pointing at a local pairing HTTP server.
That HTTP server serves both the setup page (GET /) and the pairing API
(POST /pair/forward, POST /pair/complete) on the same origin.
3. Wrapper prints the tunnel URL. Proxy mints a bearer token and emits a
setup URL — `<tunnel>/#token=<token>`. Token rides in the URL fragment
so it never appears in server access logs or Referer headers.
4. User opens the setup URL. The page is served by the proxy itself, so
browser fetches to the pairing API are same-origin — no CORS dance.
Pairing endpoints are gated by the bearer token.
5. Through the pairing API, the page discovers servers and tools on each
configured host's MCP tunnel, then submits the final configuration
(a list of hosts plus the selected tools).
6. Proxy applies the config, signals the wrapper to tear down `cloudflared`,
and shuts the pairing HTTP server. From here on the proxy talks only to
the host's long-lived MCP tunnel — no public infrastructure, no polling.
```
The wrapper guarantees `cloudflared` cannot outlive the proxy. When the
proxy exits (or the wrapper sees stdin EOF), the wrapper kills the
`cloudflared` child immediately. Detection latency is 0ms on
Linux, macOS, and Windows.
### Protocol

@@ -156,9 +182,28 @@

```
proxy [options]
--pages-url <url> Config page URL (default: https://mcp-proxy.pages.dev)
proxy
```
Also reads `MCP_PROXY_PAGES_URL` environment variable.
The proxy takes no flags. The setup page is bundled with the npm package
and served by the proxy itself on the ephemeral pairing tunnel — there's
no external infrastructure to point at and no env vars to configure.
The pairing handshake runs entirely between the browser and the proxy's
ephemeral pairing tunnel, gated by a bearer token from the URL fragment.
Server names exposed by the host agent — and host ids you assign during
pairing — must match `[A-Za-z0-9._-]+` so they stay safe inside URLs and
the proxy's tool-name routing. Names that violate the policy are rejected
at host startup or pairing time.
### Server-initiated requests
The proxy fully bridges server→client requests (sampling, elicitation,
roots/list, ping, …). When an upstream MCP server sends a request over its
SSE notification channel, the proxy remaps the request id, forwards it to
the MCP client, and routes the client's response back to the originating
host session with the original id restored. The real client's
`capabilities` are forwarded to each upstream server during initialize so
servers see the actual feature support rather than an empty capabilities
object.
## Error codes

@@ -165,0 +210,0 @@

#!/usr/bin/env node
export {};
#!/usr/bin/env node
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const node_fs_1 = require("node:fs");
const node_child_process_1 = require("node:child_process");
const node_crypto_1 = require("node:crypto");
const cloudflared_1 = require("cloudflared");
const protocol_js_1 = require("./shared/protocol.js");
// A session manages one MCP server child process + request/response matching
class McpSession {
name;
timeout;
process;
stdoutBuffer = new protocol_js_1.LineBuffer();
pending = new Map();
notifications = [];
destroyed = false;
constructor(name, config, timeout) {
this.name = name;
this.timeout = timeout;
console.log(`[${name}] Spawning: ${config.command} ${config.args.join(" ")}`);
this.process = (0, node_child_process_1.spawn)(config.command, config.args, {
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, ...config.env },
shell: config.shell ?? false,
});
this.process.stdout.on("data", (chunk) => {
const lines = this.stdoutBuffer.push(chunk.toString("utf-8"));
for (const line of lines) {
this.handleLine(line);
}
});
this.process.stderr.on("data", (chunk) => {
console.error(`[${name}] stderr: ${chunk.toString("utf-8").trimEnd()}`);
});
this.process.on("exit", (code) => {
console.log(`[${name}] Process exited (code=${code})`);
this.destroyed = true;
// Reject all pending
for (const [, p] of this.pending) {
clearTimeout(p.timer);
p.resolve((0, protocol_js_1.jsonRpcError)(protocol_js_1.ErrorCode.PROCESS_EXITED, `code=${code}`));
}
this.pending.clear();
});
this.process.on("error", (err) => {
console.error(`[${name}] Process error: ${err.message}`);
this.destroyed = true;
});
}
handleLine(line) {
// Try to extract the id to match with a pending request
let parsed;
try {
parsed = JSON.parse(line);
}
catch {
return; // Not valid JSON, skip
}
// If it has an id and matches a pending request, resolve it
if (parsed.id !== undefined && this.pending.has(parsed.id)) {
const p = this.pending.get(parsed.id);
clearTimeout(p.timer);
this.pending.delete(parsed.id);
p.resolve(line);
return;
}
// Otherwise it's a notification — queue it
this.notifications.push(line);
}
sendRequest(jsonRpcLine) {
if (this.destroyed || !this.process.stdin?.writable) {
return Promise.resolve((0, protocol_js_1.jsonRpcError)(protocol_js_1.ErrorCode.PROCESS_NOT_RUNNING));
}
// Extract id for matching
let id;
try {
id = JSON.parse(jsonRpcLine).id;
}
catch {
// If we can't parse, just send it and hope for the best
}
this.process.stdin.write(jsonRpcLine + "\n");
if (id === undefined) {
// It's a notification from client — no response expected
return Promise.resolve("");
}
return new Promise((resolve) => {
const timer = setTimeout(() => {
this.pending.delete(id);
resolve((0, protocol_js_1.jsonRpcError)(protocol_js_1.ErrorCode.REQUEST_TIMEOUT, undefined, id));
}, this.timeout);
this.pending.set(id, { resolve, timer });
});
}
drainNotifications() {
const n = this.notifications;
this.notifications = [];
return n;
}
get serverName() {
return this.name;
}
get isAlive() {
return !this.destroyed;
}
destroy() {
if (this.destroyed)
return;
this.destroyed = true;
if (!this.process.killed)
this.process.kill();
}
}
function sendSessionMismatchError(res, session, serverName) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: `Session belongs to server '${session.serverName}', not '${serverName}'` }));
}
// Main server
class HostAgent {
config;
sessions = new Map();
timeout;
authToken;
constructor(configPath, timeout) {
const raw = (0, node_fs_1.readFileSync)(configPath, "utf-8");
this.config = JSON.parse(raw);
this.timeout = timeout;
this.authToken = (0, node_crypto_1.randomBytes)(32).toString("base64url"); // 256-bit token
}
get port() {
return this.config.port ?? protocol_js_1.DEFAULT_PORT;
}
start() {
const host = this.config.host ?? protocol_js_1.DEFAULT_HOST;
const server = (0, protocol_js_1.createServer)((req, res) => this.handleRequest(req, res));
server.listen(this.port, host, () => {
console.log(`MCP Host Agent listening on http://${host}:${this.port}`);
console.log(`Available servers: ${Object.keys(this.config.servers).join(", ")}`);
console.error(`Auth token: ${this.authToken}`);
});
}
async handleRequest(req, res) {
// Auth: validate Bearer token (constant-time comparison)
const auth = req.headers.authorization ?? "";
const expected = `Bearer ${this.authToken}`;
const authBuf = Buffer.from(auth);
const expectedBuf = Buffer.from(expected);
const authorized = authBuf.length === expectedBuf.length && (0, node_crypto_1.timingSafeEqual)(authBuf, expectedBuf);
if (!authorized) {
res.writeHead(401, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Unauthorized" }));
return;
}
// GET / — list available servers
if (req.method === "GET" && req.url === "/") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
service: "mcp-proxy-host",
servers: Object.keys(this.config.servers),
}));
return;
}
// Route: /servers/:name
const match = req.url?.match(/^\/servers\/([^/?]+)/);
if (!match) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Not found. Use /servers/<name>" }));
return;
}
const serverName = match[1];
const serverConfig = this.config.servers[serverName];
if (!serverConfig) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({
error: `Unknown server: ${serverName}`,
available: Object.keys(this.config.servers),
}));
return;
}
// POST /servers/:name — MCP request
if (req.method === "POST") {
await this.handleMcpPost(req, res, serverName, serverConfig);
return;
}
// GET /servers/:name — SSE for server notifications
if (req.method === "GET") {
this.handleSse(req, res, serverName);
return;
}
// DELETE /servers/:name — close session
if (req.method === "DELETE") {
const sessionId = req.headers["mcp-session-id"];
if (sessionId && this.sessions.has(sessionId)) {
const session = this.sessions.get(sessionId);
if (session.serverName !== serverName) {
sendSessionMismatchError(res, session, serverName);
return;
}
session.destroy();
this.sessions.delete(sessionId);
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
return;
}
res.writeHead(405);
res.end();
}
async handleMcpPost(req, res, serverName, serverConfig) {
const body = await (0, protocol_js_1.readBody)(req);
let sessionId = req.headers["mcp-session-id"];
// Get or create session
let session;
if (sessionId && this.sessions.has(sessionId)) {
session = this.sessions.get(sessionId);
if (session.serverName !== serverName) {
sendSessionMismatchError(res, session, serverName);
return;
}
if (!session.isAlive) {
// Session dead — clean up and create new
this.sessions.delete(sessionId);
sessionId = undefined;
}
}
if (!sessionId || !this.sessions.has(sessionId)) {
sessionId = (0, node_crypto_1.randomBytes)(16).toString("hex");
session = new McpSession(serverName, serverConfig, this.timeout);
this.sessions.set(sessionId, session);
}
else {
session = this.sessions.get(sessionId);
}
// Forward request
const response = await session.sendRequest(body);
if (!response) {
// Client notification — no response body
res.writeHead(202, { "Mcp-Session-Id": sessionId });
res.end();
return;
}
res.writeHead(200, {
"Content-Type": "application/json",
"Mcp-Session-Id": sessionId,
});
res.end(response);
}
handleSse(req, res, serverName) {
const sessionId = req.headers["mcp-session-id"];
const session = sessionId ? this.sessions.get(sessionId) : undefined;
if (session && session.serverName !== serverName) {
sendSessionMismatchError(res, session, serverName);
return;
}
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
...(sessionId ? { "Mcp-Session-Id": sessionId } : {}),
});
res.write(": connected\n\n");
if (!session) {
req.on("close", () => { });
return;
}
// Poll for notifications and send them
const interval = setInterval(() => {
if (!session.isAlive) {
clearInterval(interval);
res.end();
return;
}
const notifications = session.drainNotifications();
for (const n of notifications) {
res.write(`data: ${n}\n\n`);
}
}, 100);
req.on("close", () => clearInterval(interval));
}
}
function startTunnel(port) {
const tunnel = cloudflared_1.Tunnel.quick(`http://localhost:${port}`);
tunnel.once("url", (url) => {
console.log(`\n Tunnel URL: ${url}`);
console.log(`\n Enter this URL in the setup page when configuring the proxy.\n`);
});
tunnel.on("error", (err) => {
console.error("Tunnel error:", err.message);
});
process.on("SIGINT", () => {
tunnel.stop();
process.exit(0);
});
}
function main() {
const configPath = (0, protocol_js_1.getArg)("--config") ?? "config.json";
const timeout = parseInt((0, protocol_js_1.getArg)("--timeout") ?? "120000", 10); // 2min default for long tool calls
const useTunnel = process.argv.includes("--tunnel");
const agent = new HostAgent(configPath, timeout);
agent.start();
if (useTunnel) {
console.log("Starting Cloudflare tunnel...");
startTunnel(agent.port);
}
}
main();
#!/usr/bin/env node
export {};
#!/usr/bin/env node
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const node_crypto_1 = require("node:crypto");
const protocol_js_1 = require("./shared/protocol.js");
const POLL_INTERVAL = 2000; // ms
const TOOL_SEPARATOR = "__";
const subtle = node_crypto_1.webcrypto.subtle;
// --- Crypto helpers (AES-256-GCM + SHA-256 + HMAC) ---
async function importAesKey(keyB64) {
const raw = Buffer.from(keyB64, "base64url");
return subtle.importKey("raw", raw, "AES-GCM", false, ["encrypt", "decrypt"]);
}
async function importHmacKey(keyB64) {
const raw = Buffer.from(keyB64, "base64url");
return subtle.importKey("raw", raw, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
}
async function deriveCodeId(code) {
const hash = await subtle.digest("SHA-256", new TextEncoder().encode(code));
return Buffer.from(hash).toString("base64url");
}
async function deriveAuthHash(keyB64, code) {
const hmacKey = await importHmacKey(keyB64);
const sig = await subtle.sign("HMAC", hmacKey, new TextEncoder().encode(code));
return Buffer.from(sig).toString("base64url");
}
async function encrypt(key, plaintext) {
const iv = (0, node_crypto_1.randomBytes)(12);
const encoded = new TextEncoder().encode(plaintext);
const ciphertext = new Uint8Array(await subtle.encrypt({ name: "AES-GCM", iv }, key, encoded));
const combined = new Uint8Array(iv.length + ciphertext.length);
combined.set(iv);
combined.set(ciphertext, iv.length);
return Buffer.from(combined).toString("base64url");
}
async function decrypt(key, data) {
const combined = Buffer.from(data, "base64url");
const iv = combined.subarray(0, 12);
const ciphertext = combined.subarray(12);
const plaintext = await subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext);
return new TextDecoder().decode(plaintext);
}
// --- RPC client ---
async function rpc(pagesUrl, codeId, authHash, action, payload) {
const resp = await fetch(`${pagesUrl}/api/rpc`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ codeId, authHash, action, payload }),
});
return (await resp.json());
}
// --- Proxy ---
class ProxyServer {
config = null;
pagesUrl;
code;
encKeyB64;
aesKey = null;
codeId = null;
authHash = null;
pollTimer = null;
servers = new Map();
toolRoute = new Map();
initialized = false;
constructor(pagesUrl) {
this.pagesUrl = pagesUrl.replace(/\/+$/, "");
this.code = (0, node_crypto_1.randomBytes)(64).toString("base64url");
this.encKeyB64 = (0, node_crypto_1.randomBytes)(32).toString("base64url");
}
get setupUrl() {
return `${this.pagesUrl}/setup.html#code=${this.code}&key=${this.encKeyB64}`;
}
get hostHeaders() {
return {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
Authorization: `Bearer ${this.config?.authToken ?? ""}`,
};
}
async ensureDerivedKeys() {
if (!this.aesKey)
this.aesKey = await importAesKey(this.encKeyB64);
if (!this.codeId)
this.codeId = await deriveCodeId(this.code);
if (!this.authHash)
this.authHash = await deriveAuthHash(this.encKeyB64, this.code);
return { aesKey: this.aesKey, codeId: this.codeId, authHash: this.authHash };
}
start() {
this.startPairing();
const stdinBuffer = new protocol_js_1.LineBuffer();
process.stdin.setEncoding("utf-8");
process.stdin.on("data", (chunk) => {
const lines = stdinBuffer.push(chunk);
for (const line of lines) {
this.handleLine(line).catch((err) => {
process.stderr.write(`Proxy error: ${err.message}\n`);
});
}
});
process.stdin.on("end", () => {
process.exit(0);
});
}
async handleLine(line) {
let parsed;
try {
parsed = JSON.parse(line);
}
catch {
return;
}
const id = parsed.id ?? null;
// Handle client notifications (no id)
if (id === null)
return;
switch (parsed.method) {
// initialize always succeeds — proxy is a valid server even before pairing
case "initialize":
this.sendResult(id, {
protocolVersion: protocol_js_1.MCP_PROTOCOL_VERSION,
capabilities: { tools: { listChanged: true }, prompts: {}, logging: {} },
serverInfo: { name: protocol_js_1.PACKAGE_NAME, version: protocol_js_1.PACKAGE_VERSION },
});
return;
// tools/list returns configure tool before pairing, real tools after
case "tools/list":
if (!this.config) {
this.sendResult(id, { tools: [{
name: "configure",
description: "Set up or reconfigure the MCP proxy connection. Returns the setup URL.",
inputSchema: { type: "object", properties: {} },
}] });
return;
}
if (!this.initialized)
await this.discoverServers();
this.sendResult(id, { tools: this.getFilteredTools() });
return;
// prompts/list always available
case "prompts/list":
this.sendResult(id, { prompts: [{
name: "configure",
description: "Set up or reconfigure the MCP proxy connection",
}] });
return;
case "prompts/get": {
const promptName = parsed.params?.name;
if (promptName === "configure") {
const text = await this.handleConfigure();
this.sendResult(id, {
messages: [
{ role: "user", content: { type: "text", text: "Show the MCP Proxy setup URL. Do not add any follow-up — do not ask me to let you know or report back." } },
{ role: "assistant", content: { type: "text", text } },
],
});
}
else {
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, `Unknown prompt: ${promptName}`, id);
}
return;
}
case "tools/call": {
const toolName = parsed.params?.name;
if (toolName === "configure") {
const text = await this.handleConfigure();
this.sendResult(id, { content: [{ type: "text", text }] });
return;
}
if (!this.config) {
this.sendError(protocol_js_1.ErrorCode.PROXY_NOT_CONFIGURED, `Visit ${this.setupUrl}`, id);
return;
}
await this.handleToolCall(id, parsed.params);
return;
}
default:
this.sendError(protocol_js_1.ErrorCode.METHOD_NOT_FOUND, parsed.method, id);
}
}
async handleToolCall(id, params) {
const prefixedName = params.name;
const serverName = this.toolRoute.get(prefixedName);
if (!serverName) {
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, `Unknown tool: ${prefixedName}`, id);
return;
}
const originalName = prefixedName.slice(serverName.length + TOOL_SEPARATOR.length);
const server = this.servers.get(serverName);
const targetUrl = `${this.config.tunnelUrl}/servers/${serverName}`;
const headers = { ...this.hostHeaders };
if (server.sessionId)
headers["Mcp-Session-Id"] = server.sessionId;
try {
const body = JSON.stringify({
jsonrpc: "2.0",
id,
method: "tools/call",
params: { name: originalName, arguments: params.arguments },
});
const upstream = await fetch(targetUrl, { method: "POST", headers, body });
server.sessionId = upstream.headers.get("mcp-session-id") ?? server.sessionId;
const responseBody = await upstream.text();
if (responseBody)
process.stdout.write(responseBody + "\n");
}
catch (err) {
this.sendError(protocol_js_1.ErrorCode.HOST_UNREACHABLE, err.message, id);
}
}
getFilteredTools() {
const selectedSet = this.config?.selectedTools?.length
? new Set(this.config.selectedTools)
: null;
const tools = [];
for (const [serverName, state] of this.servers) {
for (const tool of state.tools) {
const prefixed = `${serverName}${TOOL_SEPARATOR}${tool.name}`;
if (selectedSet && !selectedSet.has(prefixed))
continue;
tools.push({
...tool,
name: prefixed,
description: `[${serverName}] ${tool.description ?? ""}`.trim(),
});
}
}
return tools;
}
async discoverServers() {
if (!this.config)
return;
try {
const listResp = await fetch(`${this.config.tunnelUrl}/`, { headers: this.hostHeaders });
const listData = (await listResp.json());
const serverNames = listData.servers ?? [];
// Skip servers whose names contain the tool separator to prevent routing confusion
const safeNames = serverNames.filter((name) => {
if (name.includes(TOOL_SEPARATOR)) {
process.stderr.write(` [${name}] skipped: name contains '${TOOL_SEPARATOR}'\n`);
return false;
}
return true;
});
process.stderr.write(` Discovered servers: ${safeNames.join(", ")}\n`);
for (const name of safeNames) {
await this.initServer(name);
}
this.toolRoute.clear();
for (const [serverName, state] of this.servers) {
for (const tool of state.tools) {
this.toolRoute.set(`${serverName}${TOOL_SEPARATOR}${tool.name}`, serverName);
}
}
this.initialized = true;
process.stderr.write(` Total tools: ${this.toolRoute.size}\n\n`);
}
catch (err) {
process.stderr.write(` Discovery failed: ${err.message}\n`);
}
}
async initServer(name) {
const targetUrl = `${this.config.tunnelUrl}/servers/${name}`;
const headers = { ...this.hostHeaders };
try {
const initResp = await fetch(targetUrl, {
method: "POST",
headers,
body: JSON.stringify({
jsonrpc: "2.0",
id: `init-${name}`,
method: "initialize",
params: {
protocolVersion: protocol_js_1.MCP_PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: protocol_js_1.PACKAGE_NAME, version: protocol_js_1.PACKAGE_VERSION },
},
}),
});
const sessionId = initResp.headers.get("mcp-session-id") ?? undefined;
if (sessionId)
headers["Mcp-Session-Id"] = sessionId;
await fetch(targetUrl, {
method: "POST",
headers,
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized", params: {} }),
});
const toolsResp = await fetch(targetUrl, {
method: "POST",
headers,
body: JSON.stringify({ jsonrpc: "2.0", id: `tools-${name}`, method: "tools/list", params: {} }),
});
const toolsData = (await toolsResp.json());
const tools = toolsData.result?.tools ?? [];
this.servers.set(name, { sessionId, tools });
process.stderr.write(` [${name}] ${tools.length} tools\n`);
}
catch (err) {
process.stderr.write(` [${name}] init failed: ${err.message}\n`);
}
}
sendResult(id, result) {
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
}
sendError(code, detail, id) {
process.stdout.write((0, protocol_js_1.jsonRpcError)(code, detail, id) + "\n");
}
sendNotification(method) {
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", method }) + "\n");
}
async handleConfigure() {
if (this.config) {
await this.startPairing();
this.sendNotification("notifications/tools/list_changed");
}
return `Open this URL in your browser to set up the MCP Proxy:\n\n${this.setupUrl}\n\nThe proxy will connect automatically once setup is complete.`;
}
async startPairing() {
if (this.pollTimer)
clearInterval(this.pollTimer);
const previousConfig = this.config;
this.code = (0, node_crypto_1.randomBytes)(64).toString("base64url");
this.encKeyB64 = (0, node_crypto_1.randomBytes)(32).toString("base64url");
this.aesKey = null;
this.codeId = null;
this.authHash = null;
this.config = null;
this.initialized = false;
this.servers.clear();
this.toolRoute.clear();
// Seed with encrypted previous config (unsealed) — skip on first pairing
if (previousConfig) {
try {
const { aesKey, codeId, authHash } = await this.ensureDerivedKeys();
const payload = await encrypt(aesKey, JSON.stringify({ ...previousConfig, sealed: false }));
await rpc(this.pagesUrl, codeId, authHash, "write", payload);
}
catch {
// Non-critical
}
}
process.stderr.write(`\n Configure at: ${this.setupUrl}\n\n`);
process.stderr.write(` Waiting for configuration...\n`);
this.pollTimer = setInterval(() => this.pollConfig(), POLL_INTERVAL);
}
async pollConfig() {
if (this.config)
return; // Already paired — guard against overlapping async polls
try {
const { aesKey, codeId, authHash } = await this.ensureDerivedKeys();
const result = await rpc(this.pagesUrl, codeId, authHash, "read");
if (result.payload) {
const plaintext = await decrypt(aesKey, result.payload);
const data = JSON.parse(plaintext);
if (data.tunnelUrl && data.authToken && data.serverName && data.sealed) {
data.tunnelUrl = data.tunnelUrl.replace(/\/+$/, "");
this.config = data;
if (this.pollTimer)
clearInterval(this.pollTimer);
this.pollTimer = null;
process.stderr.write(` Paired! tunnel=${data.tunnelUrl}\n`);
await this.discoverServers();
this.sendNotification("notifications/tools/list_changed");
}
}
}
catch {
// Silently retry
}
}
}
function main() {
const pagesUrl = (0, protocol_js_1.getArg)("--pages-url") ?? process.env.MCP_PROXY_PAGES_URL ?? protocol_js_1.DEFAULT_PAGES_URL;
const proxy = new ProxyServer(pagesUrl);
proxy.start();
}
main();
export declare const PACKAGE_NAME = "@silver886/mcp-proxy";
export declare const PACKAGE_VERSION = "0.1.4";
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.PACKAGE_VERSION = exports.PACKAGE_NAME = void 0;
exports.PACKAGE_NAME = "@silver886/mcp-proxy";
exports.PACKAGE_VERSION = "0.1.4";
import type { IncomingMessage, ServerResponse } from "node:http";
export { PACKAGE_NAME, PACKAGE_VERSION } from "./generated.js";
export declare const MCP_PROTOCOL_VERSION = "2024-11-05";
export declare const DEFAULT_HOST = "127.0.0.1";
export declare const DEFAULT_PORT = 6270;
export declare const DEFAULT_PAGES_URL = "https://mcp-proxy.pages.dev";
export declare const ErrorCode: {
readonly METHOD_NOT_FOUND: -32601;
readonly INVALID_PARAMS: -32602;
readonly INTERNAL: -32603;
readonly PROXY_NOT_CONFIGURED: -32001;
readonly HOST_UNREACHABLE: -32002;
readonly PROCESS_EXITED: -32003;
readonly PROCESS_NOT_RUNNING: -32004;
readonly REQUEST_TIMEOUT: -32005;
};
export declare const ErrorMessage: {
readonly [-32601]: "Method not found";
readonly [-32602]: "Invalid params";
readonly [-32603]: "Internal error";
readonly [-32001]: "Proxy not configured";
readonly [-32002]: "Host agent unreachable";
readonly [-32003]: "Server process exited";
readonly [-32004]: "Server process not running";
readonly [-32005]: "Request timed out";
};
export declare function jsonRpcError(code: number, detail?: string, id?: string | number | null): string;
export declare function readBody(req: IncomingMessage): Promise<string>;
export declare function getArg(name: string): string | undefined;
export declare function createServer(handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>): import("http").Server<typeof IncomingMessage, typeof ServerResponse>;
export declare class LineBuffer {
private buffer;
push(chunk: string): string[];
}
export interface ServerConfig {
command: string;
args: string[];
env?: Record<string, string>;
shell?: boolean;
}
export interface HostAgentConfig {
servers: Record<string, ServerConfig>;
host?: string;
port?: number;
}
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.LineBuffer = exports.ErrorMessage = exports.ErrorCode = exports.DEFAULT_PAGES_URL = exports.DEFAULT_PORT = exports.DEFAULT_HOST = exports.MCP_PROTOCOL_VERSION = exports.PACKAGE_VERSION = exports.PACKAGE_NAME = void 0;
exports.jsonRpcError = jsonRpcError;
exports.readBody = readBody;
exports.getArg = getArg;
exports.createServer = createServer;
const node_http_1 = require("node:http");
var generated_js_1 = require("./generated.js");
Object.defineProperty(exports, "PACKAGE_NAME", { enumerable: true, get: function () { return generated_js_1.PACKAGE_NAME; } });
Object.defineProperty(exports, "PACKAGE_VERSION", { enumerable: true, get: function () { return generated_js_1.PACKAGE_VERSION; } });
exports.MCP_PROTOCOL_VERSION = "2024-11-05";
exports.DEFAULT_HOST = "127.0.0.1";
exports.DEFAULT_PORT = 6270;
exports.DEFAULT_PAGES_URL = "https://mcp-proxy.pages.dev";
// JSON-RPC error codes: -32600..-32603 = spec-defined, -32000..-32099 = server-defined
exports.ErrorCode = {
METHOD_NOT_FOUND: -32601, // JSON-RPC spec: method not found
INVALID_PARAMS: -32602, // JSON-RPC spec: invalid params
INTERNAL: -32603, // JSON-RPC spec: internal error
PROXY_NOT_CONFIGURED: -32001, // Proxy has not been paired yet
HOST_UNREACHABLE: -32002, // Cannot reach the host agent via tunnel
PROCESS_EXITED: -32003, // MCP server child process exited unexpectedly
PROCESS_NOT_RUNNING: -32004, // MCP server child process is not running
REQUEST_TIMEOUT: -32005, // MCP server did not respond in time
};
exports.ErrorMessage = {
[exports.ErrorCode.METHOD_NOT_FOUND]: "Method not found",
[exports.ErrorCode.INVALID_PARAMS]: "Invalid params",
[exports.ErrorCode.INTERNAL]: "Internal error",
[exports.ErrorCode.PROXY_NOT_CONFIGURED]: "Proxy not configured",
[exports.ErrorCode.HOST_UNREACHABLE]: "Host agent unreachable",
[exports.ErrorCode.PROCESS_EXITED]: "Server process exited",
[exports.ErrorCode.PROCESS_NOT_RUNNING]: "Server process not running",
[exports.ErrorCode.REQUEST_TIMEOUT]: "Request timed out",
};
// JSON-RPC error response helper
function jsonRpcError(code, detail, id = null) {
const base = exports.ErrorMessage[code] ?? "Unknown error";
const message = detail ? `${base}: ${detail}` : base;
return JSON.stringify({ jsonrpc: "2.0", error: { code, message }, id });
}
// Read full request body as string
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
req.on("error", reject);
});
}
// Parse CLI argument by name: --flag value
function getArg(name) {
const idx = process.argv.indexOf(name);
return idx !== -1 && idx + 1 < process.argv.length ? process.argv[idx + 1] : undefined;
}
// Create HTTP server with async handler and error catching
function createServer(handler) {
return (0, node_http_1.createServer)((req, res) => {
handler(req, res).catch((err) => {
console.error(`Request handler error: ${err.message}`);
if (!res.headersSent) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(jsonRpcError(exports.ErrorCode.INTERNAL));
}
});
});
}
// Line-buffered reader: accumulates chunks and yields complete lines
class LineBuffer {
buffer = "";
push(chunk) {
this.buffer += chunk;
const parts = this.buffer.split("\n");
this.buffer = parts.pop(); // Keep incomplete trailing segment
return parts.filter((line) => line.trim().length > 0);
}
}
exports.LineBuffer = LineBuffer;