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),
317 lines • 17.9 kB
JavaScript
// Aggregate pre-flight health check for a connected ComfyUI instance.
//
// Originally contributed by João Lucas (github.com/joaolvivas) in
// joaolvivas/comfyui-mcp-byjlucas@de82ecda (2026-05-12). Refactored to
// match this repo's service/tool split.
import { ConnectionError } from "../utils/errors.js";
import { isCloudMode } from "../config.js";
import { getQueue, getSystemStats, comfyApiFetch } from "../comfyui/client.js";
import { scrubLogLines } from "../comfyui/json-guard.js";
const CRITICAL_MODEL_CATS = [
"checkpoints",
"diffusion_models",
"loras",
"vae",
"text_encoders",
"controlnet",
];
/**
* The ComfyUI FRONTEND package version, as reported by /system_stats.
*
* ComfyUI gives this two ways and they can disagree, which is itself worth
* seeing: `comfy_package_versions[comfyui-frontend-package].installed` is what is
* actually loaded, and `required_frontend_version` is what this ComfyUI asked
* for. A mismatch means someone pinned or overrode the frontend — exactly the
* situation in panel#779, where `--front-end-version …@1.47.12` was the fix.
*
* Returns "?" when neither is present rather than inventing one. An unknown
* version must not read as an absent problem.
*/
function describeFrontendVersion(stats) {
// Both fields live under `system`, verified against a live ComfyUI 0.30.2 —
// the first draft read them off the top level and silently produced "?" on a
// server that reports them perfectly well. Top-level is still accepted in case
// another build puts them there, but `system` is where they actually are.
const pkgs = Array.isArray(stats?.system?.comfy_package_versions)
? stats.system.comfy_package_versions
: Array.isArray(stats?.comfy_package_versions)
? stats.comfy_package_versions
: [];
const fe = pkgs.find((p) => p?.name === "comfyui-frontend-package");
const installed = typeof fe?.installed === "string" ? fe.installed : undefined;
const required = typeof stats?.system?.required_frontend_version === "string"
? stats.system.required_frontend_version
: typeof stats?.required_frontend_version === "string"
? stats.required_frontend_version
: undefined;
if (installed && required && installed !== required) {
return `${installed} (this ComfyUI expects ${required} — pinned or overridden)`;
}
return installed ?? required ?? "?";
}
export async function runHealthCheck(options = {}) {
const categories = options.modelCategories ?? [...CRITICAL_MODEL_CATS];
const recentErrors = options.recentErrors ?? 20;
const lines = ["## Health Check\n"];
try {
const stats = await getSystemStats({ diagnosticContext: "health" });
const sys = stats.system ?? {};
const dev = stats.devices?.[0] ?? {};
const vramTotalGB = dev.vram_total
? (dev.vram_total / 1024 ** 3).toFixed(1)
: "?";
const vramFreeGB = dev.vram_free
? (dev.vram_free / 1024 ** 3).toFixed(1)
: "?";
const ramFreeGB = sys.ram_free
? (sys.ram_free / 1024 ** 3).toFixed(1)
: "?";
lines.push(`**ComfyUI**: ${sys.comfyui_version ?? "?"} | ` +
// panel#779 — the FRONTEND package version, which /system_stats reports and
// nothing here read. That outage (a blank agent panel on a fresh install)
// turned entirely on it: ComfyUI 0.30.0 was identical between the broken
// and working machines, and the frontend was 1.50.3 vs 1.47.12. A reporter
// had to be asked for it after an hour of eliminating everything else, and
// "ComfyUI version" alone will keep hiding this class of skew.
`frontend ${describeFrontendVersion(stats)} | ` +
`Python ${(sys.python_version ?? "").split(" ")[0] || "?"} | ` +
`PyTorch ${sys.pytorch_version ?? "?"}`);
lines.push(`**GPU**: ${dev.name ?? "?"} | VRAM free ${vramFreeGB}/${vramTotalGB} GB | RAM free ${ramFreeGB} GB`);
}
catch (err) {
throw new ConnectionError(`ComfyUI unreachable: ${err instanceof Error ? err.message : err}`);
}
try {
const q = await getQueue();
const running = q.queue_running?.length ?? 0;
const pending = q.queue_pending?.length ?? 0;
lines.push(`**Queue**: ${running} running, ${pending} pending`);
}
catch (err) {
lines.push(`**Queue**: ERROR — ${err instanceof Error ? err.message : err}`);
}
if (isCloudMode()) {
// Comfy Cloud has its own model library and no /internal/logs equivalent.
lines.push(`\n**Models**: managed by Comfy Cloud (not listable from this client)`);
lines.push(`**Recent errors**: not available in cloud mode`);
return lines.join("\n");
}
const modelLines = [];
let totalModelsSeen = 0;
for (const cat of categories) {
try {
const res = await comfyApiFetch(`/models/${cat}`);
if (!res.ok) {
modelLines.push(`- ${cat}: REST ${res.status}`);
continue;
}
const files = (await res.json());
const count = Array.isArray(files) ? files.length : 0;
totalModelsSeen += count;
if (count === 0) {
modelLines.push(`- ${cat}: **EMPTY** ⚠️ (check extra_model_paths.yaml)`);
}
else {
const preview = files.slice(0, 3).join(", ");
const more = count > 3 ? ` (+${count - 3} more)` : "";
modelLines.push(`- ${cat}: ${count} — ${preview}${more}`);
}
}
catch (err) {
modelLines.push(`- ${cat}: ERROR — ${err instanceof Error ? err.message : err}`);
}
}
lines.push(`\n**Models** (${totalModelsSeen} total across ${categories.length} categories):`);
lines.push(...modelLines);
// Recent custom-node errors from /internal/logs (best-effort; older
// ComfyUI versions and remote-only deployments may not expose it).
// #1146 — recent_errors:0 means "show me none", and it returned EVERYTHING.
//
// `slice(-0)` is `slice(0)`: -0 === 0 in JS, so the negative-index reading
// never happens and the whole array comes back. A reporter asking for zero got
// the full historical ComfyUI log and a response truncated at ~12k tokens —
// the opposite of the request, from the one argument value meant to suppress
// it. Any non-positive limit takes the explicit branch instead.
//
// And it is reported as NOT REQUESTED, not as "none in /internal/logs". The
// log was never read for content, so claiming it is clean would assert an
// absence nobody observed — a caller trying to shrink a response would be told
// their server is healthy as a side effect of asking for a shorter answer.
if (recentErrors <= 0) {
lines.push(`\n**Recent errors**: not requested (recent_errors=${recentErrors}) — the log was NOT checked, which is not the same as it being clean.`);
}
else {
try {
const res = await comfyApiFetch("/internal/logs");
if (res.ok) {
const rawText = await res.text();
// /internal/logs returns a JSON-wrapped string. Parse it to get the actual logs.
let text;
try {
const parsed = JSON.parse(rawText);
// Ensure the parsed value is a string, not an object or other type
text = typeof parsed === "string" ? parsed : rawText;
}
catch {
// If not JSON, use the raw text
text = rawText;
}
// #1206 — these lines go straight into the health report, which is a
// DIAGNOSTIC users paste into bug reports. A custom node logging the URL
// it fetched can put a CivitAI/HF token in one, so scrub before emitting.
// Per line, and fail-closed VISIBLY, for the same reasons as getLogs.
//
// #2329 — match severity on the line itself, not a bare substring. A log
// line containing "error" in a message like "0 errors found" is not an
// actual error. Only lines with [ERROR]/[EXCEPTION] markers or starting
// with "Traceback" are errors. Keep traceback continuation lines (indented).
// #2347 — severity is matched on the entry BODY, after the timestamp prefix.
// /internal/logs joins each record as `l["t"] + " - " + l["m"]`
// (api_server/routes/internal/internal_routes.py), so every line begins with a
// timestamp. #2329 matched `^Traceback` against the raw line, which therefore
// could never fire: measured on a live 105-line log, 0 lines start with
// "Traceback".
//
// #2355 — and something STILL sits between the prefix and the anchor.
// ColoredFormatter (app/logger.py:35-45) prepends its own tag before it
// delegates, so ColoredFormatter("%(message)s") does NOT mean message-only:
//
// level_tag = f"{bold}{color}[{record.levelname}]{ANSI_RESET} "
// return level_tag + super().format(record)
//
// and setup_logger swaps sys.stdout/sys.stderr for LogInterceptor BEFORE it
// builds the handlers (app/logger.py:107-108 vs :120), so what reaches the
// in-memory deque — and therefore /internal/logs — is
//
// 2026-... - ESC[1mESC[31m[ERROR]ESC[0m !!! Exception during processing !!! ...
//
// Measured on ComfyUI 0.34.0 by driving its own app/logger.py, NOT by
// reconstructing the format from its description — that reconstruction is
// exactly how #2347 shipped two anchors that could never fire.
//
// ANSI is stripped. The `[LEVEL]` tag deliberately is NOT: discarding it
// would disarm the `[ERROR]`/`[EXCEPTION]` clause below, which is the only
// cover for the Manager and file-handler shapes. The anchors tolerate the
// tag instead of the body losing it.
const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
const bodyOf = (line) => stripAnsi(line.replace(/^\S+ - /, ""));
// The tag is `[` + record.levelname + `]`, so it is one of Python's level
// names plus DETAIL, which ComfyUI adds (comfy/internal_logging.py). Spelled
// once: four copies of this alternation is four places for it to drift.
const LEVEL = String.raw `(?:\[(?:CRITICAL|ERROR|EXCEPTION|WARNING|INFO|DETAIL|DEBUG)\]\s*)?`;
// A traceback's last line is `Name: message` — or, when the exception was
// raised with no argument, just `Name`. #2347 required the colon, so a real
// KeyboardInterrupt and a bare `raise RuntimeError` (rows D and E of #2355,
// both measured against the live formatter) produced a group whose exception
// was never named. Accepting `:` OR end-of-line takes those without matching
// prose like "Exit code 0". Case-sensitive, as before, so that it cannot fire
// on "0 errors found".
const EXCEPTION_TAIL = new RegExp(`^${LEVEL}[A-Za-z0-9_.]*(?:Error|Exception|Interrupt|Exit)\\s*(?::|$)`);
// What stock ComfyUI actually emits. #2329 keyed on `[ERROR]`/`[EXCEPTION]`,
// which its in-memory handler never writes — app/logger.py formats the memory
// handler with ColoredFormatter("%(message)s"), message only. The bracketed
// forms come from ComfyUI-Manager printing its own prefix, which is why a live
// test appeared to pass: it was reading Manager lines, not ComfyUI ones.
//
// The consequence was worse than the blob it replaced: execution.py's
// "!!! Exception during processing !!!", the traceback that follows it, and
// "Got an OOM, unloading all loaded models." were all invisible, so a crashed
// render reported byte-identically to a healthy server in the diagnostic users
// paste into bug reports.
//
// #2355 — the body reaching these begins `[ERROR] !!! Exception...`, not
// `!!! Exception...`, so the two anchored patterns take the optional tag.
// WARNING is what makes this reachable rather than cosmetic: a custom-node
// import failure is logged at WARNING (nodes.py:2335-2336), and the
// `[ERROR]` clause below gives that no cover at all.
const ERROR_HEADERS = [
new RegExp(`^${LEVEL}!!!\\s*Exception during processing`, "i"), // execution.py:637
new RegExp(`^${LEVEL}Traceback\\s*\\(`, "i"), // a Python traceback header
/\[ERROR\]|\[EXCEPTION\]/i, // ComfyUI-Manager / file handler
];
const ERROR_SIGNALS = [
...ERROR_HEADERS,
EXCEPTION_TAIL, // a bare exception tail
/\bGot an OOM\b/i, // model_management OOM notice
/\bAllocation on device\b/i, // torch allocator OOM
/\bCUDA out of memory\b/i,
/\b(ERROR|EXCEPTION)\s*:/, // an explicit level prefix
];
const isHeader = (body) => ERROR_HEADERS.some((re) => re.test(body));
const isSignal = (body) => ERROR_SIGNALS.some((re) => re.test(body));
const allLines = text.split("\n");
const bodies = allLines.map(bodyOf);
const errorGroups = [];
const processed = new Set();
for (let i = 0; i < allLines.length; i++) {
if (processed.has(i) || !isSignal(bodies[i]))
continue;
// Walk BACKWARDS to the header this line belongs to. #2329 only extended
// forwards, so matching an exception TAIL ("RuntimeError: ...") dropped every
// frame above it and the "!!! Exception during processing !!!" header with
// them — the caller saw the exception name and nothing that located it.
let first = i;
if (!isHeader(bodies[i])) {
for (let j = i - 1; j >= 0 && i - j <= 200; j--) {
if (processed.has(j))
break;
const b = bodies[j];
if (isHeader(b)) {
first = j;
break;
}
// Frames are indented; anything else ends the traceback above us.
if (!/^[ \t]/.test(b) && b.trim() !== "")
break;
}
}
const group = [];
for (let k = first; k <= i; k++) {
group.push(allLines[k]);
processed.add(k);
}
// Then forwards through the frames and the exception tail below.
for (let j = i + 1; j < allLines.length; j++) {
const b = bodies[j];
if (isHeader(b))
break;
if (/^[ \t]/.test(b) || b.trim() === "") {
group.push(allLines[j]);
processed.add(j);
continue;
}
// A bare exception tail closes the traceback it belongs to; take it and
// stop. The SAME regex as the signal list — a second copy of it is how
// the two drift apart and the tail silently stops being taken.
if (EXCEPTION_TAIL.test(b)) {
group.push(allLines[j]);
processed.add(j);
}
break;
}
errorGroups.push(group);
}
// Take the last N error groups, then flatten them into lines for scrubbing
const recentErrorGroups = errorGroups.slice(-recentErrors);
const errorLines = recentErrorGroups.flat();
// #2355 — strip ANSI on the way OUT too. These lines land in a diagnostic
// the user pastes into a bug report, where raw escape bytes are noise, and
// they would otherwise be what scrubSecretShapedText reasons about.
const errLines = scrubLogLines(errorLines.map(stripAnsi));
if (errLines.length > 0) {
lines.push(`\n**Recent errors** (last ${errLines.length}):`);
for (const e of errLines)
lines.push(` ${e.trim()}`);
}
else {
lines.push(`\n**Recent errors**: none in /internal/logs`);
}
}
}
catch {
// Logs endpoint unavailable — silent.
}
}
return lines.join("\n");
}
//# sourceMappingURL=health-check.js.map