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),
209 lines • 9.72 kB
JavaScript
import { readFileSync } from "node:fs";
import { resolvePackWorkflowFile, enumeratePacks } from "./skills-access.js";
import { enqueueWorkflow } from "../services/workflow-executor.js";
import { modifyWorkflow } from "../services/workflow-composer.js";
import { isUiFormat, isApiFormat, isLinkRef, convertUiToApi, } from "../services/workflow-converter.js";
import { getJobStatus } from "../services/queue-manager.js";
import { getObjectInfo, getHistory } from "../comfyui/client.js";
import { ValidationError } from "../utils/errors.js";
import { logger } from "../utils/logger.js";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** Rank pack names by similarity to the query for the not-found error. */
function nearMatches(query, names) {
const q = query.toLowerCase();
const scored = names
.map((name) => {
const n = name.toLowerCase();
let score = 0;
if (n === q)
score = 100;
else if (n.includes(q) || q.includes(n))
score = 50;
else {
// shared token overlap (packs are dash-delimited, e.g. anima-txt2img)
const qTokens = new Set(q.split(/[-_ ]+/).filter(Boolean));
for (const t of n.split(/[-_ ]+/))
if (qTokens.has(t))
score += 10;
}
return { name, score };
})
.filter((s) => s.score > 0)
.sort((a, b) => b.score - a.score);
return scored.slice(0, 8).map((s) => s.name);
}
/**
* Parse "<nodeId>.<widget_name>" override keys (the SAME convention
* enqueue_workflow (action:"template_schema") emits, so schema→run round-trips)
* into set_input operations, validating each against the resolved API graph.
*/
function overridesToOps(workflow, overrides) {
const ops = [];
const nodeKeys = new Set(Object.keys(workflow));
for (const [key, value] of Object.entries(overrides)) {
const dot = key.indexOf(".");
if (dot <= 0 || dot === key.length - 1) {
throw new ValidationError(`Invalid override key "${key}". Use "<nodeId>.<widget_name>", e.g. "3.seed" or "6.text" (the keys enqueue_workflow (action:"template_schema") reports).`);
}
const nodeId = key.slice(0, dot);
const widget = key.slice(dot + 1);
const node = workflow[nodeId];
if (!node) {
// #809: an id list clipped at 40 with no marker looks exhaustive, so a caller
// whose node sits past the cut concludes the id does not exist at all. Say it was
// cut, and name the tool that reports every override key.
const allIds = Object.keys(workflow);
const ids = allIds.slice(0, 40).join(", ");
const more = allIds.length > 40
? ` (+${allIds.length - 40} more not listed — enqueue_workflow (action:"template_schema") reports every override key)`
: "";
throw new ValidationError(`Override "${key}": no node "${nodeId}" in the template's graph. Node ids: ${ids}${more}`);
}
const widgetInputs = Object.entries(node.inputs ?? {})
.filter(([, v]) => !isLinkRef(v, nodeKeys))
.map(([k]) => k);
if (!node.inputs || !(widget in node.inputs)) {
const avail = widgetInputs.join(", ") || "(none)";
throw new ValidationError(`Override "${key}": node ${nodeId} (${node.class_type}) has no input "${widget}". Overridable widgets: ${avail}`);
}
if (isLinkRef(node.inputs[widget], nodeKeys)) {
throw new ValidationError(`Override "${key}": input "${widget}" on node ${nodeId} (${node.class_type}) is a graph CONNECTION, not a widget — overriding it would break the graph. Overridable widgets: ${widgetInputs.join(", ") || "(none)"}. Use create_workflow (action:"modify") to rewire connections.`);
}
ops.push({ op: "set_input", node_id: nodeId, input_name: widget, value });
}
return ops;
}
/** Load a template's graph and normalize it to API format for enqueue. */
async function loadTemplateApiGraph(wfFile) {
let parsed;
try {
parsed = JSON.parse(readFileSync(wfFile, "utf8"));
}
catch (err) {
throw new ValidationError(`Template workflow file is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
}
if (isApiFormat(parsed))
return parsed;
if (isUiFormat(parsed)) {
const objectInfo = await getObjectInfo();
const { workflow, warnings } = convertUiToApi(parsed, objectInfo);
if (warnings.length) {
logger.warn('enqueue_workflow (action:"run_template"): UI→API conversion warnings', { warnings });
}
return workflow;
}
throw new ValidationError("Template workflow is neither UI nor API format — cannot enqueue it.");
}
/**
* `enqueue_workflow (action:"run_template")` — the handler the standalone
* template-run tool used to carry (0.50.0 slice 16), unchanged apart from losing
* its own registration.
*
* Same template resolution, same override→set_input translation, same enqueue
* service call, and the same three terminal payloads (immediate {prompt_id},
* the waited completed/failed result with `isError` on failure, and the timeout
* message that says the job was NOT cancelled). The try/catch moved OUT to the
* dispatcher in workflow-execute.ts, which applies the identical
* `errorToToolResult` — so the not-found ValidationError with its near-matches
* still comes back exactly as before.
*/
export async function runTemplateAction(args) {
// 1. Resolve the template (same resolution as list_packs action:"read_workflow").
const wfFile = resolvePackWorkflowFile(args.template);
if (!wfFile) {
const known = enumeratePacks()
.filter((p) => p.has_workflow)
.map((p) => String(p.name));
const near = nearMatches(args.template, known);
throw new ValidationError(`No template named "${args.template}".` +
(near.length
? ` Did you mean: ${near.join(", ")}?`
: ` Available templates: ${known.join(", ") || "(none bundled)"}.`) +
" Use list_packs to browse all templates.");
}
// 2. Load + normalize to API format, then apply overrides via the
// shared modifyWorkflow (set_input) service.
let workflow = await loadTemplateApiGraph(wfFile);
const overrides = args.overrides ?? {};
const ops = overridesToOps(workflow, overrides);
if (ops.length) {
workflow = modifyWorkflow(workflow, ops).workflow;
}
// 3. Enqueue via the existing enqueue service.
const result = await enqueueWorkflow(workflow, {
disable_random_seed: args.disable_random_seed,
});
const base = {
status: "enqueued",
template: args.template,
prompt_id: result.prompt_id,
queue_remaining: result.queue_remaining,
// #1037 — a 200 from /prompt does not mean every output was accepted;
// ComfyUI queues the branches that validate and reports the rest.
...(result.rejectedOutputs
? { rejected_outputs: result.rejectedOutputs }
: {}),
overrides_applied: Object.keys(overrides),
};
if (!args.wait) {
return {
content: [
{ type: "text", text: JSON.stringify(base, null, 2) },
],
};
}
// 4. Minimal wait: poll the job-status service until done or timeout.
// (Kept deliberately simple — for a bare enqueue, use
// enqueue_workflow action:"enqueue" and poll queue action:"status"
// yourself.)
const timeoutMs = (args.timeout_s ?? 300) * 1000;
const start = Date.now();
for (;;) {
const status = await getJobStatus(result.prompt_id);
if (status.done) {
let outputs = null;
try {
const history = await getHistory(result.prompt_id);
outputs = history[result.prompt_id]?.outputs ?? null;
}
catch (err) {
logger.warn('enqueue_workflow (action:"run_template"): could not fetch outputs from history', {
prompt_id: result.prompt_id,
error: err instanceof Error ? err.message : err,
});
}
const failed = Boolean(status.error);
return {
...(failed ? { isError: true } : {}),
content: [
{
type: "text",
text: JSON.stringify({
...base,
status: failed ? "failed" : "completed",
...(status.error ? { error: status.error } : {}),
...(status.text_outputs ? { text_outputs: status.text_outputs } : {}),
outputs,
}, null, 2),
},
],
};
}
if (Date.now() - start >= timeoutMs) {
return {
content: [
{
type: "text",
text: JSON.stringify({
...base,
status: "timeout",
message: `Job still ${status.running ? "running" : "pending"} after ${args.timeout_s ?? 300}s — it was NOT cancelled. Poll queue (action:"status") with prompt_id "${result.prompt_id}" for completion.`,
}, null, 2),
},
],
};
}
await sleep(1500);
}
}
//# sourceMappingURL=run-template.js.map