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),
254 lines • 11.5 kB
JavaScript
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { getComfyUIBaseUrl } from "../config.js";
import { resolveEffectiveComfyUIBase } from "./workspace-env.js";
import { comfyuiFetch } from "../comfyui/fetch.js";
import { restartComfyUI } from "./process-control.js";
import { ComfyUIError, ProcessControlError, ValidationError } from "../utils/errors.js";
import { logger } from "../utils/logger.js";
const makeDefaultDeps = (resolvedBase) => ({
restart: async () => {
const result = await restartComfyUI();
return { ready: result.readiness?.ready ?? false, message: result.message };
},
fetchObjectInfoKeys: async () => {
const url = `${getComfyUIBaseUrl()}/object_info`;
const res = await comfyuiFetch(url, { signal: AbortSignal.timeout(30_000) });
if (!res.ok) {
throw new ComfyUIError(`Failed to fetch /object_info: ${res.status} ${res.statusText}`, "OBJECT_INFO_FAILED");
}
const data = await res.json();
if (data === null || typeof data !== "object" || Array.isArray(data)) {
throw new ComfyUIError("Unexpected /object_info response (not a JSON object).", "OBJECT_INFO_FAILED");
}
return Object.keys(data);
},
readPackInit: (packName) => {
const base = resolvedBase ?? resolveEffectiveComfyUIBase();
if (!base)
return undefined;
const initPath = join(base, "custom_nodes", packName, "__init__.py");
if (!existsSync(initPath))
return undefined;
try {
return readFileSync(initPath, "utf-8");
}
catch {
return undefined;
}
},
readPackSources: (packName) => {
const base = resolvedBase ?? resolveEffectiveComfyUIBase();
if (!base)
return [];
const packDir = join(base, "custom_nodes", packName);
if (!existsSync(packDir))
return [];
const sources = [];
const MAX_FILES = 200;
// Bounded recursive walk: read .py files, skip vendored / hidden dirs.
const walk = (dir, depth) => {
if (depth > 4 || sources.length >= MAX_FILES)
return;
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
}
catch {
return;
}
for (const entry of entries) {
if (sources.length >= MAX_FILES)
break;
const name = entry.name;
if (entry.isDirectory()) {
if (name.startsWith(".") || name === "__pycache__" || name === "node_modules")
continue;
walk(join(dir, name), depth + 1);
}
else if (entry.isFile() && name.endsWith(".py")) {
try {
sources.push(readFileSync(join(dir, name), "utf-8"));
}
catch {
// Skip unreadable files.
}
}
}
};
walk(packDir, 0);
return sources;
},
inferPackClassTypes: async (packName) => {
const url = `${getComfyUIBaseUrl()}/object_info`;
const res = await comfyuiFetch(url, { signal: AbortSignal.timeout(30_000) });
if (!res.ok) {
throw new ComfyUIError(`Failed to fetch /object_info: ${res.status} ${res.statusText}`, "OBJECT_INFO_FAILED");
}
const data = await res.json();
if (data === null || typeof data !== "object" || Array.isArray(data)) {
throw new ComfyUIError("Unexpected /object_info response (not a JSON object).", "OBJECT_INFO_FAILED");
}
return classTypesForPack(data, packName);
},
});
/**
* Given a parsed /object_info map, return the class_types whose `python_module`
* belongs to `packName`. ComfyUI tags each node with a python_module like
* "custom_nodes.<folder>" (sometimes with extra ".<submodule>" segments), so we
* match when the pack folder name appears as a dot-separated segment.
*/
export function classTypesForPack(objectInfo, packName) {
const out = [];
for (const [classType, value] of Object.entries(objectInfo)) {
if (!value || typeof value !== "object")
continue;
const mod = value.python_module;
if (typeof mod !== "string")
continue;
const segments = mod.split(".");
if (segments.includes(packName))
out.push(classType);
}
return out;
}
/**
* Parse NODE_CLASS_MAPPINGS keys from an __init__.py. Best-effort regex (Python
* isn't parsed) — good enough for the common `{ "Key": Cls, ... }` literal the
* scaffold and most packs use. Returns [] when it can't find a literal.
*/
export function parseClassMappingKeys(initPy) {
const assign = initPy.search(/NODE_CLASS_MAPPINGS\s*=\s*\{/);
if (assign < 0)
return [];
const open = initPy.indexOf("{", assign);
// Find the brace that closes the literal by depth-counting, so a brace inside
// a value (e.g. `"Foo": make({"x": 1})`) doesn't truncate the block.
let depth = 0;
let end = -1;
for (let i = open; i < initPy.length; i += 1) {
if (initPy[i] === "{")
depth += 1;
else if (initPy[i] === "}") {
depth -= 1;
if (depth === 0) {
end = i;
break;
}
}
}
const body = end > open ? initPy.slice(open + 1, end) : initPy.slice(open + 1);
// Match keys at the start of a line only, so keys of inline nested dicts in a
// value (mid-line) are not mistaken for top-level node class_types.
const keys = [];
const keyRe = /^[ \t]*["']([^"'\n]+)["']\s*:/gm;
let m;
while ((m = keyRe.exec(body)) !== null)
keys.push(m[1]);
return keys;
}
export async function verifyCustomNode(options, deps = makeDefaultDeps(options.resolvedBase)) {
// Classify LOCAL vs remote by the EFFECTIVE local base (COMFYUI_PATH, else the
// saved default workspace when not targeting a remote ComfyUI), not by
// COMFYUI_PATH alone — otherwise a local instance backed only by a default
// workspace was wrongly rejected as remote when COMFYUI_PATH was unset
// (#386/#409). Only a genuinely remote target (or no local base at all) is
// unsupported here. A threaded resolvedBase (#1715) IS the local-base answer
// — the live-aware resolver already ran it through the same gates.
if (!(options.resolvedBase ?? resolveEffectiveComfyUIBase())) {
throw new ProcessControlError('node_pack (action:"verify") is local-only: it restarts and inspects a local ComfyUI ' +
"install. It cannot verify a remote --comfyui-url target. Set the COMFYUI_PATH " +
"environment variable, or save a default workspace with workspace (action:\"set_default\").");
}
// Resolve the expected class_types: explicit list wins, else infer from the
// pack. Inference proceeds in escalating order so common packs need no
// class_types argument:
// 1. NODE_CLASS_MAPPINGS literal in __init__.py
// 2. NODE_CLASS_MAPPINGS literal in ANY pack source file (handles packs that
// define mappings in a submodule and re-export them, e.g. cg-use-everywhere)
// 3. (after restart, below) the LIVE /object_info, filtered to this pack's
// python_module — covers packs that build mappings dynamically.
const explicit = (options.classTypes ?? []).map((s) => s.trim()).filter(Boolean);
let expected = explicit;
let inferredFromLiveServer = false;
const haveName = !!options.name;
if (expected.length === 0 && !haveName) {
throw new ValidationError("Provide `class_types` (the NODE_CLASS_MAPPINGS keys to check) or a `name` " +
"whose node class_types can be inferred.");
}
if (expected.length === 0 && options.name) {
// 1. __init__.py literal.
const initPy = deps.readPackInit(options.name);
if (initPy)
expected = parseClassMappingKeys(initPy);
// 2. Any pack source file's literal (re-exported mappings).
if (expected.length === 0 && deps.readPackSources) {
const seen = new Set();
for (const src of deps.readPackSources(options.name)) {
for (const key of parseClassMappingKeys(src))
seen.add(key);
}
expected = [...seen];
}
// Step 3 (live /object_info) runs after restart, once the server is ready.
}
// Restart so newly-added packs are (re)loaded, unless the caller opted out.
const shouldRestart = options.restart !== false;
let ready = true;
let restartMessage = "Skipped restart; checked the running server as-is.";
if (shouldRestart) {
const r = await deps.restart();
ready = r.ready;
restartMessage = r.message;
if (!ready) {
return {
ready: false,
restarted: true,
expected,
loaded: [],
missing: expected,
message: `ComfyUI did not become ready after restart, so node loading could not be ` +
`verified. ${restartMessage}`,
};
}
}
const registered = new Set(await deps.fetchObjectInfoKeys());
// 3. Live fallback: still no class_types to check, so derive them from the
// running server's /object_info, filtered to this pack's python_module. If the
// pack registered any nodes, that proves it imported cleanly.
if (expected.length === 0 && options.name && deps.inferPackClassTypes) {
expected = await deps.inferPackClassTypes(options.name);
inferredFromLiveServer = true;
}
if (expected.length === 0) {
throw new ValidationError(`Could not determine node class_types for pack "${options.name}". No ` +
`NODE_CLASS_MAPPINGS literal was found in its sources, and ComfyUI's ` +
`/object_info reports no nodes from this pack (it may have failed to import, ` +
`or its folder name differs from its python_module). Pass class_types ` +
`explicitly (the NODE_CLASS_MAPPINGS keys you expect to register).`);
}
const loaded = expected.filter((c) => registered.has(c));
const missing = expected.filter((c) => !registered.has(c));
const ok = missing.length === 0;
logger.info("Verified custom node", {
name: options.name,
loaded: loaded.length,
missing: missing.length,
});
return {
ready,
restarted: shouldRestart,
expected,
loaded,
missing,
message: ok
? `All ${expected.length} node type(s) registered in ComfyUI. The pack loads correctly.` +
(inferredFromLiveServer
? ` (class_types were inferred from the live /object_info for "${options.name}".)`
: "")
: `${missing.length} of ${expected.length} node type(s) are NOT registered: ` +
`${missing.join(", ")}. The pack likely failed to import — check ComfyUI logs ` +
`(a missing dependency or a syntax error keeps a node out of /object_info).`,
};
}
//# sourceMappingURL=node-verify.js.map