UNPKG

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
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