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),
867 lines • 39.3 kB
JavaScript
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { lookup as dnsLookupCb } from "node:dns";
import { promisify } from "node:util";
import { createServer } from "node:http";
/**
* Authenticated loopback relay for the read-only workflow-template index.
*
* The list_packs child cannot reach the panel bridge directly. It sends only a
* capability-bound request to the orchestrator; the orchestrator resolves the
* live panel tab and asks that tab to fetch same-origin /api/workflow_templates
* (the SSRF-safe path for a remote or mixed-alias origin). Loopback HTTP to a
* corroborated origin remains a fallback for older panels that do not implement
* the workflow_templates read. No configured ComfyUI credentials or
* caller-supplied URL crosses this boundary.
*/
export const PANEL_TEMPLATE_RELAY_VERSION = 1;
export const PANEL_TEMPLATE_RELAY_TIMEOUT_MS = 8_000;
export const PANEL_TEMPLATE_RELAY_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
export const PANEL_TEMPLATE_RELAY_MAX_REQUEST_BYTES = 16 * 1024;
export const PANEL_TEMPLATE_RELAY_MAX_CONCURRENT = 4;
export const PANEL_TEMPLATE_RELAY_STALE_MS = 15_000;
export const PANEL_TEMPLATE_RELAY_HTTP_PATH = "/__comfyui_mcp_panel_template_relay";
const ID_RE = /^[A-Za-z0-9_-]{16,80}$/;
const HEX_RE = /^[a-f0-9]{64}$/;
// Hosts that may AUTHORIZE a relay origin. `localhost` is included, but it is
// never fetched as a name — see LOOPBACK_LITERALS and pinnedLoopbackUrls.
//
// #2382 is the reason this needs saying. `localhost` names an address family,
// not a listener, so an exact origin match does not prove that a later
// `fetch(url)` reaches the socket the browser is on: Node >=20 connects with
// Happy Eyeballs, so the name can land on either loopback family. #2385
// answered that by dropping `localhost` from this set, which took `list_packs
// action:"list_templates"` from working to erroring for every user whose
// ComfyUI is served at `http://localhost:<port>` — an ordinary setup, since the
// panel Origin is the browser's — and the caller is fail-closed once a panel
// route exists, so nothing caught it.
//
// The refusal was not the only option, and it was not the right one. The
// ambiguity lives in the SECOND name resolution, not in the origin check, so it
// is removed rather than refused: the destination is resolved once, here, and
// every request goes to a literal address. Where a name resolves to more than
// one loopback address the candidates must AGREE, which is the property that
// actually matters and is checkable, unlike listener identity, which is not.
//
// Not falling through to the headless COMFYUI_URL path on a refusal is
// deliberate and stays that way (#2387): a mid-turn child keeps the PREVIOUS
// target across a retarget (#1429, retargetAllForMcpEnv), so it can list a
// stale server's index. Fetching here, under the relay's own generation fence,
// is what prevents that.
//
// A mixed pair (`localhost` observed vs `127.0.0.1` configured, or the reverse)
// is still refused by the exact-origin equality below.
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
// Relay fetches only ever go to a LITERAL loopback address. `localhost` may
// authorize an origin (above), but it is never a fetch destination: see
// pinnedLoopbackUrls, which resolves it to these before any request is made.
const LOOPBACK_LITERALS = new Set(["127.0.0.1", "::1"]);
// Promisified so the single resolution happens in ONE place we control.
const dnsLookup = promisify(dnsLookupCb);
const AMBIGUOUS_LOOPBACK_NAMES = new Set(["localhost"]);
export const PANEL_TEMPLATE_READ_OPERATION = "workflow_templates";
export class PanelTemplateRelayError extends Error {
code;
unavailable;
constructor(message, code, unavailable = false) {
super(message);
this.name = "PanelTemplateRelayError";
this.code = code;
this.unavailable = unavailable;
}
}
function hasOnlyKeys(value, keys) {
const allowed = new Set(keys);
return Object.keys(value).every((key) => allowed.has(key));
}
function isSafeText(value, max) {
return (typeof value === "string" &&
value.length > 0 &&
value.length <= max &&
![...value].some((char) => {
const code = char.charCodeAt(0);
return code < 0x20 || code === 0x7f;
}));
}
function isSecret(value) {
return typeof value === "string" && HEX_RE.test(value);
}
function authPayload(request) {
return JSON.stringify([request.requestId, request.createdAt, request.deadlineAt]);
}
export function makePanelTemplateRelayCapability(secret, request) {
return createHmac("sha256", secret).update(authPayload(request)).digest("hex");
}
export function verifyPanelTemplateRelayCapability(secret, request) {
if (!isSecret(secret) || !HEX_RE.test(request.capability))
return false;
const expected = makePanelTemplateRelayCapability(secret, request);
return timingSafeEqual(Buffer.from(request.capability, "hex"), Buffer.from(expected, "hex"));
}
function validateRequest(value, requestId) {
if (!value || typeof value !== "object")
return undefined;
const record = value;
if (!hasOnlyKeys(record, ["version", "requestId", "capability", "createdAt", "deadlineAt"]) ||
record.version !== PANEL_TEMPLATE_RELAY_VERSION ||
record.requestId !== requestId ||
!ID_RE.test(requestId) ||
!isSecret(record.capability) ||
typeof record.createdAt !== "number" ||
typeof record.deadlineAt !== "number" ||
!Number.isSafeInteger(record.createdAt) ||
!Number.isSafeInteger(record.deadlineAt) ||
record.deadlineAt < record.createdAt ||
record.deadlineAt - record.createdAt > PANEL_TEMPLATE_RELAY_TIMEOUT_MS)
return undefined;
return record;
}
function requestDeadline(request) {
return Math.min(request.deadlineAt, request.createdAt + PANEL_TEMPLATE_RELAY_TIMEOUT_MS);
}
function failureResponse(requestId, error, updated = Date.now()) {
return { version: PANEL_TEMPLATE_RELAY_VERSION, requestId, ok: false, error, updated };
}
function responseMacPayload(response) {
return response.ok
? JSON.stringify([response.version, response.requestId, true, response.body, response.updated])
: JSON.stringify([response.version, response.requestId, false, response.error, response.updated]);
}
function addResponseMac(secret, response) {
return {
...response,
responseMac: createHmac("sha256", secret).update(responseMacPayload(response)).digest("hex"),
};
}
function validateResponse(value, requestId, secret) {
if (!value || typeof value !== "object") {
throw new PanelTemplateRelayError("The panel template relay returned a malformed reply.", "MALFORMED_REPLY");
}
const record = value;
const responseMac = record.responseMac;
if (!isSecret(responseMac)) {
throw new PanelTemplateRelayError("The panel template relay returned an unauthenticated reply.", "MALFORMED_REPLY");
}
const body = { ...record };
delete body.responseMac;
if (!timingSafeEqual(Buffer.from(responseMac, "hex"), Buffer.from(createHmac("sha256", secret).update(responseMacPayload(body)).digest("hex"), "hex"))) {
throw new PanelTemplateRelayError("The panel template relay returned an unauthenticated reply.", "MALFORMED_REPLY");
}
if (record.version !== PANEL_TEMPLATE_RELAY_VERSION ||
record.requestId !== requestId ||
typeof record.updated !== "number" ||
!Number.isSafeInteger(record.updated)) {
throw new PanelTemplateRelayError("The panel template relay returned a malformed reply.", "MALFORMED_REPLY");
}
if (record.ok === true &&
typeof record.body === "string" &&
record.body.length <= PANEL_TEMPLATE_RELAY_MAX_RESPONSE_BYTES &&
hasOnlyKeys(record, ["version", "requestId", "ok", "body", "updated", "responseMac"])) {
return { version: PANEL_TEMPLATE_RELAY_VERSION, requestId, ok: true, body: record.body, updated: record.updated };
}
if (record.ok === false &&
typeof record.error === "string" &&
isSafeText(record.error, 160) &&
hasOnlyKeys(record, ["version", "requestId", "ok", "error", "updated", "responseMac"])) {
return record;
}
throw new PanelTemplateRelayError("The panel template relay returned a malformed reply.", "MALFORMED_REPLY");
}
function errorMessage(error) {
const known = new Set([
"AMBIGUOUS_PANEL_LISTENER",
"AMBIGUOUS_REQUESTER",
"BACKLOG_FULL",
"HTTP_ERROR",
"MALFORMED_REPLY",
"NO_LIVE_PANEL",
"NO_PANEL_ORIGIN",
"PANEL_FETCH_FAILED",
"STALE_REQUEST",
"STALE_TARGET",
"TIMEOUT",
]);
return known.has(error) ? error : "PANEL_FETCH_FAILED";
}
function responseFailureMessage(response) {
const code = errorMessage(response.error);
throw new PanelTemplateRelayError(code === "PANEL_FETCH_FAILED"
? "The connected panel could not fetch the workflow-template index."
: `The connected panel template relay failed (${code}).`, code);
}
function endpointFromEnv() {
const raw = process.env.COMFYUI_MCP_TEMPLATE_RELAY_URL?.trim();
if (!raw)
return undefined;
try {
const url = new URL(raw);
if (url.protocol !== "http:" ||
url.hostname !== "127.0.0.1" ||
!url.port ||
url.username ||
url.password ||
url.search ||
url.hash ||
url.pathname !== PANEL_TEMPLATE_RELAY_HTTP_PATH)
return undefined;
return url;
}
catch {
return undefined;
}
}
async function readBoundedJson(response, maxBytes) {
const declared = response.headers.get("content-length");
if (declared !== null) {
const length = Number(declared);
if (!Number.isSafeInteger(length) || length < 0 || length > maxBytes) {
throw new PanelTemplateRelayError("The panel template relay response exceeded its safety limit.", "RESPONSE_TOO_LARGE");
}
}
if (!response.body) {
const text = await response.text();
if (Buffer.byteLength(text, "utf8") > maxBytes) {
throw new PanelTemplateRelayError("The panel template relay response exceeded its safety limit.", "RESPONSE_TOO_LARGE");
}
return JSON.parse(text);
}
const reader = response.body.getReader();
const chunks = [];
let total = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done)
break;
total += value.byteLength;
if (total > maxBytes) {
await reader.cancel();
throw new PanelTemplateRelayError("The panel template relay response exceeded its safety limit.", "RESPONSE_TOO_LARGE");
}
chunks.push(Buffer.from(value));
}
}
finally {
reader.releaseLock();
}
return JSON.parse(Buffer.concat(chunks, total).toString("utf8"));
}
async function readResponseText(response, maxBytes) {
const declared = response.headers.get("content-length");
if (declared !== null) {
const length = Number(declared);
if (!Number.isSafeInteger(length) || length < 0 || length > maxBytes)
throw new Error("response too large");
}
if (!response.body) {
const text = await response.text();
if (Buffer.byteLength(text, "utf8") > maxBytes)
throw new Error("response too large");
return text;
}
const reader = response.body.getReader();
const chunks = [];
let total = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done)
break;
total += value.byteLength;
if (total > maxBytes) {
await reader.cancel();
throw new Error("response too large");
}
chunks.push(Buffer.from(value));
}
}
finally {
reader.releaseLock();
}
return Buffer.concat(chunks, total).toString("utf8");
}
async function withinDeadline(promise, deadlineAt) {
const remaining = deadlineAt - Date.now();
if (remaining <= 0)
throw new PanelTemplateRelayError("The panel template relay timed out.", "TIMEOUT");
let timer;
try {
return await Promise.race([
promise,
new Promise((_, reject) => {
timer = setTimeout(() => reject(new PanelTemplateRelayError("The panel template relay timed out.", "TIMEOUT")), remaining);
timer.unref?.();
}),
]);
}
finally {
if (timer)
clearTimeout(timer);
}
}
function writeHttpJson(res, status, value) {
const body = Buffer.from(JSON.stringify(value), "utf8");
res.writeHead(status, {
"content-type": "application/json",
"content-length": String(body.byteLength),
connection: "close",
});
res.end(body);
}
function readRequestBody(req) {
const declared = req.headers["content-length"];
if (declared !== undefined) {
const length = Number(declared);
if (!Number.isSafeInteger(length) || length < 0 || length > PANEL_TEMPLATE_RELAY_MAX_REQUEST_BYTES) {
return Promise.reject(new PanelTemplateRelayError("The relay request exceeded its safety limit.", "REQUEST_TOO_LARGE"));
}
}
return new Promise((resolve, reject) => {
const chunks = [];
let total = 0;
const finish = (error) => {
req.removeListener("data", onData);
req.removeListener("end", onEnd);
req.removeListener("error", onError);
if (error)
reject(error);
else
resolve(Buffer.concat(chunks, total));
};
const onData = (chunk) => {
const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
total += data.byteLength;
if (total > PANEL_TEMPLATE_RELAY_MAX_REQUEST_BYTES) {
req.destroy();
finish(new PanelTemplateRelayError("The relay request exceeded its safety limit.", "REQUEST_TOO_LARGE"));
return;
}
chunks.push(data);
};
const onEnd = () => finish();
const onError = () => finish(new PanelTemplateRelayError("The relay request was interrupted.", "RELAY_UNAVAILABLE", true));
req.on("data", onData);
req.once("end", onEnd);
req.once("error", onError);
});
}
function parsedHttpOrigin(raw) {
if (!raw)
return undefined;
try {
const url = new URL(raw);
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password)
return undefined;
return {
origin: url.origin,
protocol: url.protocol,
host: url.hostname.toLowerCase().replace(/^\[|\]$/g, ""),
port: url.port,
};
}
catch {
return undefined;
}
}
/**
* A panel origin is usable only when it is a loopback listener and corroborates
* the current target. The server-observed Origin is metadata, not authentication:
* a native client can forge it on the tokenless loopback bridge. Keeping the
* fetch destination loopback-only prevents that metadata, or the client-steerable
* active target, from authorizing a remote fetch. URL.origin also canonicalizes
* default ports and host casing.
*/
export function currentPanelTemplateOrigin(panelOrigin, currentTarget) {
const observed = parsedHttpOrigin(panelOrigin);
const target = parsedHttpOrigin(currentTarget);
if (!observed || !target)
return undefined;
const exactLoopbackOrigin = LOOPBACK_HOSTS.has(observed.host) &&
LOOPBACK_HOSTS.has(target.host) &&
observed.protocol === target.protocol &&
observed.port === target.port &&
observed.origin === target.origin;
// An ambiguous NAME is authorized over cleartext only. Over TLS the address
// cannot be pinned without breaking verification of a certificate issued to
// the name, which leaves the fetch re-resolving it -- so `https://localhost`
// stays refused, exactly as it is on main today. Nothing regresses by
// declining to restore it, and the http case is the reported one (#2382).
const ambiguousName = AMBIGUOUS_LOOPBACK_NAMES.has(observed.host) || AMBIGUOUS_LOOPBACK_NAMES.has(target.host);
if (ambiguousName && observed.protocol !== "http:")
return undefined;
return exactLoopbackOrigin ? observed.origin : undefined;
}
function safePanelTemplateUrl(raw, allowedOrigin) {
if (!raw)
return undefined;
try {
const url = new URL(raw);
const parsedUrlOrigin = parsedHttpOrigin(url.origin);
const parsedAllowedOrigin = parsedHttpOrigin(allowedOrigin);
const normalizedAllowedOrigin = parsedAllowedOrigin?.origin;
if ((url.protocol !== "http:" && url.protocol !== "https:") ||
url.username ||
url.password ||
url.search ||
url.hash ||
!url.pathname.endsWith("/api/workflow_templates") ||
!normalizedAllowedOrigin ||
!parsedUrlOrigin ||
!LOOPBACK_HOSTS.has(parsedUrlOrigin.host) ||
!LOOPBACK_HOSTS.has(parsedAllowedOrigin.host) ||
url.origin !== normalizedAllowedOrigin)
return undefined;
return url;
}
catch {
return undefined;
}
}
/**
* Expands a relay URL into the literal-address URLs that will actually be
* fetched, so `fetch` never performs a second, independent name resolution
* (#2382).
*
* A literal host is returned unchanged. An ambiguous NAME is resolved once,
* here, and every resolved address must be loopback — which also closes a hole
* that existed before this function: a hosts-file entry pointing `localhost`
* off-box would previously have been fetched, because only the ORIGIN STRING
* was ever checked against the loopback set, never the address it resolves to.
*/
async function pinnedLoopbackUrls(url) {
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
if (LOOPBACK_LITERALS.has(host))
return [url];
// Only a cleartext ambiguous name gets here: currentPanelTemplateOrigin
// refuses the TLS case, because pinning and certificate verification cannot
// both hold. Re-checked rather than assumed -- this function is what decides
// where a request is actually sent.
if (!AMBIGUOUS_LOOPBACK_NAMES.has(host) || url.protocol !== "http:") {
throw new PanelTemplateRelayError("The panel template relay refused a non-loopback destination.", "NO_PANEL_ORIGIN");
}
let resolved;
try {
resolved = await dnsLookup(host, { all: true, verbatim: true });
}
catch {
throw new PanelTemplateRelayError("The connected panel could not fetch the workflow-template index.", "PANEL_FETCH_FAILED");
}
const literals = [];
for (const { address } of resolved) {
const literal = address.toLowerCase();
// A name that resolves ANYWHERE off loopback is refused whole rather than
// partially honoured. Before pinning, only the ORIGIN STRING was ever
// checked, so a `localhost` pointed off-box by a hosts entry was fetched.
if (!LOOPBACK_LITERALS.has(literal)) {
throw new PanelTemplateRelayError("The panel template relay refused a non-loopback destination.", "NO_PANEL_ORIGIN");
}
if (!literals.includes(literal))
literals.push(literal);
}
if (literals.length === 0) {
throw new PanelTemplateRelayError("The connected panel could not fetch the workflow-template index.", "PANEL_FETCH_FAILED");
}
return literals.map((literal) => {
const pinned = new URL(url.href);
pinned.hostname = literal.includes(":") ? `[${literal}]` : literal;
return pinned;
});
}
/**
* Fetches the template index over pinned literal addresses.
*
* When a name resolves to more than one loopback address we cannot know which
* socket the browser reached — the Origin header does not carry the family, and
* probing cannot tell a dual-stack listener bound to `::` apart from two
* separate processes. So identity is not what we establish. We fetch every
* candidate and require them to AGREE: if each reachable address returns the
* same index, the answer is the same whichever one the panel is on, which is
* the property that actually matters. Disagreement is the real ambiguity, and
* is refused rather than resolved by guessing.
*/
async function fetchPinnedPanelIndex(url, deadlineAt) {
const candidates = await pinnedLoopbackUrls(url);
if (candidates.length === 1)
return fetchPanelIndex(candidates[0], deadlineAt);
const settled = await Promise.all(candidates.map(async (candidate) => {
try {
return { state: "ok", body: await fetchPanelIndex(candidate, deadlineAt) };
}
catch (error) {
// A REFUSED CONNECTION IS THE ONLY SAFE THING TO IGNORE. It proves
// nothing is listening on that address, so it cannot be the panel. Any
// other failure means something IS there and merely answered badly --
// possibly the real panel returning 503 -- and preferring whichever
// address happened to succeed would then serve the OTHER listener's
// index, which is the defect this function exists to prevent.
return isConnectionRefused(error)
? { state: "absent" }
: { state: "failed", error };
}
}));
const present = settled.filter((r) => r.state !== "absent");
if (present.length === 0) {
throw new PanelTemplateRelayError("The connected panel could not fetch the workflow-template index.", "PANEL_FETCH_FAILED");
}
// Exactly one listener exists, so there is nothing for it to disagree with:
// report its outcome verbatim, success or failure.
if (present.length === 1) {
const only = present[0];
if (only.state === "ok")
return only.body;
throw only.error;
}
// More than one listener answered the panel's address. Every one of them must
// have produced the SAME index, or we cannot say what the panel would see.
const bodies = [];
for (const result of present) {
if (result.state !== "ok") {
throw new PanelTemplateRelayError("Another loopback listener answered the panel's address and could not be read.", "AMBIGUOUS_PANEL_LISTENER");
}
bodies.push(result.body);
}
if (bodies.some((body) => body !== bodies[0])) {
throw new PanelTemplateRelayError("Two different loopback listeners answered the panel's address with different template indexes.", "AMBIGUOUS_PANEL_LISTENER");
}
return bodies[0];
}
/** True only for a refused CONNECTION, i.e. nothing is listening on that address. */
function isConnectionRefused(error) {
const seen = new Set();
let current = error;
while (current && typeof current === "object" && !seen.has(current)) {
seen.add(current);
const code = current.code;
// ECONNREFUSED is the loopback case. The rest cover a resolvable address
// with no route -- equally "nothing is listening here".
if (code === "ECONNREFUSED" || code === "EHOSTUNREACH" || code === "ENETUNREACH" || code === "EADDRNOTAVAIL")
return true;
const aggregate = current.errors;
if (Array.isArray(aggregate) && aggregate.some((inner) => isConnectionRefused(inner)))
return true;
current = current.cause;
}
return false;
}
function isUnsupportedPanelTemplateRead(error) {
const message = error instanceof Error ? error.message : String(error ?? "");
return (/operation must be one of/i.test(message) ||
/unknown command/i.test(message) ||
/does not implement/i.test(message) ||
/too old for/i.test(message));
}
function isPanelReadViewingWitness(value) {
if (!value || typeof value !== "object" || Array.isArray(value))
return false;
if (!hasOnlyKeys(value, ["scope", "owner_node_id", "title", "workflow_uuid", "graph_identity"]))
return false;
if (!("scope" in value) || (value.scope !== "root" && value.scope !== "subgraph"))
return false;
if ("owner_node_id" in value) {
const owner = value.owner_node_id;
if (owner !== null &&
!(typeof owner === "number" && Number.isSafeInteger(owner)) &&
!(typeof owner === "string" && isSafeText(owner, 256)))
return false;
}
if ("title" in value && !isSafeText(value.title, 256))
return false;
if ("workflow_uuid" in value && !isSafeText(value.workflow_uuid, 256))
return false;
if ("graph_identity" in value && !isSafeText(value.graph_identity, 256))
return false;
return true;
}
function validatePanelTemplateReadPayload(value) {
if (!value || typeof value !== "object")
return undefined;
const record = value;
if ("viewing" in record && !isPanelReadViewingWitness(record.viewing))
return undefined;
if (!hasOnlyKeys(record, ["operation", "body", "contentType", "bytes", "viewing"]) ||
record.operation !== PANEL_TEMPLATE_READ_OPERATION ||
typeof record.body !== "string" ||
record.body.length === 0 ||
Buffer.byteLength(record.body, "utf8") > PANEL_TEMPLATE_RELAY_MAX_RESPONSE_BYTES ||
(record.contentType !== null &&
(typeof record.contentType !== "string" || !isSafeText(record.contentType, 128))))
return undefined;
const bytes = record.bytes;
if (typeof bytes !== "number" ||
!Number.isSafeInteger(bytes) ||
bytes < 0 ||
bytes > PANEL_TEMPLATE_RELAY_MAX_RESPONSE_BYTES ||
Buffer.byteLength(record.body, "utf8") !== bytes)
return undefined;
let parsed;
try {
parsed = JSON.parse(record.body);
}
catch {
return undefined;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
return undefined;
return record.body;
}
async function fetchPanelNativeIndex(bridge, panelTab, deadlineAt) {
if (typeof bridge.send !== "function") {
throw new PanelTemplateRelayError("The connected panel could not fetch the workflow-template index.", "PANEL_FETCH_FAILED");
}
const remaining = deadlineAt - Date.now();
if (remaining <= 0)
throw new PanelTemplateRelayError("The panel template relay timed out.", "TIMEOUT");
const reply = await bridge.send({ cmd: "fetch_comfyui_read", operation: PANEL_TEMPLATE_READ_OPERATION }, { tabId: panelTab, timeoutMs: Math.min(PANEL_TEMPLATE_RELAY_TIMEOUT_MS, remaining) });
const body = validatePanelTemplateReadPayload(reply);
if (!body) {
throw new PanelTemplateRelayError("The connected panel could not fetch the workflow-template index.", "PANEL_FETCH_FAILED");
}
return body;
}
async function fetchLivePanelIndex(options, panelTab, currentTarget, deadlineAt) {
if (typeof options.bridge.send === "function") {
try {
return await fetchPanelNativeIndex(options.bridge, panelTab, deadlineAt);
}
catch (error) {
// An older panel implements fetch_comfyui_read but not this operation.
// Keep the loopback HTTP fallback rather than failing closed on a
// version skew the child cannot see.
if (!isUnsupportedPanelTemplateRead(error))
throw error;
}
}
const allowedOrigin = options.resolveAllowedPanelOrigin(panelTab, currentTarget);
const panelUrl = safePanelTemplateUrl(options.resolvePanelUrl(panelTab, currentTarget), allowedOrigin);
if (!panelUrl) {
throw new PanelTemplateRelayError("The panel template relay refused a non-loopback destination.", "NO_PANEL_ORIGIN");
}
return fetchPinnedPanelIndex(panelUrl, deadlineAt);
}
async function fetchPanelIndex(url, deadlineAt) {
const remaining = deadlineAt - Date.now();
if (remaining <= 0)
throw new PanelTemplateRelayError("The panel template relay timed out.", "TIMEOUT");
let response;
try {
response = await fetch(url, {
method: "GET",
redirect: "manual",
signal: AbortSignal.timeout(remaining),
});
}
catch (error) {
const failure = new PanelTemplateRelayError("The connected panel could not fetch the workflow-template index.", "PANEL_FETCH_FAILED");
// Preserve the connect-level cause so a multi-address fetch can tell "no
// listener" from "a listener that failed" -- see fetchPinnedPanelIndex.
failure.cause = error;
throw failure;
}
if (response.url) {
const finalUrl = safePanelTemplateUrl(response.url, url.origin);
if (!finalUrl || finalUrl.origin !== url.origin || finalUrl.pathname !== url.pathname) {
throw new PanelTemplateRelayError("The panel template relay refused a response from another origin.", "PANEL_FETCH_FAILED");
}
}
if (response.status >= 300 && response.status < 400) {
throw new PanelTemplateRelayError("The panel template relay refused a redirect.", "PANEL_FETCH_FAILED");
}
if (!response.ok)
throw new PanelTemplateRelayError("The connected panel returned an HTTP error.", "HTTP_ERROR");
const body = await readResponseText(response, PANEL_TEMPLATE_RELAY_MAX_RESPONSE_BYTES);
let parsed;
try {
parsed = JSON.parse(body);
}
catch {
throw new PanelTemplateRelayError("The connected panel returned a non-JSON template index.", "PANEL_FETCH_FAILED");
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new PanelTemplateRelayError("The connected panel returned a malformed template index.", "PANEL_FETCH_FAILED");
}
return body;
}
export async function startPanelTemplateRelayServer(options) {
let active = 0;
const server = createServer((req, res) => {
void (async () => {
if (req.method !== "POST" || req.url !== PANEL_TEMPLATE_RELAY_HTTP_PATH) {
writeHttpJson(res, 404, { ok: false, error: "NOT_FOUND" });
return;
}
if (active >= PANEL_TEMPLATE_RELAY_MAX_CONCURRENT) {
writeHttpJson(res, 429, { ok: false, error: "BACKLOG_FULL" });
return;
}
active += 1;
try {
let body;
try {
body = await readRequestBody(req);
}
catch (error) {
writeHttpJson(res, error instanceof PanelTemplateRelayError && error.code === "REQUEST_TOO_LARGE" ? 413 : 408, {
ok: false,
error: error instanceof PanelTemplateRelayError ? error.code : "MALFORMED_REQUEST",
});
return;
}
let raw;
try {
raw = JSON.parse(body.toString("utf8"));
}
catch {
writeHttpJson(res, 400, { ok: false, error: "MALFORMED_REQUEST" });
return;
}
const requestId = raw && typeof raw === "object" ? raw.requestId : undefined;
const request = typeof requestId === "string" ? validateRequest(raw, requestId) : undefined;
if (!request) {
writeHttpJson(res, 400, { ok: false, error: "MALFORMED_REQUEST" });
return;
}
const auth = options.resolvePanelAgent(request);
if (!auth || !isSecret(auth.secret) || !verifyPanelTemplateRelayCapability(auth.secret, request)) {
writeHttpJson(res, 401, { ok: false, error: "UNAUTHORIZED" });
return;
}
let response;
const now = Date.now();
if (now - request.createdAt < -5_000 || now - request.createdAt > PANEL_TEMPLATE_RELAY_STALE_MS || now >= requestDeadline(request)) {
response = failureResponse(request.requestId, now >= requestDeadline(request) ? "TIMEOUT" : "STALE_REQUEST");
}
else {
const targetAtStart = options.resolveCurrentTarget();
const panelTab = options.resolvePanelTab(auth.agentKey);
const panelReachable = panelTab ? options.bridge.canReach(panelTab) : false;
if (!panelTab || !panelReachable) {
response = failureResponse(request.requestId, options.bridge.resolveFailure?.(auth.agentKey) === "ambiguous" ? "AMBIGUOUS_REQUESTER" : "NO_LIVE_PANEL");
}
else {
try {
const body = await withinDeadline(fetchLivePanelIndex(options, panelTab, targetAtStart.url, requestDeadline(request)), requestDeadline(request));
const targetNow = options.resolveCurrentTarget();
if (targetNow.url !== targetAtStart.url || targetNow.generation !== targetAtStart.generation) {
throw new PanelTemplateRelayError("The ComfyUI target changed while the panel template index was being fetched.", "STALE_TARGET");
}
response = {
version: PANEL_TEMPLATE_RELAY_VERSION,
requestId: request.requestId,
ok: true,
body,
updated: Date.now(),
};
}
catch (error) {
response = failureResponse(request.requestId, error instanceof PanelTemplateRelayError ? error.code : "PANEL_FETCH_FAILED", error instanceof PanelTemplateRelayError && error.code === "TIMEOUT" ? requestDeadline(request) : Date.now());
}
}
}
writeHttpJson(res, 200, addResponseMac(auth.secret, response));
}
catch {
if (!res.headersSent)
writeHttpJson(res, 503, { ok: false, error: "RELAY_UNAVAILABLE" });
}
finally {
active -= 1;
}
})();
});
server.headersTimeout = 2_000;
server.requestTimeout = PANEL_TEMPLATE_RELAY_TIMEOUT_MS + 1_000;
await new Promise((resolve, reject) => {
const onError = (error) => {
server.removeListener("listening", onListening);
reject(error);
};
const onListening = () => {
server.removeListener("error", onError);
resolve();
};
server.once("error", onError);
server.once("listening", onListening);
server.listen({ host: "127.0.0.1", port: 0 });
});
const address = server.address();
if (!address || typeof address === "string" || address.address !== "127.0.0.1" || !address.port) {
await new Promise((resolve) => server.close(() => resolve()));
throw new Error("panel template relay did not bind to IPv4 loopback");
}
return {
endpointUrl: `http://127.0.0.1:${address.port}${PANEL_TEMPLATE_RELAY_HTTP_PATH}`,
close: () => new Promise((resolve) => {
server.close(() => resolve());
server.closeIdleConnections?.();
}),
};
}
/** Child-side request. Undefined means this MCP child has no panel relay. */
export async function requestPanelTemplateIndex() {
const endpoint = endpointFromEnv();
const secret = process.env.COMFYUI_MCP_RELAY_SECRET;
if (!endpoint || !isSecret(secret))
return undefined;
const createdAt = Date.now();
const request = {
version: PANEL_TEMPLATE_RELAY_VERSION,
requestId: `${Date.now().toString(36)}-${process.pid.toString(36)}-${randomBytes(8).toString("hex")}`,
capability: "",
createdAt,
deadlineAt: createdAt + PANEL_TEMPLATE_RELAY_TIMEOUT_MS,
};
request.capability = makePanelTemplateRelayCapability(secret, request);
const body = Buffer.from(JSON.stringify(request), "utf8");
let httpResponse;
try {
httpResponse = await fetch(endpoint, {
method: "POST",
headers: { "content-type": "application/json", "content-length": String(body.byteLength) },
body,
redirect: "error",
signal: AbortSignal.timeout(PANEL_TEMPLATE_RELAY_TIMEOUT_MS),
});
}
catch {
throw new PanelTemplateRelayError("The connected panel template relay is unavailable.", "RELAY_UNAVAILABLE", true);
}
let decoded;
try {
decoded = await readBoundedJson(httpResponse, PANEL_TEMPLATE_RELAY_MAX_RESPONSE_BYTES + 8_192);
}
catch {
throw new PanelTemplateRelayError("The panel template relay returned a malformed reply.", "MALFORMED_REPLY");
}
if (!httpResponse.ok) {
const code = decoded && typeof decoded === "object" && typeof decoded.error === "string"
? String(decoded.error)
: "RELAY_UNAVAILABLE";
throw new PanelTemplateRelayError("The connected panel template relay is unavailable.", code, httpResponse.status >= 500);
}
const response = validateResponse(decoded, request.requestId, secret);
// `undefined` is reserved for a child with no authenticated relay environment.
// Once a relay is configured, an unauthorizable panel origin is a refusal, not
// permission to fall through to getComfyUIBaseUrl(): during a target retarget
// that URL can still name stale target A while the relay has already observed
// target B. Keep this failure typed so the list_packs caller returns an honest
// unknown/error result without issuing a second fetch.
if (response.ok === false)
responseFailureMessage(response);
const age = Date.now() - response.updated;
if (response.updated < request.createdAt ||
response.updated > request.deadlineAt ||
age < -5_000 ||
age > PANEL_TEMPLATE_RELAY_STALE_MS)
throw new PanelTemplateRelayError("The panel template relay returned a stale reply.", "STALE_REPLY");
let index;
try {
index = JSON.parse(response.body);
}
catch {
throw new PanelTemplateRelayError("The panel template relay returned invalid JSON.", "MALFORMED_REPLY");
}
if (!index || typeof index !== "object" || Array.isArray(index)) {
throw new PanelTemplateRelayError("The panel template relay returned a malformed index.", "MALFORMED_REPLY");
}
return index;
}
//# sourceMappingURL=panel-template-relay.js.map