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),

273 lines • 13.2 kB
// 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