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),
273 lines • 13.2 kB
JavaScript
// The connected ComfyUI's SAVED workflow library, read through its userdata API.
//
// #810: `get_workflow (action:"list")` asked for `/api/userdata?dir=workflows` — a SHALLOW read —
// and reported the resulting empty array as "No saved workflows found." A user whose
// six workflows all live in `IMAGE/` and `VIDEO/MiniMaxH3/` (the folder tree the
// ComfyUI web UI sidebar shows) was told their library was empty. That is the repo's
// dominant defect class: "could not determine X" rendered as "determined X is not the
// case", and here it is worse than slow — it sends the user to recreate a workflow
// they already have.
//
// One listing read, in one place, so a second caller cannot re-introduce the shallow
// spelling: panel_load_workflow's resolver already used `recurse=true` (it was fixed
// for #202) while get_workflow (action:"list") did not, which is exactly the drift a shared helper
// removes.
import { getClient } from "../comfyui/client.js";
import { connectedPanelOriginsNow } from "../comfyui/fetch.js";
import { getComfyUIBasePath } from "../config.js";
import { describeFetchFailure } from "../utils/errors.js";
/**
* The listing route for the workflow library.
*
* `recurse=true` is VERIFIED against the installed ComfyUI (app/user_manager.py
* `listuserdata`): with it the glob is `<dir>/**\/*` and a workflow in a SUBFOLDER
* comes back as its store-relative path ("VIDEO/MiniMaxH3/x.json"); without it the
* glob is `<dir>/*` and that file is simply absent. Root entries stay bare either way,
* so the recursive listing is a strict superset — nothing that used to be listed stops
* being listed.
*
* `split=false` is explicit rather than implied. The endpoint switches shape on
* `split=true` (each entry becomes `[rel_path, ...segments]`), and its default is
* "absent means false"; naming it means a build that ever flipped that default cannot
* silently turn every entry into an array.
*
* There is no "the server ignored `recurse`" case to version-gate against, and that is
* a checked fact rather than an assumption (codex gate MAJOR asked for the proof): the
* `/userdata` listing route and its `recurse` parameter arrived in the SAME ComfyUI
* commit as the workflow library itself — 90aebb6c, "New Menu & Workflow Management"
* (#3112, 2024-06-25), present from tag v0.0.1 onward. A build old enough to ignore
* `recurse` has no `workflows` userdata library to list, so it answers 404 and takes
* the `absent` path, never the empty-list one. The empty-list message still states that
* dependency rather than asserting subfolder coverage outright, because something OTHER
* than that ComfyUI answering this route is the one way the ground could be false.
*/
export const WORKFLOW_LIBRARY_LISTING_ROUTE = "/api/userdata?dir=workflows&recurse=true&split=false";
/**
* Issue ONE userdata GET and hand back the raw Response.
*
* This deliberately bypasses the client's own `fetchApi` (panel #202, codex/gate
* MAJOR). `fetchApi` throws for every non-2xx AND calls `response.json()` while
* building that error — so a refusal with a non-JSON body (ComfyUI's own `403 Invalid
* directory` / `404 Directory not found` are text/plain, as is a proxy's 403 or an
* HTML 502) rejects with a STATUSLESS SyntaxError. There is then no way to tell "the
* server refused" from "the server was never reached", and both get reported as
* whatever the caller's catch-all says.
*
* Building the request from the client's own `apiURL` + `apiHeaders` keeps every
* transport concern identical (host, ssl, base path, clientId, Comfy-User, the
* injected COMFYUI_AUTH_* fetch) while making the outcome unambiguous:
* - a returned Response = the server ANSWERED; classify by status, decode later
* - a thrown error = no Response exists, so the request never got one
* Nothing here reads a body, so a body that cannot be decoded can never be mistaken
* for a transport failure.
*
* #1845 — a confirmed ComfyUI restart can still leave the next userdata GET
* racing the listener (ECONNREFUSED on 127.0.0.1:8188 while the panel tab is
* already talking to the same server). Transient connection failures retry a
* bounded number of times. After the headless URL is exhausted, the live
* connected-panel origins are tried: the optional tab origin, then the
* published reconnect set (`connectedPanelOriginsNow`), then the loopback
* alias (127.0.0.1 ↔ localhost). Those are different TCP endpoints on Windows
* even when they name one ComfyUI, and the browser origin is the one that just
* proved reachable. The #1850 pass only retried `opts.panelOrigin` when its
* string differed from the headless URL, so a same-spelling 8188 or a missing
* tab origin after restart never left the dead default.
*/
export async function userdataFetch(route, opts) {
const client = getClient();
const headers = client.apiHeaders();
const urls = userdataRetryUrls(client.apiURL(route), route, opts?.panelOrigin);
let lastErr;
const alias = loopbackAliasUrl(urls[0] ?? "");
for (let i = 0; i < urls.length; i++) {
const url = urls[i];
if (!url)
continue;
// Alternates are one-shot with a short ceiling. The headless URL already
// spent the restart-race retries; re-waiting 200/600/1500ms per alias
// (and letting localhost sit on the 120s comfyuiFetch ceiling) is what
// hung GRAPH_CMD_EFFECT's tool-surface probe on Windows after #1850 —
// 127.0.0.1 refuses immediately, then ::1/localhost black-holes.
const fallback = i > 0;
const ceilingMs = url === alias ? LOOPBACK_ALIAS_TIMEOUT_MS : FALLBACK_TIMEOUT_MS;
for (let attempt = 0;; attempt++) {
try {
return await client.fetch(url, {
headers,
...(fallback ? { signal: AbortSignal.timeout(ceilingMs) } : {}),
});
}
catch (err) {
lastErr = err;
if (fallback)
break;
if (!isTransientUnreachable(err))
throw err;
if (attempt >= retryDelaysMs.length)
break;
await sleepImpl(retryDelaysMs[attempt]);
}
}
}
throw lastErr;
}
/** Headless URL first, then every distinct live-origin spelling of the same route. */
function userdataRetryUrls(primary, route, panelOrigin) {
const urls = [];
const seen = new Set();
const add = (url) => {
if (!url || seen.has(url))
return;
seen.add(url);
urls.push(url);
};
add(primary);
add(panelOrigin?.trim() ? userdataUrlAtOrigin(panelOrigin.trim(), route) : undefined);
for (const origin of connectedPanelOriginsNow()) {
add(userdataUrlAtOrigin(origin, route));
}
add(loopbackAliasUrl(primary));
return urls;
}
/** Connection-level failures that a just-restarted listener commonly produces. */
function isTransientUnreachable(err) {
const { code, message } = describeFetchFailure(err);
return /ECONNREFUSED|ECONNRESET|ETIMEDOUT|UND_ERR_CONNECT_TIMEOUT|EPIPE/i.test(`${code ?? ""} ${message}`);
}
function userdataUrlAtOrigin(origin, route) {
let basePath = "";
try {
basePath = getComfyUIBasePath().replace(/\/$/, "");
}
catch {
basePath = "";
}
const path = route.startsWith("/") ? route : `/${route}`;
return `${origin.replace(/\/$/, "")}${basePath}${path}`;
}
/**
* 127.0.0.1 and localhost are one ComfyUI by origin identity, and different
* sockets on Windows (IPv4 vs IPv6). After a restart the browser often reaches
* the live spelling while the headless client is still pinned to 8188's other
* form. A relative apiURL (tests, misconfigured client) is not a URL, so there
* is no alias to try.
*/
function loopbackAliasUrl(url) {
try {
const parsed = new URL(url);
const host = parsed.hostname.toLowerCase();
if (host === "127.0.0.1")
parsed.hostname = "localhost";
else if (host === "localhost")
parsed.hostname = "127.0.0.1";
else
return undefined;
return parsed.href;
}
catch {
return undefined;
}
}
const DEFAULT_RETRY_DELAYS_MS = [200, 600, 1500];
/** Published/tab origin GET. A live listener answers in milliseconds. */
const FALLBACK_TIMEOUT_MS = 2_000;
/** Dual-stack localhost can black-hole for the full comfyuiFetch ceiling.
* Keep this tight: several panel tools reach userdataFetch, and 2s each
* stacked past the 30s GRAPH_CMD_EFFECT probe. */
const LOOPBACK_ALIAS_TIMEOUT_MS = 400;
let retryDelaysMs = DEFAULT_RETRY_DELAYS_MS;
let sleepImpl = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export const __userdataLibraryTestHooks = {
setRetryDelays(delays) {
retryDelaysMs = delays ?? DEFAULT_RETRY_DELAYS_MS;
},
setSleep(fn) {
sleepImpl = fn ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
},
};
/** Normalize one listing entry to a store-relative key, or null when it is not one. */
function entryKey(entry) {
// The default shape on every build we have seen: a bare relative path.
if (typeof entry === "string")
return entry || null;
// `split=true` shape, in case a build ever returns it despite `split=false`:
// [rel_path, ...segments] — element 0 is the same key the default shape returns.
if (Array.isArray(entry))
return typeof entry[0] === "string" && entry[0] ? entry[0] : null;
// `full_info=true` shape: { path, size, modified }.
const rec = entry;
if (rec && typeof rec.path === "string" && rec.path)
return rec.path;
if (rec && typeof rec.name === "string" && rec.name)
return rec.name;
return null;
}
/**
* The connected ComfyUI's OWN list of saved workflow store keys — asked, never
* reconstructed, so it reflects the server's runtime `--user-directory`.
*
* Keys are store-relative and INCLUDE any subfolder ("VIDEO/MiniMaxH3/x.json"). They
* are used verbatim: nothing is stripped, case-folded or separator-folded here,
* because a depth-0 request must only ever match a depth-0 entry (panel #202 gate
* MAJOR) and because those keys are what `get_workflow`'s get/analyze/query
* actions take as `filename`.
*
* Never throws.
*/
export async function listWorkflowLibraryKeys() {
let res;
try {
res = await userdataFetch(WORKFLOW_LIBRARY_LISTING_ROUTE);
}
catch (err) {
return {
ok: false,
kind: "unreachable",
// What was observed is that NO Response came back — not that the request failed to
// arrive (codex gate MAJOR). A reply lost after ComfyUI had already listed the
// directory looks identical from here, and for a read that distinction changes
// nothing except whether the sentence is true.
detail: `no response came back (${err instanceof Error ? err.message : String(err)}), so this call learned nothing about the library either way`,
};
}
if (!res.ok) {
// 404 is ComfyUI's answer for "that directory does not exist". What it PROVES is
// that the directory is not there NOW — not the historical claim that nothing was
// ever saved (codex gate MAJOR), and not that this request even reached the
// listing handler rather than a proxy or a mismatched base path. So it is kept
// separate from a refusal, and the caller words it as an observation with its
// caveat rather than as a verdict on the user's library.
return res.status === 404
? { ok: false, kind: "absent", detail: "the server answered HTTP 404 for its `workflows` directory" }
: { ok: false, kind: "refused", detail: `the server answered HTTP ${res.status}` };
}
let body;
try {
body = await res.json();
}
catch (err) {
return {
ok: false,
kind: "undecodable",
detail: `the listing body did not parse as JSON (${err instanceof Error ? err.message : String(err)})`,
};
}
if (!Array.isArray(body)) {
return { ok: false, kind: "undecodable", detail: "the listing was not a JSON array" };
}
const decoded = body.map(entryKey);
const keys = decoded.filter((k) => k != null);
// An entry in a shape none of the known ComfyUI response forms produce is a workflow
// this call cannot name. Silently dropping it is how a listing becomes a lie by
// omission — and a listing of ONLY such entries would otherwise read as an empty
// library. Count them so the caller can say the list may be short.
return {
ok: true,
keys,
unreadable: decoded.length - keys.length,
// Positive evidence only: a name carrying a separator is the response demonstrating
// it recursed. Anything else — including an empty list — leaves it unproven.
recursionProven: keys.some((k) => k.includes("/")),
};
}
//# sourceMappingURL=userdata-library.js.map