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.2.4
to
0.2.5
+69
-22
dist/host/agent.js

@@ -32,21 +32,59 @@ "use strict";

const raw = (0, node_fs_1.readFileSync)(configPath, "utf-8");
this.config = JSON.parse(raw);
const parsed = 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.
// Single boundary-time validation pass: server-name policy, per-entry
// shape, and top-level host/port. Documented defaults (args=[]) are
// installed by normalizeServerConfig so downstream consumers can trust
// the ServerConfig contract instead of re-checking shapes — without
// this, omitting `args` (legal per README) crashes McpSession deep
// inside `args.join(" ")`. All reasons are accumulated so a broken
// file surfaces a complete diff to fix in one error message rather
// than one-issue-per-restart.
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}`);
const servers = {};
if (!parsed.servers || typeof parsed.servers !== "object" || Array.isArray(parsed.servers)) {
invalid.push(` - "servers": must be an object map of name to config`);
}
else {
const rawServers = parsed.servers;
if (Object.keys(rawServers).length === 0) {
invalid.push(` - "servers": at least one server must be declared`);
}
for (const [name, entry] of Object.entries(rawServers)) {
const nameReason = (0, protocol_js_1.validateServerName)(name);
if (nameReason)
invalid.push(` - "${name}": ${nameReason}`);
const result = (0, protocol_js_1.normalizeServerConfig)(entry);
if (!result.ok) {
for (const reason of result.reasons)
invalid.push(` - "${name}": ${reason}`);
}
else if (!nameReason) {
servers[name] = result.config;
}
}
}
if (parsed.host !== undefined && typeof parsed.host !== "string") {
invalid.push(` - "host": must be a string (default: ${protocol_js_1.DEFAULT_HOST})`);
}
if (parsed.port !== undefined
&& (typeof parsed.port !== "number"
|| !Number.isInteger(parsed.port)
|| parsed.port < 0
|| parsed.port > 65535)) {
invalid.push(` - "port": must be an integer 0–65535 (default: ${protocol_js_1.DEFAULT_PORT})`);
}
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.`);
throw new Error(`Invalid host config in ${configPath}:\n${invalid.join("\n")}\n` +
`Fix the entries in config.json so they match the documented schema.`);
}
this.config = {
servers,
host: typeof parsed.host === "string" ? parsed.host : undefined,
port: typeof parsed.port === "number" ? parsed.port : undefined,
};
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;
}

