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),
222 lines (215 loc) • 12.1 kB
JavaScript
/**
* The knowledge-parity smoke's MOCK CANVAS — its own module so the tests can call it.
*
* These executors decide whether the smoke tells the truth. When they were inline in a
* script that connects to ComfyUI at import, nothing could exercise them, so the tests
* asserted on source TEXT — and mutation testing showed exactly what that is worth: wrapping
* the `graph_load` refusal in `if (false)` left every assertion green while the mock went
* back to reporting a successful load of nothing.
*
* Same move, same reason, as plugin/scripts/monitor-timeout.mjs (#1385).
*/
/** A UI workflow's node list, when this object carries one. */
function nodeArray(v) {
return Array.isArray(v?.nodes) ? v.nodes : undefined;
}
/**
* An API-format prompt: `{ "1": { class_type, inputs }, "2": {…} }`. Recognised only when
* EVERY key is numeric and every value looks like a node — a workflow object with two
* unrelated keys must not be mistaken for one, or the refusal stops meaning anything.
*/
function nodeIdMap(v) {
if (!v || typeof v !== "object" || Array.isArray(v)) return undefined;
const entries = Object.entries(v);
if (!entries.length) return undefined;
if (!entries.every(([k, node]) => /^\d+$/.test(k) && node && typeof node === "object")) {
return undefined;
}
return entries.map(([id, node]) => ({ id: Number(id), type: node.class_type ?? node.type, ...node }));
}
/** A SHAPE, never the payload: a workflow can carry prompts and paths, and this string
* goes into a transcript. */
export function describeShape(value) {
if (value === null || value === undefined) return "nothing";
if (Array.isArray(value)) return `an array of ${value.length}`;
if (typeof value !== "object") return typeof value;
const keys = Object.keys(value);
return `an object with keys [${keys.slice(0, 8).join(", ")}${keys.length > 8 ? ", …" : ""}]`;
}
/**
* The workflow identity this mock tab holds.
*
* It lives HERE, not in the smoke script. When the executors were extracted it stayed
* behind in the script's lexical scope, and ES modules do not share that — so
* `workflow_list` threw a ReferenceError and answered `ok:false` on every call. Nothing
* caught it: the new tests never called `workflow_list`, and the one smoke run afterwards
* happened not to reach it. An extraction is a behaviour change until something proves
* otherwise (codex).
*/
export const MOCK_WORKFLOW_UUID = "11111111-1111-4111-8111-111111111111";
export function makeGraph() {
let seq = 0;
const nodes = new Map();
const add = (type, title) => {
const id = ++seq;
nodes.set(id, {
id, type, title: title || type, is_subgraph: false,
widgets: { value: 0, text: "" },
inputs: [{ name: "in0", type: "*", link: null }, { name: "model", type: "MODEL", link: null }],
outputs: [{ name: "out0", type: "*", links: [] }, { name: "MODEL", type: "MODEL", links: [] }],
});
return nodes.get(id);
};
const brief = (n) => ({ id: n.id, type: n.type, title: n.title, is_subgraph: !!n.is_subgraph, widgets: n.widgets, inputs: n.inputs, outputs: n.outputs });
const need = (id) => { const n = nodes.get(Number(id)); if (!n) throw new Error(`No node ${id}`); return n; };
const EXEC = {
graph_get_state: () => ({ viewing: { scope: "root" }, node_count: nodes.size, truncated: false, nodes: [...nodes.values()].map(brief) }),
graph_add_node: ({ class_type, title }) => { if (!class_type) throw new Error("class_type required"); return { added: brief(add(class_type, title)) }; },
graph_remove_node: ({ node_id }) => { const n = need(node_id); nodes.delete(Number(node_id)); return { removed: brief(n) }; },
graph_clear: () => { const c = nodes.size; nodes.clear(); return { cleared: c }; },
// #1384 — without this the preferred one-shot panel_load_workflow(pack:…) path cannot
// complete, so the smoke could only reach the capability by the long route.
graph_load: (m) => {
// ACCEPT THE SHAPES A WORKFLOW ACTUALLY ARRIVES IN. Reading only `workflow.nodes`
// meant a pack workflow that nests them elsewhere loaded ZERO nodes — and the mock
// then honestly reported `node_count: 0` for a 16-node graph, which an agent
// correctly read as the panel silently dropping its workflow and filed as a panel
// defect (comfyui-mcp-panel#1068). The observation was right; the panel it was
// observing was this file.
const wf = m.workflow ?? m.graph ?? m.data ?? m;
const incoming =
nodeArray(wf) ??
nodeArray(wf?.workflow) ??
nodeArray(wf?.prompt) ??
// AN API-FORMAT PROMPT IS A MAP KEYED BY NODE ID, and a real panel accepts one.
// Refusing it made the smoke fail where the product passes — a harness false
// NEGATIVE, which is the same class of lie as the false success this refusal was
// added to stop, just pointed the other way (codex).
nodeIdMap(wf?.prompt) ??
nodeIdMap(wf);
// REFUSE, do not report a successful load of nothing (codex). An API-format prompt
// is an object keyed by node id, not an array, and falling through to `[]` cleared
// the canvas and answered `node_count: 0` — a successful-looking load that loaded
// nothing, which is the exact false success this harness exists to detect elsewhere.
if (!incoming) {
throw new Error(
`graph_load: this SMOKE MOCK understands a UI workflow with a nodes[] array ` +
`(at .nodes, .workflow.nodes or .prompt.nodes); it was handed ${describeShape(wf)}. ` +
`Nothing was loaded and the canvas is unchanged.`,
);
}
nodes.clear();
for (const n of incoming) {
const id = Number(n.id ?? nodes.size + 1);
nodes.set(id, {
id,
type: n.type ?? "Unknown",
title: n.title ?? n.type ?? "Unknown",
widgets: {},
inputs: [],
outputs: [],
});
}
return { loaded: { node_count: nodes.size } };
},
graph_connect: ({ from_node_id, to_node_id }) => ({ connected: { from: { node_id: from_node_id }, to: { node_id: to_node_id } } }),
graph_disconnect: ({ node_id }) => ({ disconnected: { node_id } }),
graph_set_widget: ({ node_id, widget, value }) => { const n = need(node_id); const p = n.widgets[widget]; n.widgets[widget] = value; return { set: { node_id, widget, previous: p, value } }; },
graph_set_title: ({ node_id, title }) => { const n = need(node_id); const p = n.title; n.title = title; return { node_id, previous: p, title }; },
graph_move_node: ({ node_id, pos }) => ({ moved: { node_id, to: pos } }),
graph_canvas: ({ action }) => ({ canvas: { action } }),
graph_run: ({ batch_count }) => ({ queued: true, batch_count: batch_count ?? 1 }),
graph_get_errors: () => ({ last_execution_error: null, node_errors: null, note: "no errors" }),
workflow_save: () => ({ saved: true }),
workflow_save_as: ({ name }) => ({ saved_as: `workflows/${name}.json` }),
workflow_new: () => ({ created: true }),
workflow_open: ({ path }) => ({ opened: { path } }),
// The ACTIVE record must carry a workflow_uuid (#1384). Without one the tab is
// identity-less, so `rebindWorkflowFence` reports `no_uuid` and every graph WRITE stays
// refused — the same class of refusal the report opened with, one fence further in.
// The refusal is correct product behaviour: the mock was the thing lying, by claiming a
// panel version new enough to stamp per-workflow identity while advertising none.
workflow_list: () => ({
active: {
path: "workflows/current.json",
filename: "current.json",
key: "cur",
workflow_uuid: MOCK_WORKFLOW_UUID,
},
workflows: [
{
path: "workflows/current.json",
filename: "current.json",
key: "cur",
workflow_uuid: MOCK_WORKFLOW_UUID,
active: true,
},
],
open: [],
}),
// COMMANDS THE ADVERTISED VERSION IMPLIES (comfyui-mcp-panel#1068). The hello claims a
// panel new enough for the whole bridge set; throwing `unknown graph_outline` at an
// agent that checked the version first is the mock lying again, and this time the agent
// did the right thing with the lie — it treated a missing capability as a defect and
// filed a report against the real panel.
//
// THEIR REPLY SHAPES ARE THE PRODUCT'S, not plausible-looking inventions (codex). A
// reply the orchestrator has to reinterpret makes the smoke exercise a path no real
// panel takes, which is a different kind of lie with the same cost.
graph_outline: () => ({
// ONE STRING. `panel_graph_outline` promises `outline` and bounds it by max_chars;
// an object of nodes here would be reported as a panel that ignores the contract.
outline:
`root (${nodes.size} node(s))\n` +
[...nodes.values()].map((n) => ` #${n.id} ${n.type} — ${n.title}`).join("\n"),
}),
graph_find_nodes: ({ query, types, title, limit }) => {
const q = String(query ?? title ?? "").toLowerCase();
const wanted = Array.isArray(types) ? types.map((t) => String(t).toLowerCase()) : [];
const all = [...nodes.values()].filter((n) => {
const hay = `${n.type} ${n.title}`.toLowerCase();
return (!q || hay.includes(q)) && (!wanted.length || wanted.some((t) => n.type.toLowerCase().includes(t)));
});
// `matches`, plus the cap semantics the product tool relies on: a capped result is
// explicitly NOT a complete match set, and a caller that cannot tell will treat a
// truncated answer as exhaustive.
const cap = Number.isFinite(limit) ? Math.max(1, Number(limit)) : 40;
return {
matches: all.slice(0, cap).map(brief),
total: all.length,
truncated: all.length > cap,
};
},
// `nodes_list` is DELIBERATELY NOT IMPLEMENTED. In the product it lists the custom-node
// PACKS installed via the user's Manager — not canvas nodes, which is what a naive
// reading of the name produces and what the first version of this returned. This mock
// has no Manager and no packs, so any answer it gives is a fabricated Manager state: an
// empty list says "nothing installed" and sends a dependency check off to install
// things, and a populated one is simply invented. The unknown-command error names the
// harness, which is the honest answer here.
graph_select_nodes: ({ node_ids }) => ({ selected: node_ids }),
// #1400 — the orchestrator pulls this on every hello once its minimum-version
// table covers the command; a mock advertising a current version but answering
// "unknown command" is the harness lie #1384 exists to catch. The real reply
// shape with the honest content for this mock: its registry proves nothing
// virtual.
graph_get_virtual_types: () => ({ ok: true, virtual_types: [], virtual_type_count: 0 }),
set_todo: ({ items }) => ({ ok: true, count: (items || []).length }),
ask_user: (m) => (m.options && m.options[0] && m.options[0].label) || "yes",
};
return { nodes, EXEC };
}
/**
* The smoke's PASS/FAIL, as a function rather than an expression buried in a report.
*
* `builtOnCanvas` is part of it. It was printed as one of the four criteria and left out of
* the verdict (codex), so a run that discovered the pack and applied NOTHING passed while
* printing "built nodes on the live canvas: NO" — including the zero-node load this mock
* used to produce.
*/
export function parityVerdict({ discovery, discoveredKrea2, appliedPack, builtOnCanvas }) {
// `appliedPack` is required as well (codex). Without it an agent could read the Krea2
// knowledge, drop one unrelated node on the canvas, and PASS — which demonstrates that
// it can add a node, not that it used what it read.
return Boolean(discovery && discoveredKrea2 && appliedPack && builtOnCanvas);
}