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),
257 lines (238 loc) • 11.1 kB
JavaScript
// Cross-check every pack's workflow.json against its manifest.yaml so a loader
// node can't reference a model file the pack never downloads (and vice versa).
//
// This is the guard that was missing when two packs shipped a VAELoader pointing
// at `ae.safetensors` while their installers only fetched a different VAE — the
// node throws a missing-model error on load, but nothing caught it in review.
//
// node scripts/check-pack-models.mjs # all packs
// node scripts/check-pack-models.mjs packs/ernie # one pack
//
// Exit non-zero if any workflow references a model the manifest doesn't provide.
// Dead downloads (provided but never referenced) are reported as warnings only.
import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
import { join, resolve, basename, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { parse } from "yaml";
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
const packsRoot = join(repoRoot, "packs");
// File extensions that denote a model weight the pack must provide.
const MODEL_EXT = /\.(safetensors|sft|gguf|ckpt|pt|pth|bin|onnx)$/i;
// Loader widget values are sometimes a sentinel rather than a real file.
const SENTINELS = new Set(["none", "undefined", "null", ""]);
// Node types that fetch their own weights on first run (the model is NOT the
// manifest's responsibility): controlnet-aux preprocessors, RIFE interpolation,
// and the kijai DownloadAndLoad* nodes. A reference from one of these is never
// a manifest gap.
const SELF_MANAGING = /(^DownloadAndLoad)|Preprocessor$|RIFE|InsightFace/i;
const isSelfManaging = (type) => SELF_MANAGING.test(String(type || ""));
// Widget names that are unambiguous MODEL selectors on every node exposing them.
// Kept in sync by hand with ASSET_WIDGET_NAMES in src/services/workflow-converter.ts
// (this script is standalone .mjs and deliberately does not import from src/).
// Deliberately excludes enum-shaped "_name" widgets (sampler_name, scheduler)
// and generic media names, for the same reasons the converter excludes them.
const ASSET_WIDGET_NAMES = new Set([
"ckpt_name",
"unet_name",
"vae_name",
"lora_name",
"clip_name",
"clip_name1",
"clip_name2",
"clip_name3",
"clip_name4",
"control_net_name",
"controlnet_name",
"style_model_name",
"gligen_name",
"hypernetwork_name",
"clip_vision_name",
"model_path",
"diffusion_model",
]);
function packDirs() {
const arg = process.argv[2];
// resolve(), not join(): an ABSOLUTE arg must be honoured as-is so the test
// suite can point the gate at a synthetic pack outside the repo. A relative
// arg still resolves against repoRoot exactly as before.
if (arg) return [resolve(repoRoot, arg)];
return readdirSync(packsRoot)
.map((n) => join(packsRoot, n))
.filter((p) => statSync(p).isDirectory() && existsSync(join(p, "manifest.yaml")));
}
// Compare by basename, lowercased — manifest local_paths and workflow widget
// values disagree on subfolders (e.g. "loras\\style\\x.safetensors" vs
// "loras/x.safetensors"), but the filename ComfyUI loads is the same.
const norm = (p) => basename(String(p).replace(/\\/g, "/")).toLowerCase();
// Files a manifest downloads (basename of local_path, else filename, else URL).
function providedFiles(manifest) {
const out = new Map(); // norm -> display name
for (const m of manifest.models || []) {
let name;
if (m.local_path) name = basename(m.local_path);
else if (m.filename) name = m.filename;
else if (m.url) {
try {
name = basename(new URL(m.url).pathname);
} catch {
name = m.url;
}
}
if (name) out.set(norm(name), name);
}
return out;
}
/** How many model-extension strings a widgets_values tree holds (same shape test
* `record` applies, so the two can never disagree about what counts). */
function countModelStrings(v) {
if (Array.isArray(v)) return v.reduce((n, x) => n + countModelStrings(x), 0);
if (v && typeof v === "object") return Object.values(v).reduce((n, x) => n + countModelStrings(x), 0);
if (typeof v !== "string") return 0;
if (SENTINELS.has(v.toLowerCase())) return 0;
return MODEL_EXT.test(v) ? 1 : 0;
}
/**
* How many of this node's MODEL widget slots are fed by a link instead of by
* their stored widget value.
*
* When a widget is promoted to an input and connected, litegraph keeps the old
* string in `widgets_values` forever, but `graphToPrompt` builds that input from
* the LINK — the stored string is dead and ComfyUI never loads it. Five
* `LoraLoader`s in z-image-xy-plot and two in ltx-2.3-xy-plot are exactly this:
* a stale filename from before the author wired up the `easy loraNames`
* selectors. Counting them as live references made the gate report seven
* missing models that no install can ever miss.
*
* The reference itself is NOT lost by skipping them: the link resolves back to
* a selector node (`easy loraNames`, through Reroutes) whose own widget value is
* scanned on its own turn, so the gap is still reported — against the node that
* actually supplies the value.
*
* `input.link == null` matters: a widget can be promoted to an input and left
* UNCONNECTED, and then its stored value is still what ComfyUI loads. Dropping
* that check would turn every promoted-but-dangling model widget into a blind
* spot.
*
* RESIDUAL RISK, stated rather than hidden: the caller decides by arity, so a
* node whose linked slot happens to hold a NON-model string (never set to a real
* file before promotion) under-counts here, and a second model widget typed in
* on the same node could be skipped with it. That shape does not occur in any of
* the 57 bundled packs — measured, all 7 link-fed model slots hold model strings
* — and closing it properly needs object_info, which this script deliberately
* does not load.
*/
function linkedAssetWidgetCount(node) {
let n = 0;
for (const input of node.inputs || []) {
if (!input || !input.widget || input.link == null) continue;
if (ASSET_WIDGET_NAMES.has(String(input.widget.name))) n++;
}
return n;
}
// Model files referenced by a workflow's loader nodes. We scan widgets_values
// (the value ComfyUI actually loads) for strings ending in a model extension —
// NOT node.properties.models, which is a download hint that is often stale.
function referencedFiles(workflow) {
const refs = new Map(); // norm -> { name, nodes: [{type,id,bypassed}] }
const record = (val, node) => {
if (typeof val !== "string") return;
if (SENTINELS.has(val.toLowerCase())) return;
if (!MODEL_EXT.test(val)) return;
const key = norm(val);
if (!refs.has(key)) refs.set(key, { name: val, nodes: [] });
refs.get(key).nodes.push({
type: node.type,
id: node.id,
bypassed: node.mode === 2 || node.mode === 4,
});
};
const walk = (v, node) => {
if (Array.isArray(v)) v.forEach((x) => walk(x, node));
else if (v && typeof v === "object") Object.values(v).forEach((x) => walk(x, node));
else record(v, node);
};
if (Array.isArray(workflow.nodes)) {
for (const node of workflow.nodes) {
// `widgets_values` is POSITIONAL and this script has no object_info, so it
// cannot map a slot index back to a widget name. It can still decide the
// question safely by ARITY: if every model-extension string on the node is
// accounted for by a linked model widget, all of them are link-supplied.
//
// When the counts DISAGREE the node is ambiguous (e.g. a DualCLIPLoader
// with clip_name1 linked and clip_name2 typed in) and we scan it in full.
// That direction is deliberate: an ambiguous node yields a false POSITIVE
// the author can silence explicitly, whereas guessing would yield a false
// NEGATIVE — a genuinely missing model, silently unreported.
const modelStrings = countModelStrings(node.widgets_values);
if (modelStrings > 0 && modelStrings <= linkedAssetWidgetCount(node)) continue;
walk(node.widgets_values, node);
}
} else {
// API/prompt-format workflow: { "<id>": { class_type, inputs } }. Scan only
// scalar input values (link references are [nodeId, slot] arrays, but a
// string filename can't be confused with one).
for (const [id, node] of Object.entries(workflow)) {
if (!node || typeof node !== "object" || !node.class_type) continue;
walk(node.inputs, { type: node.class_type, id, mode: 0 });
}
}
return refs;
}
let errors = 0;
let warnings = 0;
for (const dir of packDirs()) {
const name = basename(dir);
const manifest = parse(readFileSync(join(dir, "manifest.yaml"), "utf8")) || {};
const packPath = join(dir, "pack.yaml");
const pack = existsSync(packPath) ? parse(readFileSync(packPath, "utf8")) || {} : {};
const wfFile = pack.workflow || "workflow.json";
const wfPath = join(dir, wfFile);
if (!existsSync(wfPath)) {
console.log(`SKIP ${name} — no ${wfFile}`);
continue;
}
const provided = providedFiles(manifest);
const referenced = referencedFiles(JSON.parse(readFileSync(wfPath, "utf8")));
// Files the workflow legitimately references but the pack intentionally does
// not download (user-supplied LoRAs, etc.). Declared in pack.yaml so the gap
// is explicit and reviewed, not silent. Compared by basename.
const allowed = new Set((pack.external_models || []).map(norm));
// An ACTIVE loader pointing at a file the manifest doesn't provide is a hard
// error (ComfyUI flags it missing on load). The same from a bypassed node, a
// self-managing node, or an allow-listed file is a warning at most.
const hardMissing = [];
const softMissing = [];
for (const [key, info] of referenced) {
if (provided.has(key)) continue;
if (allowed.has(key)) continue;
const liveLoaders = info.nodes.filter((n) => !n.bypassed && !isSelfManaging(n.type));
(liveLoaders.length ? hardMissing : softMissing).push(info);
}
// Dead downloads: provided but no loader references them. Often intentional
// (alt quants, optional upscalers), so this is a warning, not an error.
const unused = [];
for (const [key, disp] of provided) {
if (!referenced.has(key)) unused.push(disp);
}
if (hardMissing.length === 0) {
console.log(`OK ${name} — ${referenced.size} model refs, no live gaps`);
}
for (const info of hardMissing) {
const where = info.nodes
.map((n) => `${n.type}#${n.id}${n.bypassed ? "(bypassed)" : ""}`)
.join(", ");
console.error(`FAIL ${name} — workflow references "${info.name}" not downloaded by manifest [${where}]`);
errors++;
}
for (const info of softMissing) {
console.warn(`warn ${name} — "${info.name}" referenced only by bypassed/self-managing nodes, not in manifest`);
warnings++;
}
for (const disp of unused) {
console.warn(`warn ${name} — manifest downloads "${disp}" but no loader references it`);
warnings++;
}
}
console.log(`\n${errors} error(s), ${warnings} warning(s)`);
process.exit(errors ? 1 : 0);