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),
179 lines • 7.54 kB
JavaScript
// Choosing a validated local CONNECTED-PANEL origin to retry a failed headless
// read against (#2149).
//
// The browser handshake is useful provenance, but a server-observed origin can
// still be a remote/tunnel/LAN page. This module is deliberately stricter than
// canonicalOrigin: only an HTTP(S) origin with no credentials, path, query, or
// fragment, and only loopback hosts, is eligible for direct MCP contact.
import { canonicalOrigin } from "../utils/origin.js";
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
/** Parse an HTTP(S) URL and return its origin, rejecting credentials and fragments. */
export function httpOriginOf(raw, allowPath = true, allowQuery = true) {
if (typeof raw !== "string" || raw.trim() === "")
return undefined;
try {
const parsed = new URL(raw.trim());
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
return undefined;
if (parsed.username || parsed.password || (!allowQuery && (parsed.hash || parsed.search)))
return undefined;
if (!allowPath && parsed.pathname !== "/")
return undefined;
return parsed.origin === "null" ? undefined : parsed.origin;
}
catch {
return undefined;
}
}
/** Normalize a candidate panel origin, with no path/query/userinfo allowed. */
export function normalizePanelOrigin(raw) {
return httpOriginOf(raw, false, false);
}
/** Only loopback origins are safe for direct MCP contact in this fallback. */
export function isLoopbackPanelOrigin(origin) {
try {
const host = new URL(origin).hostname.toLowerCase().replace(/^\[|\]$/g, "");
return LOOPBACK_HOSTS.has(host);
}
catch {
return false;
}
}
/** The MCP target and fallback candidate must both be local loopback origins. */
export function isSafePanelOriginForMcp(failedTarget, candidate) {
const targetOrigin = httpOriginOf(failedTarget);
const candidateOrigin = normalizePanelOrigin(candidate);
return (targetOrigin !== undefined &&
candidateOrigin !== undefined &&
isLoopbackPanelOrigin(targetOrigin) &&
isLoopbackPanelOrigin(candidateOrigin));
}
/** Choose exactly one validated local panel origin different from the failed target. */
export function choosePanelFallbackOrigin(failedTarget, origins) {
const targetOrigin = httpOriginOf(failedTarget);
if (targetOrigin === undefined || !isLoopbackPanelOrigin(targetOrigin))
return { kind: "none" };
const byCanonical = new Map();
let invalidCount = 0;
for (const raw of origins) {
const origin = normalizePanelOrigin(raw);
if (origin === undefined || !isSafePanelOriginForMcp(failedTarget, origin)) {
invalidCount++;
continue;
}
const canon = canonicalOrigin(origin);
if (canon === undefined) {
invalidCount++;
continue;
}
if (!byCanonical.has(canon))
byCanonical.set(canon, origin);
}
// A valid origin must never survive beside malformed, unsupported, or remote
// input. That would turn a mixed trust set into a silently selected target.
if (invalidCount > 0)
return { kind: "invalid", count: invalidCount };
if (byCanonical.size === 0)
return { kind: "none" };
const targetCanonical = canonicalOrigin(targetOrigin);
if (targetCanonical === undefined)
return { kind: "none" };
const different = [];
let same;
for (const [canon, origin] of byCanonical) {
if (canon === targetCanonical)
same = origin;
else
different.push(origin);
}
if (different.length === 0)
return same ? { kind: "same", origin: same } : { kind: "none" };
if (different.length > 1)
return { kind: "ambiguous", origins: different };
return { kind: "use", origin: different[0] };
}
/** Explain why a panel set was not retried. */
export function describeDeclinedPanelFallback(choice) {
if (choice.kind === "ambiguous") {
return (` I did NOT retry against a connected panel: ${choice.origins.length} different local ` +
`ComfyUI origins are connected (${choice.origins.join(", ")}), and choosing one of ` +
`them would risk answering from a server you did not mean. Point COMFYUI_URL at the one you want.`);
}
if (choice.kind === "invalid") {
return (` I did NOT retry against a connected panel: ${choice.count} connected origin` +
`${choice.count === 1 ? " was" : "s were"} malformed, unsupported, remote, or otherwise ` +
`unsafe for direct MCP contact.`);
}
return "";
}
const UNDEFINED_API_BASE_RE = /Cannot read propert(?:y|ies) of undefined \(reading ['"]?api_base['"]?\)|Cannot read property ['"]?api_base['"]? of undefined/i;
/**
* Resolve the connected panel origin and API base before a fallback read.
* Mixed, malformed, or missing-prefix sets stay unproven so a guessed
* origin is never contacted. Loopback aliases of one host collapse to one.
*/
export function resolvePanelReadOrigin(origins, apiBase) {
if (origins.length === 0)
return { kind: "unknown" };
if (typeof apiBase !== "string")
return { kind: "unproven" };
const proven = [];
const canons = new Set();
for (const raw of origins) {
const origin = normalizePanelOrigin(raw);
if (origin === undefined)
return { kind: "unproven" };
const canon = canonicalOrigin(origin);
if (canon === undefined)
return { kind: "unproven" };
canons.add(canon);
proven.push(origin);
}
if (proven.length === 0 || canons.size !== 1)
return { kind: "unproven" };
return { kind: "proven", origin: proven[0], apiBase };
}
/**
* #2839 — the compact comfyui child may list templates from its configured
* target only when that target is the unique published panel origin. Mixed,
* malformed, or empty sets stay unproven so a guessed URL is never contacted.
*/
export function provenPanelOriginMatchesConfiguredTarget(origins, configuredTarget) {
const targetOrigin = httpOriginOf(configuredTarget);
const targetCanon = targetOrigin ? canonicalOrigin(targetOrigin) : undefined;
if (!targetCanon || origins.length === 0)
return false;
const canons = new Set();
for (const raw of origins) {
const origin = normalizePanelOrigin(raw);
if (origin === undefined)
return false;
const canon = canonicalOrigin(origin);
if (canon === undefined)
return false;
canons.add(canon);
}
return canons.size === 1 && canons.has(targetCanon);
}
/** True when the panel-side crash was `undefined.api_base`, not a transport result. */
export function isUndefinedApiBaseFailure(error) {
const parts = [];
let current = error;
const seen = new Set();
while (current !== undefined && current !== null && !seen.has(current)) {
seen.add(current);
if (typeof current === "string") {
parts.push(current);
break;
}
if (!(current instanceof Error))
break;
parts.push(current.message);
if ("reason" in current && typeof current.reason === "string") {
parts.push(current.reason);
}
current = current.cause;
}
return parts.some((part) => UNDEFINED_API_BASE_RE.test(part));
}
//# sourceMappingURL=panel-fallback-target.js.map