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),
423 lines • 25 kB
JavaScript
import { z } from "zod";
import { getLogs, getHistory, } from "../comfyui/client.js";
import { selectNewestHistoryEntry, extractWorkflowGraph } from "../services/history-select.js";
import { validateWorkflow } from "../services/workflow-validator.js";
import { errorToToolResult } from "../utils/errors.js";
import { generationStatsAction, suggestSettingsAction, } from "./generation-tracker.js";
function findExecutionError(entry) {
const msg = (entry.status?.messages ?? []).find((m) => m[0] === "execution_error");
return msg ? msg[1] : null;
}
function findExecutionInterrupted(entry) {
const msg = (entry.status?.messages ?? []).find((m) => m[0] === "execution_interrupted");
return msg ? (msg[1] ?? {}) : null;
}
/**
* The Status line + outcome section of a diagnosis. Exported for tests.
*
* ComfyUI records an INTERRUPTED run with status_str "error" (plus an
* execution_interrupted message and NO execution_error) — echoing that raw
* "error" made cancelled runs read as failures ("Render failed" on mobile,
* see comfyui-mcp-mobile#23). Report it as interrupted instead; a genuine
* execution_error always wins over the interrupted marker.
*/
export function formatRunOutcome(entry) {
const lines = [];
const execError = findExecutionError(entry);
const interrupted = execError ? null : findExecutionInterrupted(entry);
lines.push(`**Status**: ${interrupted ? "interrupted" : (entry.status?.status_str ?? "unknown")}`);
if (execError) {
lines.push("");
lines.push("### Runtime failure");
lines.push(`**Failed node**: ${execError.node_id ?? "?"} (${execError.node_type ?? "unknown type"})`);
if (execError.exception_type)
lines.push(`**Type**: ${execError.exception_type}`);
if (execError.exception_message) {
lines.push(`**Message**: ${execError.exception_message}`);
}
if (Array.isArray(execError.traceback) && execError.traceback.length > 0) {
// Tail only — the last frames carry the cause, and a full traceback can
// be hundreds of lines (this reply goes straight into an agent's context).
const tail = execError.traceback.slice(-12);
lines.push("");
lines.push("```");
lines.push(tail.join("").trimEnd());
lines.push("```");
}
}
else if (interrupted) {
lines.push("");
lines.push("### Interrupted");
const where = interrupted.node_id !== undefined
? ` at node ${interrupted.node_id}${interrupted.node_type ? ` (${interrupted.node_type})` : ""}`
: "";
lines.push(`**This run was interrupted/cancelled${where}** — it did not crash. Nothing is broken; re-queue it if the cancellation wasn't intended.`);
}
else {
lines.push("");
lines.push("_No runtime error recorded for this run_ — if the user still sees a problem, it was likely rejected BEFORE execution (see validation below) or the run simply produced an unwanted result.");
}
return lines;
}
/**
* Pick the run to diagnose. An explicit id wins; otherwise prefer the most recent
* FAILED run over a newer successful one — "why did it fail?" should land on the
* failure even when the user has since kicked off something that worked.
*/
function selectRunToDiagnose(history, promptId) {
if (promptId)
return selectNewestHistoryEntry(history, promptId);
const failed = Object.entries(history).filter(([, e]) => findExecutionError(e));
if (failed.length > 0)
return selectNewestHistoryEntry(Object.fromEntries(failed));
return selectNewestHistoryEntry(history);
}
/** #1229 — per-value cap for a non-media history output. Generous enough for a
* diagnostic string, small enough that a runaway value cannot dominate the
* reply; anything longer is clipped WITH a note saying so. */
const PER_OUTPUT_VALUE_CHARS = 800;
/**
* Render one non-media history output value (#1229).
*
* Separator is " | " between keys and ", " inside an array. Multi-line values
* are indented as continuations so one value cannot shatter the bullet list --
* the motivating use case is reading validation errors through a scratch
* PreviewAny, which are multi-line by nature.
*/
function renderOutputValue(value) {
let flat;
try {
flat = Array.isArray(value)
? value.map((v) => (typeof v === "string" ? v : JSON.stringify(v))).join(", ")
: typeof value === "string"
? value
: JSON.stringify(value);
}
catch {
// Unreachable today (the entry always comes from JSON.parse, which cannot
// produce a cycle or a BigInt) -- but losing the WHOLE entry, including the
// status and timing already computed, to an opaque TypeError would be worse
// than the bug this fixes.
return "(unserializable)";
}
if (flat === undefined || flat === "")
return "(empty)";
// Slice by CODE POINT: a UTF-16 slice can split a surrogate pair and emit a
// lone half, which is not well-formed text.
const chars = Array.from(flat);
const clipped = chars.length > PER_OUTPUT_VALUE_CHARS
? chars.slice(0, PER_OUTPUT_VALUE_CHARS).join("") +
`... [truncated, ${chars.length} chars total]`
: flat;
return clipped.includes("\n")
? "\n" + clipped.split("\n").map((l) => ` ${l}`).join("\n")
: clipped;
}
export function formatHistoryEntry(promptId, entry) {
const lines = [];
const status = entry.status;
lines.push(`## Execution: ${promptId}`);
lines.push(`**Status**: ${status.status_str} | Completed: ${status.completed}`);
// Timing from messages
const messages = status.messages || [];
const start = messages.find((m) => m[0] === "execution_start");
const end = messages.find((m) => m[0] === "execution_success" || m[0] === "execution_error");
if (start && end) {
const startTs = start[1].timestamp;
const endTs = end[1].timestamp;
const durationSec = ((endTs - startTs) / 1000).toFixed(2);
lines.push(`**Duration**: ${durationSec}s`);
}
// Cached nodes
const cached = messages.find((m) => m[0] === "execution_cached");
if (cached) {
const cachedNodes = cached[1].nodes;
if (cachedNodes.length > 0) {
lines.push(`**Cached nodes**: ${cachedNodes.join(", ")}`);
}
}
// Error details
const errorMsg = messages.find((m) => m[0] === "execution_error");
if (errorMsg) {
const errData = errorMsg[1];
lines.push("");
lines.push("### Error Details");
if (errData.node_id) {
lines.push(`**Failed node**: ${errData.node_id} (${errData.node_type || "unknown type"})`);
}
if (errData.exception_message) {
lines.push(`**Exception**: ${errData.exception_message}`);
}
if (errData.exception_type) {
lines.push(`**Type**: ${errData.exception_type}`);
}
if (Array.isArray(errData.traceback) && errData.traceback.length > 0) {
lines.push("");
lines.push("**Traceback**:");
lines.push("```");
lines.push(errData.traceback.join(""));
lines.push("```");
}
}
// Interrupted
const interrupted = messages.find((m) => m[0] === "execution_interrupted");
if (interrupted) {
lines.push("");
lines.push("**Execution was interrupted/cancelled**");
}
const outputKeys = Object.keys(entry.outputs || {});
if (outputKeys.length > 0) {
lines.push("");
lines.push(`### Outputs (${outputKeys.length} nodes)`);
for (const nodeId of outputKeys) {
const raw = entry.outputs[nodeId];
if (!raw || typeof raw !== "object") {
lines.push(`- Node ${nodeId}: (no output data)`);
continue;
}
const output = raw;
// Expand media filenames so callers can use get_image directly.
// Video keys ('videos', 'video', 'gifs') adapted from jcd315's fork
// (jcd315/comfyui-mcp-muse, commit e13342ec).
const mediaKeys = ["images", "videos", "video", "gifs"];
const expanded = [];
const consumed = new Set();
for (const key of mediaKeys) {
const items = output[key];
if (!Array.isArray(items))
continue;
const fileList = items
.map((m) => {
const path = m.subfolder ? `${m.subfolder}/${m.filename}` : m.filename;
// Carry each ref's ComfyUI directory TYPE (output/temp/input) so the
// caller passes the RIGHT `type` to get_image (#554): preview/compare
// nodes (e.g. PixaromaCompare) write to temp/, not output/, and
// get_image defaults to output — the old summary dropped the type, so
// the documented get_image(type:"output") call 404'd on a temp file.
const type = typeof m.type === "string" && m.type ? m.type : "output";
return `${path} (type=${type})`;
})
.join(", ");
if (fileList) {
expanded.push(`${key} → **${fileList}**`);
consumed.add(key);
}
}
// #1229 - render every key the media expansion did NOT consume, in BOTH
// branches.
//
// Scoping this to the no-media `else` left the reported defect live one
// branch over, on STOCK nodes: SaveAnimatedWEBP emits {images, animated}
// and a captioner emits {images, text}, so `expanded.length > 0` won a
// short-circuit and the payload was dropped exactly as before. Fixing the
// fallback alone fixed only the case I happened to test.
const leftovers = Object.entries(output)
.filter(([key]) => !consumed.has(key))
.map(([key, value]) => {
const v = renderOutputValue(value);
// No space before a multi-line block: `key: ` meeting the value's
// leading newline leaves a trailing space on the key line.
return v.startsWith("\n") ? `${key}:${v}` : `${key}: ${v}`;
});
const rendered = [...expanded, ...leftovers];
// One key per LINE once there is more than one, or any value is
// multi-line. Round 2 caught the two round-1 fixes colliding: rendering
// leftovers alongside media (HIGH 1) plus indented continuations for
// multi-line values meant `join(" | ")` appended the next key to the last
// indented line, so `animated: true` read as the tail of a PreviewAny
// error. No data lost, but mis-attributed -- and it fires on exactly the
// {images, text, animated} shape HIGH 1 made renderable.
//
// This also retires the inline separator rather than relocating its
// collision: `" | "` merely moved the clash from semicolon-bearing text
// onto pipe-bearing text, and a markdown table row is a very plausible
// PreviewAny payload.
if (rendered.length === 0) {
// Three states used to share "(no output data)": a missing output, a
// non-object output, and an object with no keys. Distinct causes, and
// folding them is the defect class this issue belongs to.
lines.push(`- Node ${nodeId}: (empty output object)`);
}
else if (rendered.length === 1 && !rendered[0].includes("\n")) {
lines.push(`- Node ${nodeId}: ${rendered[0]}`);
}
else {
lines.push(`- Node ${nodeId}:`);
for (const part of rendered)
lines.push(` - ${part}`);
}
}
}
return lines.join("\n");
}
const HISTORY_ACTIONS = ["list", "diagnose", "stats", "suggest"];
/**
* `get_system_stats (action:"logs")` — what the retired `get_system_stats (action:"logs")` tool did
* (0.50.0 slice 13). Same /internal/logs read, same keyword filter, same
* default-100 tail, same ANSI strip, same "No log lines found" text.
*
* `keyword` keeps its TRUTHINESS check rather than an absence check: an empty
* keyword matched every line before this consolidation ("".toLowerCase() is a
* substring of everything), and that is the behaviour callers have. This is the
* filter's own semantics, not a requiredness guard.
*/
export async function getLogsAction(args) {
// #1223 — the keyword filter and the tail are handed to getLogs so they run on
// the RAW log, BEFORE redaction. Filtering here meant matching against text the
// scrub had already rewritten, so `keyword:"ltxvideo"` answered "No log lines
// found" for a log that contained it — an absence claim about a line that was
// right there. Selection and redaction have to happen in that order, and the
// only way to guarantee it is to not have the raw lines here at all.
const maxLines = args.max_lines ?? 100;
const lines = await getLogs({ keyword: args.keyword, maxLines });
// Strip ANSI escape codes for readability
const clean = lines.map((l) => l.replace(/\x1b\[[0-9;]*m/g, ""));
const text = clean.length === 0
? `No log lines found${args.keyword ? ` matching \"${args.keyword}\"` : ""}.`
: clean.join("\n");
return { content: [{ type: "text", text }] };
}
export function registerDiagnosticsTools(server) {
server.tool("get_history", "Read what has already been generated on this machine — execution history, why a run failed, and the settings your past renders actually used. Driven by the `action` parameter:\n" +
'- action:"list" — Execution history for a ComfyUI prompt: status, timing, cached nodes, and output details (media filenames for get_image action:\"get\"). Also carries the raw error/traceback. To diagnose WHY a run FAILED or what is missing, prefer action:"diagnose" — it returns the same failure info PLUS missing models (with the file + widget) and missing node types, which this action does not. Use action:"list" when you need the run\'s OUTPUTS or timing for a specific prompt_id.\n' +
'- action:"diagnose" — WHY DID MY RENDER FAIL / WHAT IS MISSING? Explains a failed run in ONE call, without needing a canvas — the headless counterpart to the panel\'s panel_get_errors ("why is this red?"), so mobile/remote sessions get the same answer. Returns: the failed node (id, type) with its `exception_type` + message and a trimmed traceback; **missing_models** (the exact model file that is not installed and the widget holding it — feed the filename to download_model action:\'search_civitai\', then action:\'download_civitai\' — or action:\'search\' then action:\'download\' — to fix it); **missing_node_types** (node classes this install lacks — feed to search_custom_nodes, then install_custom_node); and any other per-input validation errors. Call this whenever a run fails, an enqueue is rejected, or the user asks what is missing — instead of guessing from raw logs. With no prompt_id it diagnoses the most recent FAILED run (falling back to the most recent run). Read-only.\n' +
'- action:"stats" — Statistics from this MCP server\'s LOCAL generation-history database (populated as you run workflows; NOT from ComfyUI, and not the same source as action:"list"): total generations, count of unique sampler/scheduler/steps/CFG combos, a per-model-family breakdown, and the most-reused settings. Read-only; works without a running ComfyUI. Returns empty stats until you have generated images. For concrete recommended settings rather than aggregate counts, use action:"suggest".\n' +
'- action:"suggest" — Recommend concrete, proven sampler/scheduler/steps/CFG (and denoise/shift/LoRA) settings derived from that same LOCAL generation-history database. Read-only and works without a running ComfyUI. Narrow results by `model_family`, `lora_hash`, or a name `search`; with no filter it returns the top settings across all history. Returns a ranked list with each combo\'s reuse count, or a "no history" message until you have generated images. Use this for ready-to-apply values; use action:"stats" for aggregate counts and breakdowns rather than specific suggestions.', {
action: z
.enum(HISTORY_ACTIONS)
.describe('Which history view to return. "list" and "diagnose" read ComfyUI\'s execution history and take an optional `prompt_id`; "stats" and "suggest" read this server\'s own local generation-settings database and take `model_family` (plus `lora_hash`/`search`/`limit` for "suggest"). No action requires any other field.'),
prompt_id: z
.string()
.optional()
.describe('Actions "list" and "diagnose" — the prompt ID to look up (returned by enqueue_workflow). For action:"list", if omitted, returns the most recent COMMITTED execution (chosen by ComfyUI\'s queue number, not dict order); immediately after a run finishes it can briefly lag by one until ComfyUI commits the new entry, so pass the prompt_id from enqueue_workflow to get that exact run, and prefer the run-finished event for naming a just-produced output. For action:"diagnose", omit to diagnose the most recent FAILED run — preferred over a newer successful one — falling back to the most recent run if nothing failed.'),
model_family: z
.string()
.optional()
.describe('Actions "stats" and "suggest" — model-family key to scope to, e.g. \'sdxl\', \'flux\', \'qwen_image\', \'illustrious\'.'),
lora_hash: z
.string()
.optional()
.describe('action:"suggest" — AutoV2 hash (10 chars) of a specific LoRA to find settings for.'),
search: z
.string()
.optional()
.describe("action:\"suggest\" — full-text search on model/LoRA filenames (e.g. 'copax', 'lightning')."),
limit: z
.number()
.int()
.min(1)
.max(50)
.optional()
.describe('action:"suggest" — max results (default 10).'),
}, async (args) => {
try {
switch (args.action) {
case "list": {
const history = await getHistory(args.prompt_id);
const selected = selectNewestHistoryEntry(history, args.prompt_id);
if (!selected) {
return {
content: [
{
type: "text",
text: args.prompt_id
? `No history found for prompt ${args.prompt_id}.`
: "No execution history available.",
},
],
};
}
const [promptId, entry] = selected;
const text = formatHistoryEntry(promptId, entry);
return { content: [{ type: "text", text }] };
}
case "diagnose": {
const history = await getHistory(args.prompt_id);
const selected = selectRunToDiagnose(history, args.prompt_id);
if (!selected) {
return {
content: [
{
type: "text",
text: args.prompt_id
? `No history found for prompt ${args.prompt_id}.`
: "No execution history available — nothing has run yet on this ComfyUI.",
},
],
};
}
const [promptId, entry] = selected;
const lines = [];
lines.push(`## Diagnosis: ${promptId}`);
// 1. Status + outcome: runtime failure, interruption, or clean.
lines.push(...formatRunOutcome(entry));
// 2. Re-validate the exact graph that ran, so we can name what's missing.
// Reuses the same validator behind create_workflow (action:"validate") — the graph is the
// one ComfyUI recorded, so this reflects what actually executed.
const graph = extractWorkflowGraph(entry);
if (!graph) {
lines.push("");
lines.push("_This history entry has no recorded graph_, so missing models/nodes can't be checked for it.");
}
else {
const result = await validateWorkflow(graph, { health: false });
const errors = result.issues.filter((i) => i.severity === "error");
const missingModels = errors.filter((i) => i.kind === "missing_model");
const missingNodeTypes = errors.filter((i) => i.kind === "missing_node_type");
const otherErrors = errors.filter((i) => i.kind !== "missing_model" && i.kind !== "missing_node_type");
if (missingModels.length > 0) {
lines.push("");
lines.push("### Missing models");
for (const i of missingModels) {
lines.push(`- **${i.value ?? "(unknown file)"}** — node ${i.node_id} (${i.node_type}), widget \`${i.input ?? "?"}\``);
}
lines.push("_Fix_: download_model action:\"search_civitai\" (or action:\"search\") by that filename, then download_model action:\"download\" (or action:\"download_civitai\") into the loader's directory.");
}
if (missingNodeTypes.length > 0) {
lines.push("");
lines.push("### Missing node types (packs this install lacks)");
const uniq = [...new Set(missingNodeTypes.map((i) => i.node_type))];
for (const t of uniq)
lines.push(`- **${t}**`);
lines.push("_Fix_: search_custom_nodes for the owning pack, install_custom_node with action:'install', then restart ComfyUI to load it.");
}
if (otherErrors.length > 0) {
lines.push("");
lines.push("### Validation errors");
for (const i of otherErrors.slice(0, 20)) {
lines.push(`- node ${i.node_id} (${i.node_type}): ${i.message}`);
}
if (otherErrors.length > 20) {
lines.push(`- …and ${otherErrors.length - 20} more`);
}
}
if (errors.length === 0) {
lines.push("");
lines.push("_The recorded graph validates clean_ — nothing is missing, so the failure was a runtime one (see above), not a setup problem.");
}
}
return { content: [{ type: "text", text: lines.join("\n") }] };
}
case "stats":
return await generationStatsAction({ model_family: args.model_family });
case "suggest":
return await suggestSettingsAction({
model_family: args.model_family,
lora_hash: args.lora_hash,
search: args.search,
limit: args.limit,
});
default: {
// Unreachable given the zod enum, but a clear runtime guard beats a
// silent undefined if the schema and switch ever drift apart.
const exhaustive = args.action;
throw new Error(`Unknown get_history action "${String(exhaustive)}". Expected one of: ${HISTORY_ACTIONS.join(", ")}.`);
}
}
}
catch (err) {
return errorToToolResult(err);
}
});
}
//# sourceMappingURL=diagnostics.js.map