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),
387 lines • 16.6 kB
JavaScript
// Provenance sidecar for saved ComfyUI workflows.
//
// Captures everything that determines whether a workflow will produce the
// same output tomorrow as it did today:
// - Every model file the workflow references, with SHA-256.
// - Every custom-node pack the workflow's class_types come from, with the
// git commit currently checked out at custom_nodes/<pack>/.git/HEAD.
// - The ComfyUI version reported by /system_stats.
//
// Idea credited to josephoibrahim/comfy-cozy. The lock file lives next to the
// workflow in ComfyUI's user library as `workflows/<name>.lock.json`, so it
// travels with the workflow.
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { getObjectInfo, getSystemStats } from "../comfyui/client.js";
import { ModelError, ProcessControlError, ValidationError } from "../utils/errors.js";
import { logger } from "../utils/logger.js";
import { MODEL_SUBDIRS, resolveExistingModelFile } from "./model-resolver.js";
import { resolveEffectiveComfyUIBase } from "./workspace-env.js";
import { isUiFormat, looksLikeAssetFilename } from "./workflow-converter.js";
// Which class_type → which input field carries the model filename. This list
// covers the loaders ComfyUI core ships; custom packs are walked best-effort
// via the same input-field heuristic (any string input ending in a model
// extension that resolves under <models>/<subdir>/).
//
// `widgetIndex` is the field's position in a UI-format node's `widgets_values`
// array — needed because UI-saved graphs carry values positionally with no
// field names. For every core loader here the model filename is the FIRST
// widget (index 0); the one exception is DualCLIPLoader's second encoder
// (clip_name2 at index 1).
const KNOWN_LOADERS = [
{ classType: "CheckpointLoaderSimple", field: "ckpt_name", modelType: "checkpoints", widgetIndex: 0 },
{ classType: "UNETLoader", field: "unet_name", modelType: "diffusion_models", widgetIndex: 0 },
{ classType: "VAELoader", field: "vae_name", modelType: "vae", widgetIndex: 0 },
{ classType: "CLIPLoader", field: "clip_name", modelType: "text_encoders", widgetIndex: 0 },
{ classType: "DualCLIPLoader", field: "clip_name1", modelType: "text_encoders", widgetIndex: 0 },
{ classType: "DualCLIPLoader", field: "clip_name2", modelType: "text_encoders", widgetIndex: 1 },
{ classType: "LoraLoader", field: "lora_name", modelType: "loras", widgetIndex: 0 },
{ classType: "LoraLoaderModelOnly", field: "lora_name", modelType: "loras", widgetIndex: 0 },
{ classType: "ControlNetLoader", field: "control_net_name", modelType: "controlnet", widgetIndex: 0 },
{ classType: "UpscaleModelLoader", field: "model_name", modelType: "upscale_models", widgetIndex: 0 },
{ classType: "CLIPVisionLoader", field: "clip_name", modelType: "clip_vision", widgetIndex: 0 },
{ classType: "StyleModelLoader", field: "style_model_name", modelType: "style_models", widgetIndex: 0 },
];
async function requireLocalPackRoot(op) {
const packRoot = resolveEffectiveComfyUIBase();
if (!packRoot) {
throw new ProcessControlError(`${op} requires a local ComfyUI install. Lock generation needs to inspect custom_nodes/*/.git/HEAD; set COMFYUI_PATH (or a default workspace). On --base-directory deployments this is the data/base directory the runtime scans, not COMFYUI_CODE_PATH.`);
}
return packRoot;
}
/**
* Flatten a UI-format workflow to every REAL node — top-level nodes PLUS the
* nodes inside every subgraph definition (`definitions.subgraphs[].nodes`). A
* top-level node whose `type` is a subgraph-definition id is a structural
* INSTANCE, not a real node, so it's skipped; its inner nodes are walked from
* the definition instead. Without this, a loader or custom node nested inside a
* subgraph/component would silently disappear from the lock (models + packs).
* Mirrors the subgraph-aware walk in api-nodes.extractWorkflowClassTypes.
*/
function collectUiNodes(workflow) {
const g = workflow;
const defs = g.definitions?.subgraphs;
const defIds = new Set();
const defNodeLists = [];
if (Array.isArray(defs)) {
for (const sg of defs) {
const s = sg;
const id = s?.id;
if (typeof id === "string" && id.length > 0)
defIds.add(id);
if (Array.isArray(s?.nodes))
defNodeLists.push(s.nodes);
}
}
const out = [];
const push = (nodes) => {
if (!Array.isArray(nodes))
return;
for (const n of nodes) {
const node = n;
const t = node?.type;
if (typeof t !== "string" || t.length === 0)
continue;
if (defIds.has(t))
continue; // subgraph instance — structural, not a real node
out.push(node);
}
};
push(g.nodes);
for (const nl of defNodeLists)
push(nl);
return out;
}
/**
* Extract referenced model files from a saved workflow, handling BOTH formats.
*
* Workflows saved from the ComfyUI UI are stored in UI format
* (`{ nodes: [...], links: [...] }` with `type` + positional `widgets_values`),
* NOT the API/prompt format (`{ "1": { class_type, inputs: {...} } }`). The old
* walk only understood API format, so a UI-saved graph exposed neither
* `class_type` nor `inputs` and lock generation silently found 0 models — e.g.
* native VAELoader (`vae_name`) / UNETLoader (`unet_name`) went uncaptured
* (issue #482).
*
* Reading UI nodes directly (rather than round-tripping through a UI→API
* converter) is deliberate: the converter drops bypassed/muted nodes and nodes
* absent from /object_info, which would silently omit their models from the
* lock. Provenance must capture every referenced model regardless of a node's
* mute state or whether its (native) loader happens to be installed, matching
* the API-format behavior.
*/
function extractReferencedModels(workflow) {
const seen = new Map(); // key: `${type}/${name}` → type
const add = (modelType, value) => {
if (typeof value !== "string" || !value)
return;
seen.set(`${modelType}/${value}`, modelType);
};
if (isUiFormat(workflow)) {
for (const n of collectUiNodes(workflow)) {
const type = n.type; // collectUiNodes guarantees a non-empty string
const widgets = Array.isArray(n.widgets_values) ? n.widgets_values : null;
if (!widgets)
continue;
for (const match of KNOWN_LOADERS.filter((l) => l.classType === type)) {
const value = widgets[match.widgetIndex];
// Positional read: guard with the shared asset-filename predicate so an
// unusual widget order can't misread a non-filename widget (e.g.
// UNETLoader's "default" weight_dtype) as a model.
if (looksLikeAssetFilename(value)) {
add(match.modelType, value);
}
}
}
}
else {
for (const node of Object.values(workflow)) {
if (!node?.class_type || !node.inputs)
continue;
for (const match of KNOWN_LOADERS.filter((l) => l.classType === node.class_type)) {
add(match.modelType, node.inputs[match.field]);
}
}
}
return Array.from(seen.entries()).map(([key, type]) => ({
name: key.slice(type.length + 1),
type,
}));
}
async function hashFile(filePath) {
return new Promise((resolve, reject) => {
const hash = createHash("sha256");
const stream = createReadStream(filePath);
stream.on("data", (chunk) => hash.update(chunk));
stream.on("end", () => resolve(hash.digest("hex")));
stream.on("error", reject);
});
}
async function findModelOnDisk(modelType, filename) {
// Use the shared model resolver so the primary data/models root and every
// active extra_model_paths root are searched independently of the checkout
// that owns custom_nodes. This is the split-root boundary workflow locks used
// to cross by assuming <code>/models.
const subdirs = MODEL_SUBDIRS.includes(modelType)
? [modelType]
: [modelType, ...MODEL_SUBDIRS];
for (const subdir of subdirs) {
try {
const resolved = await resolveExistingModelFile(join(subdir, filename));
if (resolved.info.isFile())
return resolved.path;
}
catch (err) {
if (err instanceof ModelError)
continue;
throw err;
}
}
return undefined;
}
async function readGitHeadCommit(packPath) {
const headPath = join(packPath, ".git", "HEAD");
try {
const head = (await readFile(headPath, "utf-8")).trim();
if (head.startsWith("ref: ")) {
const refPath = join(packPath, ".git", head.slice(5).trim());
try {
return (await readFile(refPath, "utf-8")).trim();
}
catch {
// Packed refs path — fall back to refs/packed.
try {
const packed = await readFile(join(packPath, ".git", "packed-refs"), "utf-8");
const wanted = head.slice(5).trim();
for (const line of packed.split("\n")) {
if (line.endsWith(` ${wanted}`)) {
return line.split(" ")[0].trim();
}
}
}
catch {
return undefined;
}
}
}
// Detached HEAD: contents are the commit SHA directly.
if (/^[0-9a-f]{7,40}$/.test(head))
return head;
}
catch {
return undefined;
}
return undefined;
}
function buildClassTypeOriginMap(objectInfo) {
const byClass = new Map();
for (const [classType, def] of Object.entries(objectInfo ?? {})) {
const mod = def?.python_module ?? "";
// `python_module` is e.g. "custom_nodes.ComfyUI-WanVideoWrapper.nodes" for
// a custom pack, or "comfy_extras.nodes_*" / "nodes" for built-ins.
if (mod.startsWith("custom_nodes.")) {
const pack = mod.split(".")[1] ?? null;
byClass.set(classType, { pack, builtin: false });
}
else if (mod) {
byClass.set(classType, { pack: null, builtin: true });
}
}
return { byClass };
}
// Every node's class_type, reading UI format (`node.type`, subgraph-aware) or
// API/prompt format (`node.class_type`). Bypassed/muted nodes are included on
// purpose: their pack is still a provenance dependency of the saved graph.
function workflowClassTypes(workflow) {
if (isUiFormat(workflow)) {
return collectUiNodes(workflow).map((n) => n.type);
}
return Object.values(workflow)
.map((n) => n?.class_type)
.filter((t) => typeof t === "string" && t.length > 0);
}
async function packsReferencedByWorkflow(workflow, origins) {
const packs = new Set();
for (const classType of workflowClassTypes(workflow)) {
const origin = origins.byClass.get(classType);
if (origin?.pack)
packs.add(origin.pack);
}
return Array.from(packs).sort();
}
export async function generateLock(workflow) {
const packRoot = await requireLocalPackRoot("generateLock");
const stats = await getSystemStats();
const comfyuiVersion = stats.system?.comfyui_version ?? "unknown";
// /object_info maps class_types to their originating packs. Model + pack
// discovery read UI and API/prompt formats directly (see extractReferencedModels
// / workflowClassTypes), so no whole-graph conversion is needed.
const objectInfo = await getObjectInfo();
const models = extractReferencedModels(workflow);
const lockedModels = [];
for (const m of models) {
const path = await findModelOnDisk(m.type, m.name);
if (!path) {
lockedModels.push({ ...m, missing: true });
continue;
}
try {
const sha256 = await hashFile(path);
lockedModels.push({ ...m, sha256 });
}
catch (err) {
logger.warn("Could not SHA-256 model file", {
name: m.name,
error: err instanceof Error ? err.message : err,
});
lockedModels.push({ ...m, missing: true });
}
}
const origins = buildClassTypeOriginMap(objectInfo);
const packIds = await packsReferencedByWorkflow(workflow, origins);
const lockedPacks = [];
for (const id of packIds) {
const packPath = join(packRoot, "custom_nodes", id);
const commit = await readGitHeadCommit(packPath);
lockedPacks.push(commit ? { id, commit_sha: commit } : { id });
}
// Built-in dependencies don't get a separate row — the ComfyUI version field
// covers them. Only custom packs appear in node_packs.
return {
generated_at: new Date().toISOString(),
comfyui_version: comfyuiVersion,
models: lockedModels,
node_packs: lockedPacks,
};
}
/**
* Parse a lock file's raw body into a WorkflowLock, tolerating a missing or
* empty file. ComfyUI's /api/userdata can return 200 with an empty body for a
* file that doesn't exist; feeding that straight to JSON.parse throws
* "Unexpected end of JSON input". Returns null when there is no lock present
* (empty/whitespace body) so callers can report "no lock" gracefully, and
* throws a clear ValidationError only when the body is genuinely malformed.
*/
export function parseWorkflowLock(raw) {
if (raw == null)
return null;
const trimmed = raw.trim();
if (trimmed === "")
return null;
let parsed;
try {
parsed = JSON.parse(trimmed);
}
catch (err) {
throw new ValidationError(`Lock file exists but is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
return parsed;
}
export function diffLocks(lock, current) {
const drift = { models: [], node_packs: [] };
if (lock.comfyui_version !== current.comfyui_version) {
drift.comfyui_version = { lock: lock.comfyui_version, current: current.comfyui_version };
}
const lockModels = new Map(lock.models.map((m) => [`${m.type}/${m.name}`, m]));
const currentModels = new Map(current.models.map((m) => [`${m.type}/${m.name}`, m]));
for (const [key, m] of lockModels) {
const c = currentModels.get(key);
if (!c || c.missing) {
drift.models.push({
name: m.name,
type: m.type,
lock_sha256: m.sha256,
status: "missing",
});
continue;
}
if (m.sha256 && c.sha256 && m.sha256 !== c.sha256) {
drift.models.push({
name: m.name,
type: m.type,
lock_sha256: m.sha256,
current_sha256: c.sha256,
status: "changed",
});
}
}
for (const [key, c] of currentModels) {
if (!lockModels.has(key)) {
drift.models.push({
name: c.name,
type: c.type,
current_sha256: c.sha256,
status: "added",
});
}
}
const lockPacks = new Map(lock.node_packs.map((p) => [p.id, p]));
const currentPacks = new Map(current.node_packs.map((p) => [p.id, p]));
for (const [id, p] of lockPacks) {
const c = currentPacks.get(id);
if (!c) {
drift.node_packs.push({ id, lock_commit: p.commit_sha, status: "missing" });
continue;
}
if (p.commit_sha && c.commit_sha && p.commit_sha !== c.commit_sha) {
drift.node_packs.push({
id,
lock_commit: p.commit_sha,
current_commit: c.commit_sha,
status: "changed",
});
}
}
for (const [id, c] of currentPacks) {
if (!lockPacks.has(id)) {
drift.node_packs.push({ id, current_commit: c.commit_sha, status: "added" });
}
}
return drift;
}
//# sourceMappingURL=workflow-lock.js.map