@silver886/mcp-proxy
Advanced tools
+69
-22
@@ -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 @@ }); |
+10
-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, | ||
| }, | ||
| }; | ||
| } |
+3
-2
| { | ||
| "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" | ||
| } | ||
| } |
+2
-1
@@ -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 |
+83
-22
@@ -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 @@ } |
274477
5.91%5813
5.27%217
0.46%