@@ -204,15 +242,24 @@ get port() {

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;
// Parse once at the HTTP boundary. The host is the JSON-RPC endpoint
// from the proxy's perspective, so a malformed body must surface as a
// spec-compliant parse-error response with id:null — not get forwarded
// to the child as a notification. Without this gate the body falls
// through sendRequest's `id === undefined` branch (notification path),
// garbage hits stdin, the caller gets a misleading 202, and the
// child's parse-error reply arrives with id:null and is dropped as an
// orphan — guaranteeing a silent timeout on the proxy.
let parsedBody;
try {
method = JSON.parse(body).method;
parsedBody = JSON.parse(body);
}
catch {
// Unparseable body falls through as non-initialize → 404 below.
res.writeHead(200, { "Content-Type": "application/json" });
res.end((0, protocol_js_1.jsonRpcError)(protocol_js_1.ErrorCode.PARSE_ERROR, undefined, null));
return;
}
const isInitialize = method === "initialize";
// 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.
const isInitialize = parsedBody.method === "initialize";
let existing;

@@ -219,0 +266,0 @@ if (headerSessionId) {

@@ -9,2 +9,4 @@ import type { Prompt, Tool } from "./types.js";

export declare const SESSION_DELETE_TIMEOUT_MS = 5000;
export declare const PAIRING_HEADERS_TIMEOUT_MS = 30000;
export declare const PAIRING_REQUEST_TIMEOUT_MS = 60000;
export declare const SSE_BACKOFF_INITIAL_MS = 500;

@@ -11,0 +13,0 @@ export declare const SSE_BACKOFF_MAX_MS = 10000;

"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;
exports.CONFIGURE_PROMPT = exports.CONFIGURE_TOOL = exports.SSE_CONNECT_TIMEOUT_MS = exports.SSE_BACKOFF_MAX_MS = exports.SSE_BACKOFF_INITIAL_MS = exports.PAIRING_REQUEST_TIMEOUT_MS = exports.PAIRING_HEADERS_TIMEOUT_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");

@@ -18,2 +18,9 @@ exports.TOOL_SEPARATOR = protocol_js_1.TOOL_NAME_SEPARATOR;

exports.SESSION_DELETE_TIMEOUT_MS = 5_000;
// Pairing HTTP server per-request budgets. Pairing payloads are small JSON
// blobs (host creds, selected servers/tools), so a slow header or body
// phase is broken or hostile rather than legitimate. Bounding both prevents
// a slow upload from outliving PAIRING_WINDOW_MS via Node's defaults
// (headersTimeout 60s, requestTimeout 5min).
exports.PAIRING_HEADERS_TIMEOUT_MS = 30_000;
exports.PAIRING_REQUEST_TIMEOUT_MS = 60_000;
exports.SSE_BACKOFF_INITIAL_MS = 500;

@@ -20,0 +27,0 @@ exports.SSE_BACKOFF_MAX_MS = 10_000;

@@ -16,2 +16,13 @@ "use strict";

const fetch_timeout_js_1 = require("../core/fetch-timeout.js");
// Discovery helpers. List calls (tools/prompts/resources/templates)
// distinguish METHOD_NOT_FOUND ("feature absent" → []) from any other
// failure (transport blip, JSON-RPC error, malformed body → throw). The
// caller decides whether to preserve cached state, mark a per-capability
// retry flag, or fail outright. Every fetch is wrapped in
// DISCOVERY_FETCH_TIMEOUT_MS so a host that accepts the connection then
// hangs can't pin the proxy.
// 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;
async function initializeServer(targetUrl, baseHeaders, name, clientCapabilities, clientInfo) {

@@ -85,10 +96,9 @@ const resp = await fetch(targetUrl, {

const error = data.error;
if (error)
if (error) {
if (error.code === METHOD_NOT_FOUND)
return [];
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

@@ -153,8 +163,11 @@ // extract the list field. Throws on any transport, parse, or JSON-RPC

// 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.
// notifications/initialized → tools / prompts / resources / templates
// (each optional — METHOD_NOT_FOUND collapses to [], other failures on
// prompts/resources/templates are recorded as pending; tools failures
// other than METHOD_NOT_FOUND are still fatal because tools/list is the
// canonical "is this server alive" probe). 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) {

@@ -161,0 +174,0 @@ let sessionId;

@@ -11,4 +11,6 @@ import type { ProxyState } from "../core/state.js";

private pairing;
private configureInflight;
constructor(state: ProxyState, runner: DiscoveryRunner, bridge: UpstreamBridge, log: (line: string) => void, sendNotification: (method: string) => void);
handleConfigure(): Promise<string>;
private doConfigure;
teardownPairing(): void;

@@ -19,3 +21,4 @@ private validateHostCreds;

private handleComplete;
private notifyAllListsChanged;
closeAllSessions(): Promise<void>;
}

@@ -25,2 +25,9 @@ "use strict";

pairing = null;
// Single-flight gate for handleConfigure. Without it, two `configure`
// calls landing in the same event-loop window both observe `this.pairing`
// as null, both spawn an HTTP server + cloudflared subprocess, and the
// second's assignment to `this.pairing` orphans the first. The orphan's
// expiryTimer would then later call teardownPairing() and tear down the
// wrong (active) pairing, killing a working setup mid-session.
configureInflight = null;
constructor(state, runner, bridge, log, sendNotification) {

@@ -33,3 +40,11 @@ this.state = state;

}
async handleConfigure() {
handleConfigure() {
if (this.configureInflight)
return this.configureInflight;
this.configureInflight = this.doConfigure().finally(() => {
this.configureInflight = null;
});
return this.configureInflight;
}
async doConfigure() {
this.teardownPairing();

@@ -262,2 +277,18 @@ const bearer = (0, node_crypto_1.randomBytes)(32).toString("base64url");

}
// selectedTools entries are validated structurally by validatePairingConfig,
// but their existence on the wire can only be checked after discovery has
// populated state.toolRoute. The browser path can't produce stale entries
// (the UI builds selectedTools from the just-discovered set), but a direct
// caller of /pair/complete can — and getFilteredTools silently drops any
// entry whose key isn't in toolRoute, leaving the proxy paired-but-empty
// for tools with no error surfaced. Treat stale entries the same as
// missing servers: roll back to the previous pairing (or refuse, on
// first-time pair) so the operator gets a real failure signal instead of
// a cheerful ok on a config that won't expose any tools.
if (cfg.selectedTools) {
for (const key of cfg.selectedTools) {
if (!this.state.toolRoute.has(key))
missing.push(key);
}
}
if (missing.length > 0) {

@@ -275,2 +306,8 @@ // Cap the detail string so a wildly broken submit doesn't produce a

await this.runner.discoverServers();
// installConfig replaced toolRoute / promptRoute / resources between
// the success-path notify (only fires when missing.length === 0) and
// here, so an agent that polled tools/list during the new pairing's
// discovery may be holding a partial snapshot that no longer matches
// the restored routes. Notify list_changed so it re-fetches.
this.notifyAllListsChanged();
return {

@@ -286,2 +323,6 @@ ok: false,

this.state.config = null;
// Same notify rationale as the rollback branch above: a polling agent
// may have grabbed partial-new routes during discovery; tell it to
// re-fetch and find an empty unconfigured set.
this.notifyAllListsChanged();
return {

@@ -297,5 +338,3 @@ ok: false,

// cached empty lists from before pairing.
this.sendNotification("notifications/tools/list_changed");
this.sendNotification("notifications/prompts/list_changed");
this.sendNotification("notifications/resources/list_changed");
this.notifyAllListsChanged();
// Defer pairing teardown until /pair/complete's response body has

@@ -307,3 +346,16 @@ // actually drained to the client — a fixed timer races slow clients

}
notifyAllListsChanged() {
this.sendNotification("notifications/tools/list_changed");
this.sendNotification("notifications/prompts/list_changed");
this.sendNotification("notifications/resources/list_changed");
}
async closeAllSessions() {
// Bump the supersession token before any await so an in-flight forwarder
// request that resolves during teardown sees a stale generation and
// refuses to write its response. Without this, a fetch completing during
// bridge.clear / DELETE awaits emits onto stdout against a pairing that
// no longer exists. installConfig() bumps again on the way in; the
// double-bump is harmless because the token is only used for equality
// comparison.
this.state.configGeneration++;
// Tear down SSE listeners first so loops don't reconnect after DELETE.

@@ -310,0 +362,0 @@ for (const host of this.state.hosts.values()) {

@@ -6,2 +6,3 @@ "use strict";

const protocol_js_1 = require("../../shared/protocol.js");
const constants_js_1 = require("../core/constants.js");
const static_assets_js_1 = require("./static-assets.js");

@@ -21,2 +22,7 @@ class PairingHttpServer {

const srv = (0, protocol_js_1.createServer)((req, res) => this.handle(req, res));
// Bound the header + body phases so a slow upload can't drag past
// PAIRING_WINDOW_MS. Node defaults (60s / 5min) are too generous for
// small JSON pairing payloads.
srv.headersTimeout = constants_js_1.PAIRING_HEADERS_TIMEOUT_MS;
srv.requestTimeout = constants_js_1.PAIRING_REQUEST_TIMEOUT_MS;
srv.once("error", rejectP);

@@ -37,2 +43,7 @@ srv.listen(0, "127.0.0.1", () => {

return;
// server.close() alone only refuses new connections; idle keep-alive
// sockets and any in-flight request body would otherwise outlive the
// pairing window. closeAllConnections() (Node ≥18.2) hard-drops them
// so teardown is bounded by the window, not by the slowest client.
this.server.closeAllConnections();
this.server.close();

@@ -39,0 +50,0 @@ this.server = null;

@@ -42,2 +42,9 @@ "use strict";

const body = JSON.stringify({ jsonrpc: "2.0", id, method, params: upstreamParams });
// Snapshot the active pairing so a re-pair landing while this fetch is
// in flight can be detected before we write a stale response back to the
// agent. closeAllSessions clears state.inflight, but our local closure
// still holds the id/route, so without this guard the OLD pairing's
// response (or its upstream-level error) would be emitted on stdout
// against a pairing that no longer exists.
const startGeneration = this.state.configGeneration;
this.state.inflight.set(id, route);

@@ -86,2 +93,10 @@ const progressToken = (upstreamParams?._meta?.progressToken);

}
// Pairing changed underneath us between dispatch and response. Reply
// with a generic error so the request doesn't hang — the agent can
// retry against the new pairing — but don't write the stale body or
// its upstream-level error, since neither applies to the new config.
if (this.state.configGeneration !== startGeneration) {
this.sendError(protocol_js_1.ErrorCode.INTERNAL, "request superseded by reconfiguration", id);
return;
}
if (!upstream.ok) {

@@ -142,2 +157,9 @@ this.sendError(protocol_js_1.ErrorCode.HOST_UNREACHABLE, `host returned ${upstream.status}: ${responseBody.slice(0, 200)}`, id);

catch (err) {
// Same supersession guard as the success path: if the abort/error
// raced a re-pair, the agent should see "superseded" rather than the
// raw transport error from a pairing that no longer exists.
if (this.state.configGeneration !== startGeneration) {
this.sendError(protocol_js_1.ErrorCode.INTERNAL, "request superseded by reconfiguration", id);
return;
}
this.sendError(protocol_js_1.ErrorCode.HOST_UNREACHABLE, err.message, id);

@@ -144,0 +166,0 @@ }

@@ -43,7 +43,7 @@ import type { ProxyState } from "../core/state.js";

handleToolDispatch(id: string | number, params: {
name: string;
name?: string;
arguments?: Record<string, unknown>;
_meta?: unknown;
}): Promise<void>;
} | undefined): Promise<void>;
handleClientNotification(method: string, params: Record<string, unknown>): Promise<void>;
}

@@ -244,3 +244,8 @@ "use strict";

async handleToolDispatch(id, params) {
if (params.name === "configure") {
if (!params || typeof params.name !== "string") {
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, "name is required", id);
return;
}
const toolName = params.name;
if (toolName === "configure") {
let text;

@@ -261,10 +266,10 @@ try {

}
const route = this.state.toolRoute.get(params.name);
const route = this.state.toolRoute.get(toolName);
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);
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, `Unknown tool: ${toolName}`, 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);
if (this.state.config.selectedTools !== undefined && !this.state.config.selectedTools.includes(toolName)) {
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, `Unknown tool: ${toolName}`, id);
return;

@@ -271,0 +276,0 @@ }

@@ -135,2 +135,8 @@ "use strict";

}
// Symmetric with server.ts: an upstream "request" with id:null can't
// be answered (the bridge keys responses by id), so drop rather than
// forwarding it to the agent as if it were a notification — that
// would silently re-cast a request the upstream expects a response to.
if (hasMethod && msg.id === null)
return;
if (!hasMethod)

@@ -137,0 +143,0 @@ return; // stray response — not expected on this stream

@@ -109,2 +109,8 @@ "use strict";

return;
// Teardown courtesy — share the DELETE budget, not the 5-min tool
// budget. A blackholed host would otherwise stall closeAllSessions
// (re-pair, rollback, SIGTERM shutdown) until TOOL_FORWARD_TIMEOUT_MS.
// The DELETE that follows reaps the session anyway; if this courtesy
// doesn't land quickly the upstream's own UPSTREAM_REQUEST_TIMEOUT_MS
// catches the orphaned child.
return this.postResponse(host, ctx.serverName, ctx.sessionId, {

@@ -114,6 +120,6 @@ jsonrpc: "2.0",

error: { code: protocol_js_1.ErrorCode.INTERNAL, message: "proxy reconfigured before client responded" },
});
}, constants_js_1.SESSION_DELETE_TIMEOUT_MS);
}));
}
async postResponse(host, serverName, sessionId, body) {
async postResponse(host, serverName, sessionId, body, timeoutMs = constants_js_1.TOOL_FORWARD_TIMEOUT_MS) {
const target = `${host.config.tunnelUrl}/servers/${serverName}`;

@@ -125,3 +131,3 @@ const headers = { ...this.hostHeaders(host.config), "Mcp-Session-Id": sessionId };

headers,
signal: (0, fetch_timeout_js_1.timeoutSignal)(constants_js_1.TOOL_FORWARD_TIMEOUT_MS),
signal: (0, fetch_timeout_js_1.timeoutSignal)(timeoutMs),
body: JSON.stringify(body),

@@ -128,0 +134,0 @@ });

@@ -123,2 +123,12 @@ "use strict";

}
// JSON-RPC 2.0 discourages id:null in requests because the spec
// reserves null for "id couldn't be parsed" in error responses
// (we use it ourselves at the PARSE_ERROR / missing-method paths).
// Falling through to the notification branch here would silently
// drop a request the agent expects an answer to; reject explicitly
// so the agent sees a real error instead of hanging.
if (parsed.id === null) {
process.stdout.write((0, protocol_js_1.jsonRpcError)(protocol_js_1.ErrorCode.INVALID_REQUEST, "id must not be null in a request", null) + "\n");
return;
}
if (!hasId) {

@@ -125,0 +135,0 @@ await this.handlers.handleClientNotification(parsed.method, parsed.params ?? {});

@@ -53,2 +53,9 @@ import type { IncomingMessage, ServerResponse } from "node:http";

}
export declare function normalizeServerConfig(raw: unknown): {
ok: true;
config: ServerConfig;
} | {
ok: false;
reasons: string[];
};
export interface HostAgentConfig {

@@ -55,0 +62,0 @@ servers: Record<string, ServerConfig>;

@@ -9,2 +9,3 @@ "use strict";

exports.validateServerName = validateServerName;
exports.normalizeServerConfig = normalizeServerConfig;
const node_fs_1 = require("node:fs");

@@ -185,1 +186,44 @@ const node_http_1 = require("node:http");

}
// Validates a raw ServerConfig from JSON and returns the canonical form
// with documented defaults installed (args=[]). Every consumer of
// ServerConfig — McpSession's spawn, the log line, future tooling — can
// then trust the interface contract instead of re-checking shapes
// defensively at every use site. All shape reasons are collected so a
// misconfigured file surfaces a complete diff to fix in one error
// message rather than one-issue-per-restart.
function normalizeServerConfig(raw) {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return { ok: false, reasons: ["must be an object with at least { command }"] };
}
const r = raw;
const reasons = [];
if (typeof r.command !== "string" || r.command.length === 0) {
reasons.push("command must be a non-empty string");
}
if (r.args !== undefined
&& !(Array.isArray(r.args) && r.args.every((a) => typeof a === "string"))) {
reasons.push("args must be an array of strings (default: [])");
}
if (r.env !== undefined) {
const envOk = typeof r.env === "object"
&& r.env !== null
&& !Array.isArray(r.env)
&& Object.values(r.env).every((v) => typeof v === "string");
if (!envOk)
reasons.push("env must be a map of string to string (default: {})");
}
if (r.shell !== undefined && typeof r.shell !== "boolean") {
reasons.push("shell must be boolean (default: false)");
}
if (reasons.length > 0)
return { ok: false, reasons };
return {
ok: true,
config: {
command: r.command,
args: r.args ?? [],
env: r.env,
shell: r.shell,
},
};
}
{
"name": "@silver886/mcp-proxy",
"version": "0.2.4",
"version": "0.2.5",
"description": "MCP proxy bridge: forward MCP requests across network boundaries via Cloudflare tunnel",

@@ -62,4 +62,5 @@ "repository": {

"scripts": {
"build": "tsc"
"build": "tsc",
"test": "tsc --noEmit"
}
}

@@ -109,3 +109,4 @@ # MCP Proxy

That HTTP server serves both the setup page (GET /) and the pairing API
(POST /pair/forward, POST /pair/complete) on the same origin.
(POST /pair/list-servers, POST /pair/discover, POST /pair/complete) on
the same origin.
3. Wrapper prints the tunnel URL. Proxy mints a bearer token and emits a

@@ -112,0 +113,0 @@ setup URL — `<tunnel>/#token=<token>`. Token rides in the URL fragment

@@ -95,18 +95,33 @@ // Pairing UI logic. Loaded by setup.html, talks to the proxy's pairing

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 />
const head = document.createElement('div');
head.className = 'host-head';
const title = document.createElement('div');
title.className = 'host-id';
title.dataset.role = 'title';
title.textContent = `Host ${host.id || '(unnamed)'}`;
head.appendChild(title);
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'host-remove';
removeBtn.dataset.action = 'remove';
removeBtn.textContent = 'Remove';
head.appendChild(removeBtn);
row.appendChild(head);
<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 />
appendHostField(row, host.uid, 'id', 'text', host.id, 'Host ID', 'dev-laptop', {
pattern: '(?!.*__)[A-Za-z0-9._\\-]+',
title: "Letters, digits, '.', '_', '-'. Must not contain '__' and must be unique across hosts.",
});
appendHostField(row, host.uid, 'tunnelUrl', 'url', host.tunnelUrl, 'Tunnel URL', 'https://abc-xyz.trycloudflare.com');
appendHostField(row, host.uid, 'authToken', 'text', host.authToken, 'Auth Token', 'Paste token from host agent');
<div class="host-status ${host.status.startsWith('Error') ? 'error' : host.status.startsWith('Partial') ? 'partial' : host.status ? 'ok' : ''}">${esc(host.status)}</div>
`;
const status = document.createElement('div');
const statusClass = host.status.startsWith('Error') ? 'error'
: host.status.startsWith('Partial') ? 'partial'
: host.status ? 'ok' : '';
status.className = statusClass ? `host-status ${statusClass}` : 'host-status';
status.textContent = host.status;
row.appendChild(status);
container.appendChild(row);

@@ -123,2 +138,24 @@ }

// Build a label + input pair via DOM properties so user-supplied values
// can't escape attribute context. Template-literal interpolation with
// esc() escapes <, >, & only — quotes and apostrophes pass through and
// would let an upstream-supplied token break out of value="…".
function appendHostField(row, uid, field, type, value, labelText, placeholder, extras) {
const id = `${field}-${uid}`;
const label = document.createElement('label');
label.htmlFor = id;
label.textContent = labelText;
row.appendChild(label);
const input = document.createElement('input');
input.id = id;
input.type = type;
input.dataset.field = field;
input.value = value;
input.placeholder = placeholder;
input.required = true;
if (extras?.pattern) input.pattern = extras.pattern;
if (extras?.title) input.title = extras.title;
row.appendChild(input);
}
document.getElementById('hosts-container').addEventListener('input', (e) => {

@@ -588,11 +625,35 @@ const row = e.target.closest('.host-row');

: 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>
`;
// Build via DOM properties so tool.name (sourced from an upstream
// MCP server, untrusted) can't escape attribute context. esc() only
// covers text-node escaping; a quote in tool.name would break out
// of data-tool="…" / id="…" / for="…" if interpolated as HTML.
const checkWrap = document.createElement('div');
checkWrap.className = 'tool-check';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.id = cbId;
cb.dataset.role = 'tool';
cb.dataset.host = hostId;
cb.dataset.server = serverName;
cb.dataset.tool = tool.name;
cb.checked = checked;
checkWrap.appendChild(cb);
item.appendChild(checkWrap);
const label = document.createElement('label');
label.className = 'tool-label';
label.htmlFor = cbId;
const nameSpan = document.createElement('span');
nameSpan.className = 'tool-name';
nameSpan.textContent = tool.name;
label.appendChild(nameSpan);
if (tool.description) {
const descSpan = document.createElement('span');
descSpan.className = 'tool-desc';
descSpan.textContent = tool.description;
label.appendChild(descSpan);
}
item.appendChild(label);
list.appendChild(item);

@@ -599,0 +660,0 @@ }