UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

330 lines • 15 kB
// Secure bridge: when `connect` (or the panel orchestrator) drives a REMOTE // https ComfyUI (e.g. a RunPod pod), the pod's HTTPS panel page cannot open a // plain `ws://127.0.0.1:<bridge>` to the local bridge — browsers block insecure // (ws://) sockets from a secure (https://) page (mixed-content) and gate // public→loopback access (Private Network Access). This module makes it work // transparently, via one of two backends: // // cloudflared (default) — open a cloudflared quick tunnel to the loopback // bridge port, giving a `wss://<rand>.trycloudflare.com/?token=<token>` URL. // Zero setup, but the tunnel is ephemeral (a fresh random hostname every run, // "no uptime guarantee" per Cloudflare's own disclaimer for this product). // // relay (opt-in: COMFYUI_MCP_TUNNEL_BACKEND=relay + COMFYUI_MCP_RELAY_URL) — // dial OUT to a self-hosted comfyui-mcp-relay (Cloudflare Worker + Durable // Object; github.com/artokun/comfyui-mcp-relay, private) that gives a stable // wss:// endpoint under your own domain instead of a random one. See that // repo's README for the wire protocol. // // Either way: ADVERTISE the resulting URL to the pod's panel pack so the browser // panel fetches and uses it automatically — the user never copies a URL. The // token is the only thing gating a now-publicly-reachable bridge, so it is // generated per session and checked constant-time on every WS upgrade // (see UiBridge). import { randomBytes } from "node:crypto"; import { existsSync } from "node:fs"; import { startQuickTunnel } from "./tunnel.js"; import { RelayClient } from "./relay-client.js"; import { logger } from "../utils/logger.js"; import { comfyuiFetch } from "../comfyui/fetch.js"; /** https://host → wss://host/?token=… (the panel's bridge URL). */ function toWssUrl(httpsUrl, token) { const u = new URL(httpsUrl); u.protocol = "wss:"; u.search = ""; u.searchParams.set("token", token); return u.toString(); } /** Mask the token when logging a wss URL. */ function maskToken(url) { return url.replace(/token=[^&]+/, "token=…"); } /** Per-attempt ceiling for the advertise POST. Deliberately well under the retry * budget: three attempts plus backoff must still finish in a bounded time, and a * pod route that has not answered in ten seconds is not about to. */ const ADVERTISE_TIMEOUT_MS = 10_000; /** * POST the bridge URL to the pod's panel pack so its browser panel can fetch it. * Retries a few times — the orchestrator may advertise before the pod route is * warm. Returns true on the first 2xx. */ export async function advertiseBridge(comfyuiUrl, wssUrl, shouldAdvertise, localUrl) { let endpoint; try { endpoint = new URL("/comfyui_mcp_panel/advertise_bridge", comfyuiUrl).toString(); } catch { return false; } const body = { url: wssUrl }; if (localUrl) body.local_url = localUrl; for (let attempt = 0; attempt < 3; attempt += 1) { // Re-check per attempt: the target may have moved DURING the retry sequence // — a stale pod must never receive the bridge URL (codex finding). if (shouldAdvertise && !shouldAdvertise(comfyuiUrl)) return false; try { const res = await comfyuiFetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), // Without this the retry loop could not retry: a pod behind a proxy that // accepts the connection and then answers nothing (the characteristic // RunPod-route failure this function's own comment describes as "not warm // yet") left attempt 1 awaiting forever, so attempts 2 and 3 never ran and // bridge exposure hung with no error. A ceiling turns that hang into the // retry the loop was written to perform. signal: AbortSignal.timeout(ADVERTISE_TIMEOUT_MS), }); if (res.ok) return true; logger.warn(`[secure-bridge] advertise got HTTP ${res.status} from the pod panel`); } catch (err) { logger.warn(`[secure-bridge] advertise attempt ${attempt + 1}/3 failed: ${err.message}`); } await new Promise((r) => setTimeout(r, 500 * (attempt + 1))); } return false; } /** * GET the panel's status route to learn where ComfyUI lives on THIS machine. * * The sidebar panel serves `/comfyui_mcp_panel/status` with a `base_path` — the * ComfyUI install root the panel is running inside (panel commit f45054e). The * orchestrator consumes it as a LAST-RESORT source of the local ComfyUI base for * an embedded loopback session that has no CLI-configured workspace (COMFYUI_PATH * unset AND auto-detect empty), so the path-dependent LOCAL surface still works — * the comfyui MCP runs in LOCAL mode (download_model / apply_manifest / model * scans) and panel_load_workflow's local fallback can resolve names (#296). * * Best-effort and time-bounded: returns undefined on ANY failure (unreachable, * non-2xx, non-JSON, missing/blank field) so it can only ADD a path — it must * NEVER throw or stall session start. Sent with the same auth headers as * advertiseBridge so a token-gated panel answers. Disk validation is the caller's * job (this returns the panel's raw claim). */ export async function fetchPanelBasePath(comfyuiUrl, timeoutMs = 2500) { let endpoint; try { endpoint = new URL("/comfyui_mcp_panel/status", comfyuiUrl).toString(); } catch { return undefined; } try { const res = await comfyuiFetch(endpoint, { signal: AbortSignal.timeout(timeoutMs), }); if (!res.ok) return undefined; const data = (await res.json()); if (!data || typeof data !== "object") return undefined; // Current panel builds publish this as `comfyui_path` (the live // folder_paths.base_path). Keep the original `base_path` spelling and the // camelCase alias for older/interoperating panel builds. const raw = data.comfyui_path ?? data.base_path ?? data.basePath; if (typeof raw === "string" && raw.trim()) return raw.trim(); return undefined; } catch { return undefined; } } /** * Resolve the effective LOCAL ComfyUI base directory for a session target, * consulting the panel's status route as a LAST-RESORT fallback (#296). * * Decision order — kept in ONE place so startup and the panel-`hello` retarget * path can never disagree about where ComfyUI lives: * 1. A CLI-configured / auto-detected local path (`localPath`) wins, but ONLY * for a loopback, non-force-remote target (a REMOTE/force-remote target must * never adopt a local dir — it isn't the remote host). * 2. Remote / force-remote target → undefined (routes through the Manager). * 3. Otherwise (loopback, non-force-remote, but NO local path) → GET the panel's * `/comfyui_mcp_panel/status` `base_path` and adopt it, but ONLY if that path * actually EXISTS on this machine (a bogus / remote-looking claim must not * poison LOCAL mode). * * `fetchBasePath`/`exists` are injectable purely for tests; production uses the * real status fetch + `fs.existsSync`. Best-effort — never throws. */ export async function resolveComfyuiPathForTarget(opts) { const { target, localPath, forceRemote, isLoopback } = opts; if (!forceRemote && isLoopback && localPath) return localPath; if (forceRemote || !isLoopback) return undefined; const fetchBasePath = opts.fetchBasePath ?? fetchPanelBasePath; const exists = opts.exists ?? existsSync; let fromPanel; try { fromPanel = await fetchBasePath(target); } catch { return undefined; // bulletproof — a broken fetch must not break session start } if (fromPanel && exists(fromPanel)) return fromPanel; return undefined; } /** * Stand up the secure bridge and advertise its URL to the pod. Backend is * chosen by env (see the module doc comment above); cloudflared is the default. * Throws if the chosen backend can't come up (caller decides whether to fall * back to the plain ws bridge or fail). */ export async function setupSecureBridge(opts) { const backendEnv = process.env.COMFYUI_MCP_TUNNEL_BACKEND?.trim().toLowerCase(); const relayUrl = process.env.COMFYUI_MCP_RELAY_URL?.trim(); if (backendEnv === "relay" || (backendEnv !== "cloudflared" && relayUrl)) { return setupRelayBridge(opts); } return setupCloudflaredBridge(opts); } /** * Dial a self-hosted comfyui-mcp-relay Worker as this session's orchestrator * connection. Each relay-multiplexed panel connection is fed into * bridge.attachRelayConnection, making it indistinguishable from a direct * loopback socket to the rest of the orchestrator. */ /** * The identity origin for a relay-attached panel connection (#1077). * * `scheme://host[:port]`, lowercased and port-normalised, matching the shape a * browser sends as `Origin` and `workflowIdentityParts()` expects. Returns * undefined when the URL cannot be parsed rather than inventing a string: a * WRONG origin is worse than none, because none refuses adoption visibly while a * wrong one scopes the fence to an identity no other transport will reproduce. */ export function relayIdentityOrigin(comfyuiUrl) { if (typeof comfyuiUrl !== "string" || !comfyuiUrl.trim()) return undefined; try { // `new URL().origin` already omits a default port and lowercases the host, // which is exactly the normalisation the loopback path's header goes through. const origin = new URL(comfyuiUrl.trim()).origin; return origin && origin !== "null" ? origin.toLowerCase() : undefined; } catch { return undefined; } } async function setupRelayBridge(opts) { const { comfyuiUrl, token, bridge } = opts; const relayUrl = process.env.COMFYUI_MCP_RELAY_URL?.trim(); if (!relayUrl) { throw new Error("COMFYUI_MCP_TUNNEL_BACKEND=relay is set but COMFYUI_MCP_RELAY_URL is empty — set it to your " + "deployed comfyui-mcp-relay Worker URL (wss://…), or unset COMFYUI_MCP_TUNNEL_BACKEND to fall " + "back to the cloudflared quick-tunnel backend."); } // Distinct from the bridge token — the session id is path-visible (low value, // just routes to the right Durable Object); the token is the actual secret. const sessionId = randomBytes(8).toString("hex"); const client = new RelayClient({ relayUrl, sessionId, token, accessKey: process.env.COMFYUI_MCP_RELAY_KEY?.trim() || undefined, // #1077 — the workflow-instance fence is scoped to the connection's // server-observed Origin, and a relay socket has no handshake to observe one // from: `attachRelayConnection` passed none, so `workflowIdentityParts()` // refused, and every relay-backend session was PERMANENTLY unable to adopt a // fence. The reporter refreshed, closed and reopened the tab, and traced it // to source before finding that nothing could have helped. // // It does not need the relay protocol to forward the browser's Origin. The // Origin identifies WHERE THE PANEL PAGE IS SERVED FROM, and that is the // ComfyUI this session is already pointed at — which the orchestrator holds // right here. It is DERIVED rather than observed, so it asserts what the // loopback path proves; in a single-machine session those are the same fact, // and the alternative is a fence nobody can ever adopt. onAttach: (sock) => bridge.attachRelayConnection(sock, relayIdentityOrigin(comfyuiUrl)), }); client.start(); try { await client.waitUntilOpen(); } catch (err) { // A failed setup must not leave a reconnecting client behind — the 5s // retry would pile one on every attempt during a relay outage (codex). client.stop(); throw err; } const wssUrl = `${relayUrl.replace(/\/+$/, "")}/s/${sessionId}?token=${token}`; logger.info(`[secure-bridge] bridge exposed via relay at ${maskToken(wssUrl)}`); const advertise = async (target) => { const ok = await advertiseBridge(target, wssUrl, opts.shouldAdvertise, opts.localUrl); if (ok) { logger.info(`[secure-bridge] advertised the secure bridge URL to the pod panel`); } else { logger.warn(`[secure-bridge] could not reach the pod panel to advertise the bridge URL — ` + `open the pod's ComfyUI and it will retry on Connect`); } return ok; }; if (!opts.shouldAdvertise || opts.shouldAdvertise(comfyuiUrl)) { await advertise(comfyuiUrl); } return { wssUrl, advertise, stop: () => client.stop(), }; } /** * Open a cloudflared tunnel to the loopback bridge and advertise the resulting * wss URL to the pod. Throws if the tunnel can't come up (caller decides whether * to fall back to the plain ws bridge or fail). */ async function setupCloudflaredBridge(opts) { const { bridgePort, comfyuiUrl, token } = opts; // 127.0.0.1 (not localhost) — the bridge is IPv4 loopback-bound. const tunnel = await startQuickTunnel(bridgePort, "127.0.0.1"); // Safety net: kill the tunnel on any process exit (e.g. the orchestrator's // uncaughtException → process.exit(1) path, which bypasses the graceful // shutdown that also calls stop()). Idempotent. Does not fire on SIGKILL. process.once("exit", () => { try { tunnel.stop(); } catch { // already gone } }); const wssUrl = toWssUrl(tunnel.url, token); logger.info(`[secure-bridge] bridge exposed securely at ${maskToken(wssUrl)}`); const advertise = async (target) => { const ok = await advertiseBridge(target, wssUrl, opts.shouldAdvertise, opts.localUrl); if (ok) { logger.info(`[secure-bridge] advertised the secure bridge URL to the pod panel`); } else { logger.warn(`[secure-bridge] could not reach the pod panel to advertise the bridge URL — ` + `open the pod's ComfyUI and it will retry on Connect`); } return ok; }; if (!opts.shouldAdvertise || opts.shouldAdvertise(comfyuiUrl)) { await advertise(comfyuiUrl); } return { wssUrl, advertise, stop: () => { try { tunnel.stop(); } catch { // already gone } }, }; } //# sourceMappingURL=secure-bridge.js.map