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),
475 lines • 24 kB
JavaScript
import { getObjectInfo } from "../comfyui/client.js";
import { getComfyUIBaseUrl } from "../config.js";
import { comfyuiFetch } from "../comfyui/fetch.js";
import { ComfyUIError } from "../utils/errors.js";
import { logger } from "../utils/logger.js";
import { detectManagerApi, enqueueManagerTaskForExternal, managerApiPrefixFor, startManagerQueueForExternal, } from "./node-management.js";
import { targetsPanelPackExactly, withPanelPinGuard } from "./panel-pin-guard.js";
import { MANAGER_CATALOGUE_CURRENCY_CAVEAT, managerCatalogueCurrencyUnverified, } from "./manager-catalogue-currency.js";
import { extractWorkflowClassTypes } from "./api-nodes.js";
const managerBase = () => getComfyUIBaseUrl();
/**
* Minimal local fetch wrapper for ComfyUI-Manager endpoints.
* Kept inside this service per project convention (no shared manager-client).
*/
async function managerFetch(path, init, base = managerBase()) {
const url = `${base}${path}`;
logger.debug("Manager API request", { url, method: init?.method ?? "GET" });
let res;
try {
res = await comfyuiFetch(url, init);
}
catch (err) {
throw new ComfyUIError(`Failed to reach ComfyUI-Manager at ${url}: ${err instanceof Error ? err.message : err}. ` +
`Ensure ComfyUI is running and ComfyUI-Manager is installed.`, "MANAGER_UNREACHABLE");
}
if (!res.ok) {
// unknown-ok: "" is interpolated into an ERROR MESSAGE and nothing else — the
// HTTP status is reported either way, so an unreadable body costs detail in the
// text, never a wrong conclusion. Verified there is no branch on this value.
const body = await res.text().catch(() => "");
throw new ComfyUIError(`ComfyUI-Manager ${path} returned ${res.status} ${res.statusText}`, "MANAGER_ERROR", { url, status: res.status, body: body.slice(0, 500) });
}
return res;
}
/** Default dependency wiring backed by live HTTP + the ComfyUI client. */
export function defaultWorkflowDepsDeps() {
// installWorkflowDependencies invokes reset → N enqueues → start → status as
// one Manager transaction. Keep that entire sequence on the target selected
// by reset, including a dialect self-heal, so a panel retarget cannot split
// a queue across two ComfyUI instances (#670).
let queueOperation;
return {
fetchObjectInfo: () => getObjectInfo(),
fetchManagerMappings: async () => {
const base = managerBase();
const api = await detectManagerApi(base);
const res = await managerFetch(`${managerApiPrefixFor(api)}/customnode/getmappings?mode=nickname`, undefined, base);
return (await res.json());
},
fetchManagerList: async () => {
// skip_update=true avoids slow per-pack git checks; we only need metadata.
const base = managerBase();
const api = await detectManagerApi(base);
// v4 deliberately dropped getlist; its registry-first install task does
// not need the legacy catalog descriptor. Keep the legacy list path for
// 3.x and let callers resolve against Manager mappings on v4.
if (api !== "legacy")
return { packs: [], directInstall: true };
const res = await managerFetch("/customnode/getlist?mode=cache&skip_update=true", undefined, base);
const data = (await res.json());
if (Array.isArray(data))
return { packs: data };
const packs = data.node_packs ?? {};
return {
channel: data.channel,
// Fold the dict key into each entry's id when missing.
packs: Object.entries(packs).map(([key, p]) => ({ id: p.id ?? key, ...p })),
};
},
queueInstall: async (pack, channel) => {
// A plain/non-registry pack (git URL, no registry version) must route on
// version === "unknown"; a registry pack installs its catalog version.
const isUnknown = !pack.version || pack.version === "unknown";
const base = queueOperation?.base ?? managerBase();
const used = await enqueueManagerTaskForExternal("install", {
id: pack.id,
version: isUnknown ? "unknown" : pack.version,
selected_version: pack.active_version ?? (isUnknown ? undefined : pack.version),
repository: pack.reference,
files: pack.files,
channel: pack.channel ?? channel,
mode: pack.mode ?? "cache",
ui_id: pack.id ?? pack.title ?? pack.reference,
}, base);
if (queueOperation)
queueOperation.api = used;
},
resetQueue: async () => {
const base = managerBase();
const api = await detectManagerApi(base);
await managerFetch(`${managerApiPrefixFor(api)}/manager/queue/reset`, { method: "POST" }, base);
queueOperation = { base, api };
},
startQueue: async () => {
const base = queueOperation?.base ?? managerBase();
const api = queueOperation?.api ?? await detectManagerApi(base);
// Some legacy Manager 3.x builds expose /manager/queue/start as GET-only,
// returning HTTP 405 to our POST (#551). A 405 on a Manager route is a
// METHOD mismatch for this endpoint, not an unreachable Manager — retry the
// same path with GET before failing so GET-only builds still start. Guard
// the GET against ComfyUI's frontend catchall, which 200s an UNREGISTERED
// GET with a page of HTML: that HTML is NOT a real queue start (codex
// review), so treat it as the route not accepting our request.
await startManagerQueueForExternal(api, base);
},
queueStatus: async () => {
const base = queueOperation?.base ?? managerBase();
const api = queueOperation?.api ?? await detectManagerApi(base);
try {
const res = await managerFetch(`${managerApiPrefixFor(api)}/manager/queue/status`, undefined, base);
return (await res.json());
}
finally {
queueOperation = undefined;
}
},
};
}
/**
* Collect the distinct, sorted class_types referenced by a workflow. Handles
* both the API format (object keyed by node id, each `{ class_type }`) and the
* UI/"full" format (a `nodes` array whose entries carry a `type` field).
*
* Subgraph-aware via extractWorkflowClassTypes: a UI node whose `type` is a
* subgraph definition id is an instance, not a class_type, and inner nodes
* from `definitions.subgraphs[].nodes` are walked instead.
*/
export function collectClassTypes(workflow) {
return extractWorkflowClassTypes(workflow).sort();
}
/**
* Determine whether a python_module string denotes a core/built-in node
* rather than a custom node pack. ComfyUI reports built-ins as `nodes` or
* `comfy_extras(.*)`; custom packs are `custom_nodes.<pack_dir>`.
*/
function packFromPythonModule(pythonModule) {
if (!pythonModule)
return { builtin: false, pack: null };
if (pythonModule === "nodes" || pythonModule.startsWith("comfy_extras")) {
return { builtin: true, pack: null };
}
const prefix = "custom_nodes.";
if (pythonModule.startsWith(prefix)) {
// custom_nodes.<pack_dir>.<maybe.submodule> -> take the pack_dir segment.
const rest = pythonModule.slice(prefix.length);
const packDir = rest.split(".")[0];
return { builtin: false, pack: packDir || null };
}
// Unknown module form: treat as a non-builtin pack named by its root segment.
return { builtin: false, pack: pythonModule.split(".")[0] || null };
}
/** Build a class_type -> pack lookup from Manager mappings (incl. regex patterns). */
function buildMappingIndex(mappings) {
const exact = new Map();
const patterns = [];
for (const [repoOrId, value] of Object.entries(mappings)) {
if (!Array.isArray(value))
continue;
const [classNames, meta] = value;
const pack = (meta && meta.title) || repoOrId;
if (Array.isArray(classNames)) {
for (const cn of classNames) {
if (typeof cn === "string" && !exact.has(cn))
exact.set(cn, pack);
}
}
const pattern = meta?.nodename_pattern;
if (typeof pattern === "string" && pattern) {
try {
patterns.push({ re: new RegExp(pattern), pack });
}
catch {
// Ignore malformed patterns from the Manager DB.
}
}
}
return { exact, patterns };
}
function resolveFromMappings(classType, index) {
const exact = index.exact.get(classType);
if (exact)
return exact;
for (const { re, pack } of index.patterns) {
if (re.test(classType))
return pack;
}
return null;
}
/**
* Extract the custom node packs a workflow depends on.
*
* Works in remote mode (no local path) since it relies solely on HTTP:
* `/object_info` for installed-node detection and Manager `/getmappings`
* for the class_type -> pack mapping (which also covers uninstalled packs).
*/
export async function extractWorkflowDependencies(workflow, deps) {
const classTypes = collectClassTypes(workflow);
const objectInfo = await deps.fetchObjectInfo();
// Manager mappings are best-effort: extraction must still work if Manager
// is absent, falling back to object_info's python_module for installed nodes.
let mappingIndex = {
exact: new Map(),
patterns: [],
};
// #1136 — this catch used to be the whole story: log at warn, carry on, and
// let every unmapped class_type render as "neither installed nor known to
// ComfyUI-Manager". We KNOW Manager was never consulted -- we are holding the
// exception -- and we asserted absence anyway. A warn line is not a user-
// facing answer; the caller reads the tool result.
let mappingsUnavailable;
try {
const raw = await deps.fetchManagerMappings();
mappingIndex = buildMappingIndex(raw);
if (mappingIndex.exact.size === 0 && mappingIndex.patterns.length === 0) {
// A 200 carrying nothing is the same situation with no exception to hold:
// Manager answered, but with no mappings to match against.
// Distinguish "the response was empty" from "we could not read it".
// buildMappingIndex skips any entry whose value is not an Array, so a v4
// shape difference yields an empty index from a NON-empty body -- and
// calling that "came back EMPTY" asserts something about the response we
// never checked, which is this issue's own defect class one endpoint over.
const empty = !raw || typeof raw !== "object" || Object.keys(raw).length === 0;
mappingsUnavailable = empty
? "The ComfyUI-Manager node mappings came back EMPTY, so nothing below was matched against " +
"the catalogue. This is NOT evidence that these node types are unknown to Manager."
: "The ComfyUI-Manager node mappings response carried no usable entries, so nothing below " +
"was matched against the catalogue. This is NOT evidence that these node types are " +
"unknown to Manager.";
}
}
catch (err) {
logger.warn("ComfyUI-Manager mappings unavailable; relying on /object_info only", {
error: err instanceof Error ? err.message : String(err),
});
mappingsUnavailable =
`The ComfyUI-Manager node mappings could not be fetched (${err instanceof Error ? err.message : String(err)}), ` +
`so nothing below was matched against the catalogue. This is NOT evidence that these node types ` +
`are unknown to Manager -- only /object_info was consulted. Manager reaches the registry from the ` +
`ComfyUI host, so a blocked or filtered network there looks exactly like "not found" here.`;
}
const dependencies = [];
for (const classType of classTypes) {
const def = objectInfo[classType];
const installed = Boolean(def);
if (def) {
const { builtin, pack } = packFromPythonModule(def.python_module);
if (builtin) {
dependencies.push({ class_type: classType, pack: null, builtin: true, installed: true, source: "object_info" });
continue;
}
// Installed custom node: prefer Manager's friendly pack name when available,
// otherwise use the python_module-derived directory name.
const mappedPack = resolveFromMappings(classType, mappingIndex);
dependencies.push({
class_type: classType,
pack: mappedPack ?? pack,
builtin: false,
installed: true,
source: mappedPack ? "manager_mappings" : "object_info",
});
continue;
}
// Not installed: only the Manager mapping can tell us the owning pack.
const mappedPack = resolveFromMappings(classType, mappingIndex);
dependencies.push({
class_type: classType,
pack: mappedPack,
builtin: false,
installed: false,
source: mappedPack ? "manager_mappings" : "unresolved",
});
}
const requiredPackSet = new Set();
const missingPackSet = new Set();
const unresolved = [];
for (const dep of dependencies) {
if (dep.builtin)
continue;
if (dep.pack) {
requiredPackSet.add(dep.pack);
if (!dep.installed)
missingPackSet.add(dep.pack);
}
else if (!dep.installed) {
unresolved.push(dep.class_type);
}
}
return {
classTypes,
dependencies,
requiredPacks: [...requiredPackSet].sort(),
missingPacks: [...missingPackSet].sort(),
unresolved: unresolved.sort(),
...(mappingsUnavailable && unresolved.length > 0
? { mappings_unavailable: mappingsUnavailable }
: {}),
// panel#890 — the third state. The two caveats above fire on an OBSERVED failure
// (empty list, caught exception); a catalogue served from Manager's bundled copy
// presents as success, so neither fires and `unresolved` used to go out bare.
...(managerCatalogueCurrencyUnverified({
unresolvedCount: unresolved.length,
mappingsUnavailable,
})
? { catalogue_currency_unverified: MANAGER_CATALOGUE_CURRENCY_CAVEAT }
: {}),
};
}
/**
* Resolve and install the node packs a workflow needs via ComfyUI-Manager.
*
* Installs go through the Manager HTTP queue, which runs server-side on the
* ComfyUI instance this MCP server is connected to (local OR a remote
* --comfyui-url target). It does NOT depend on a local filesystem path — the
* local install dir is irrelevant to where Manager writes packs.
*/
export async function installWorkflowDependencies(workflow, deps) {
const analysis = await extractWorkflowDependencies(workflow, deps);
if (analysis.missingPacks.length === 0) {
return {
installed: [],
alreadyInstalled: analysis.requiredPacks,
unresolved: analysis.unresolved,
// panel#890 (codex round 2, P1) — this early return emits `unresolved` too, and
// it used to emit it BARE. Nothing is installed on this path, so it reads as the
// most settled answer of the three, and "not found in ComfyUI-Manager" with no
// qualification is exactly the reading the caveat exists to prevent. Carried over
// from the analysis rather than recomputed: the analysis is where the catalogue
// was actually consulted.
...(analysis.catalogue_currency_unverified
? { catalogue_currency_unverified: analysis.catalogue_currency_unverified }
: {}),
// The STRONGER caveat too (codex round 4). The currency one yields to it, so
// dropping it here left this path with neither.
...(analysis.mappings_unavailable
? { mappings_unavailable: analysis.mappings_unavailable }
: {}),
};
}
// A dependency install can name the panel exactly like a generic node tool.
// Hold the shared guard across the entire queue transaction whenever that is
// true: a one-off assert here would reopen the same check-then-queue race the
// generic mutation services fixed. Non-panel workflows intentionally do not
// take this lock, so ordinary dependency installs do not contend with pins.
const panelTarget = analysis.missingPacks.find(targetsPanelPackExactly);
if (panelTarget) {
return withPanelPinGuard("install workflow dependencies including", panelTarget, () => installWorkflowDependenciesForAnalysis(analysis, deps));
}
return installWorkflowDependenciesForAnalysis(analysis, deps);
}
export async function installWorkflowDependenciesForAnalysis(analysis, deps) {
// Match missing packs to concrete Manager list entries for install payloads,
// capturing the channel the list resolved against.
const { channel = "default", packs, directInstall = false } = await deps.fetchManagerList();
// #1136 — an EMPTY legacy catalogue is not "these packs do not exist".
//
// Manager's /customnode/getlist returns dict(channel, node_packs) with no
// error and no staleness field, and it is called with mode=cache +
// skip_update=true, so a user whose registry is blocked or filtered gets a
// healthy HTTP 200 carrying an empty cache. We cannot see their DNS failure:
// it happened inside ComfyUI's process.
//
// What we CAN see is that a healthy legacy catalogue carries thousands of
// entries, so zero on a non-local channel is a strong local signal. Without
// this, every pack falls to `unresolved` and renders as "neither installed
// nor known to ComfyUI-Manager" / "Not found in ComfyUI-Manager" -- the
// reported harm verbatim, in the surface the reporting user was sent to
// three times.
//
// Deliberately NOT phrased as a diagnosis of their network. We do not know
// it is blocked; we know the catalogue is empty and that this is not the
// same fact as absence.
const catalogueEmpty = !directInstall && channel !== "local" && packs.length === 0;
const byKey = new Map();
for (const p of packs) {
for (const key of [p.id, p.title, p.reference]) {
if (key && !byKey.has(key))
byKey.set(key, p);
}
}
const toInstall = [];
const installed = [];
const unresolved = [...analysis.unresolved];
// The sidebar panel pack NEVER goes through this transaction. The Manager
// target resolved for a workflow dependency may differ from the local root
// used to establish which panel the browser serves; accepting either one as
// proof would permit an unverified update or fabricate success. Keep it
// unresolved and require install_comfyui(action:'panel') on the selected ComfyUI host instead.
const panelTargets = [];
const panelNotes = [];
for (const pack of analysis.missingPacks) {
if (targetsPanelPackExactly(pack)) {
panelTargets.push(pack);
unresolved.push(pack);
continue;
}
const entry = byKey.get(pack);
if (!entry) {
// Manager v4 removed the legacy getlist catalog endpoint. Its task API
// resolves a registry/repository id directly, so an empty catalog is the
// v4 signal to enqueue that id rather than falsely claim it is unresolved.
if (directInstall) {
toInstall.push({ id: pack, version: "latest" });
installed.push(pack);
continue;
}
unresolved.push(pack);
continue;
}
toInstall.push(entry);
installed.push(pack);
}
// Even under the outer pin guard, this workflow transaction cannot bind its
// Manager target to a served-panel root. Do not re-add panel targets to the
// generic queue (especially in remote mode), and do not inspect/mutate the
// process-global panel root for a possibly different Manager target.
for (const pack of panelTargets) {
panelNotes.push(`"${pack}" is the sidebar panel pack: workflow dependency installation does not queue or mutate it ` +
`because this Manager transaction cannot prove the same served-panel target. ` +
`Use install_comfyui(action:'panel') on the selected ComfyUI host.`);
}
let queue;
if (toInstall.length > 0) {
// Clear any stale/pending Manager tasks first so starting the worker runs
// only the installs we just queued, not unrelated leftover work.
await deps.resetQueue();
for (const entry of toInstall) {
await deps.queueInstall(entry, channel);
}
await deps.startQueue();
try {
queue = await deps.queueStatus();
}
catch (err) {
logger.warn("Could not read Manager queue status after starting installs", {
error: err instanceof Error ? err.message : String(err),
});
}
}
// Already-installed = required packs that were never in the missing set.
// (A missing pack that failed to resolve goes to `unresolved`, not here.)
const missingSet = new Set(analysis.missingPacks);
return {
installed: installed.sort(),
alreadyInstalled: analysis.requiredPacks.filter((p) => !missingSet.has(p)),
unresolved: [...new Set(unresolved)].sort(),
queue,
// Gated on there being something to mislead about. Round 3 caught a comment
// here claiming this gate existed when it did not -- the field's own
// docblock says callers rendering `unresolved` must surface it, so setting
// it with an empty `unresolved` contradicts the contract.
...(catalogueEmpty && unresolved.length > 0
? {
catalogue_unavailable: `The ComfyUI-Manager catalogue came back EMPTY (channel "${channel}"), so nothing below ` +
`was actually looked up. A healthy catalogue carries thousands of entries, so this is ` +
`almost certainly a catalogue that could not be refreshed -- NOT evidence that these ` +
`packs do not exist. Manager fetches the registry from the ComfyUI host itself, so a ` +
`blocked or filtered network there looks exactly like an empty result here. Refresh the ` +
`Manager list on that host before concluding anything from "not found".`,
}
: {}),
...(managerCatalogueCurrencyUnverified({
unresolvedCount: unresolved.length,
catalogueUnavailable: catalogueEmpty ? "empty" : undefined,
mappingsUnavailable: analysis.mappings_unavailable,
})
? { catalogue_currency_unverified: MANAGER_CATALOGUE_CURRENCY_CAVEAT }
: {}),
// Same gap on this path: the analysis's mappings failure never reached the reply.
...(analysis.mappings_unavailable
? { mappings_unavailable: analysis.mappings_unavailable }
: {}),
...(panelNotes.length ? { panel_notes: panelNotes } : {}),
};
}
//# sourceMappingURL=workflow-deps.js.map