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),
228 lines (215 loc) • 9.66 kB
JavaScript
// Headless MOCK PANEL for unattended testing of the orchestrator agent.
//
// Connects to the bridge like the real panel, implements the graph executors
// in-memory, and RECORDS every command the agent issues — so we can drive the
// agent end-to-end and assert behavior (e.g. that "new workflow" does NOT call
// graph_clear) without a browser or a live ComfyUI.
//
// Env:
// BRIDGE_URL ws url (default ws://127.0.0.1:9111 — a TEST orchestrator)
// TASK the user message to send
// SEED_NODES pre-populate N fake nodes (simulate an existing workflow)
// DURATION_MS how long to listen (default 60000)
import { WebSocket } from "ws";
const url = process.env.BRIDGE_URL || "ws://127.0.0.1:9111";
const task = process.env.TASK || "Read my current graph and tell me what nodes are on it.";
const seed = Number(process.env.SEED_NODES || 0);
const duration = Number(process.env.DURATION_MS || 60000);
const t0 = Date.now();
const ms = () => `${((Date.now() - t0) / 1000).toFixed(1)}s`;
// --- in-memory workflows (multi-tab) ---
let seq = 0;
const workflows = new Map(); // path -> { nodes, title }
let activePath = "workflows/current.json";
function ensureWorkflow(path) {
if (!workflows.has(path)) {
workflows.set(path, { title: path.replace(/.*\//, ""), nodes: new Map() });
}
return workflows.get(path);
}
function wfFor(args) {
return ensureWorkflow(args.workflow_path || activePath);
}
function addNode(wf, type, title) {
const id = ++seq;
wf.nodes.set(id, {
id,
type,
title: title || type,
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 wf.nodes.get(id);
}
const current = ensureWorkflow(activePath);
for (let i = 0; i < seed; i++) addNode(current, `SeedNode${i}`, `Existing node ${i}`);
const blueprints = new Map();
let clipboard = [];
let selection = [];
const commands = []; // { cmd, args }
const says = [];
function summarize(n) {
return { id: n.id, type: n.type, title: n.title, is_subgraph: !!n.is_subgraph, widgets: n.widgets, inputs: n.inputs, outputs: n.outputs };
}
const EXEC = {
graph_get_state: (args) => {
const wf = wfFor(args);
return {
viewing: { scope: "root" },
workflow_path: args.workflow_path || activePath,
node_count: wf.nodes.size,
truncated: false,
nodes: [...wf.nodes.values()].map(summarize),
};
},
graph_add_node: (args) => {
const wf = wfFor(args);
if (!args.class_type) throw new Error("class_type required");
return { added: summarize(addNode(wf, args.class_type, args.title)), workflow_path: args.workflow_path || activePath };
},
graph_remove_node: (args) => {
const wf = wfFor(args);
const n = wf.nodes.get(Number(args.node_id));
if (!n) throw new Error(`no node ${args.node_id}`);
wf.nodes.delete(Number(args.node_id));
return { removed: summarize(n) };
},
graph_clear: (args) => {
const wf = wfFor(args);
const c = wf.nodes.size;
wf.nodes.clear();
return { cleared: c, workflow_path: args.workflow_path || activePath };
},
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: (args) => {
const wf = wfFor(args);
const n = wf.nodes.get(Number(args.node_id));
if (!n) throw new Error(`no node ${args.node_id}`);
const prev = n.widgets[args.widget];
n.widgets[args.widget] = args.value;
return { set: { node_id: args.node_id, widget: args.widget, previous: prev, value: args.value } };
},
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: "mock" }),
workflow_save_as: ({ name }) => ({ saved_as: `workflows/${name}.json` }),
// New-workflow opens a NEW TAB — in the mock it must NOT wipe the seeded graph.
workflow_new: () => ({ created: true, active: "Untitled (new tab)" }),
workflow_list: () => ({
active: { path: activePath, filename: activePath.replace(/.*\//, ""), key: "current" },
open: [...workflows.entries()].map(([path, wf]) => ({
path,
filename: wf.title,
key: path,
active: path === activePath,
modified: true,
persisted: true,
})),
}),
workflow_open: ({ path }) => {
ensureWorkflow(path);
activePath = path;
return { opened: { path, filename: path } };
},
workflow_rename: ({ name }) => ({ renamed: { to: `${name}.json` } }),
workflow_close: ({ path }) => ({ closed: { path } }),
graph_select_nodes: ({ node_ids }) => { selection = (node_ids || []).map(Number); return { selected: node_ids }; },
graph_create_subgraph: (args) => {
const wf = wfFor(args);
const id = ++seq;
wf.nodes.set(id, { id, type: "Subgraph", title: "Subgraph", is_subgraph: true, widgets: {}, inputs: [], outputs: [] });
selection = [id];
return { subgraph: { node_id: id, name: "Subgraph", from_nodes: args.node_ids } };
},
graph_copy_nodes: (args) => {
const wf = wfFor(args);
const ids = (Array.isArray(args.node_ids) && args.node_ids.length ? args.node_ids.map(Number) : selection);
const src = ids.map((id) => wf.nodes.get(Number(id))).filter(Boolean);
if (!src.length) throw new Error("nothing selected to copy");
clipboard = src.map((n) => ({ type: n.type, title: n.title, widgets: { ...n.widgets } }));
return { copied: clipboard.length };
},
graph_paste_nodes: (args) => {
const wf = wfFor(args);
if (!clipboard.length) throw new Error("clipboard empty");
const pasted = clipboard.map((c) => addNode(wf, c.type, c.title));
return { pasted_count: pasted.length, pasted_node_ids: pasted.map((n) => n.id), pasted: pasted.map(summarize) };
},
graph_save_subgraph: (args) => {
const wf = wfFor(args);
const id = args.node_id != null ? Number(args.node_id) : selection[0];
const n = id != null ? wf.nodes.get(id) : null;
if (!n || !n.is_subgraph) throw new Error("select a subgraph node first");
const finalName = (typeof args.name === "string" && args.name.trim()) ? args.name.trim() : (n.title || "Subgraph");
blueprints.set(finalName, { name: finalName, type: `SubgraphBlueprint.${finalName}` });
return { saved: { name: finalName, from_node_id: id, type: `SubgraphBlueprint.${finalName}` } };
},
graph_list_subgraphs: () => ({ count: blueprints.size, blueprints: [...blueprints.values()].map((b) => ({ ...b, display_name: b.name, description: null, is_global: false })) }),
graph_add_subgraph: (args) => {
const wf = wfFor(args);
const key = String(args.name).replace(/^SubgraphBlueprint\./, "");
if (!blueprints.has(key)) throw new Error(`No blueprint "${args.name}"`);
const id = ++seq;
wf.nodes.set(id, { id, type: `SubgraphBlueprint.${key}`, title: key, is_subgraph: true, widgets: {}, inputs: [], outputs: [] });
return { added: summarize(wf.nodes.get(id)), from_blueprint: `SubgraphBlueprint.${key}` };
},
};
const sock = new WebSocket(url);
let gotReply = false;
sock.on("open", () => {
console.log(ms(), "connected", url, `(seed=${seed} nodes)`);
sock.send(JSON.stringify({ type: "hello", tab_id: "mock-panel-0001", title: "mock" }));
setTimeout(() => {
console.log(ms(), `-> TASK: ${task}`);
sock.send(JSON.stringify({ type: "user_message", text: task }));
}, 1500);
});
sock.on("message", (buf) => {
let m;
try { m = JSON.parse(buf.toString()); } catch { return; }
if (typeof m.rid === "string" && typeof m.cmd === "string") {
commands.push({ cmd: m.cmd, args: m });
let reply;
try {
const fn = EXEC[m.cmd];
if (!fn) throw new Error(`unknown cmd ${m.cmd}`);
reply = { rid: m.rid, ok: true, result: fn(m) };
} catch (err) {
reply = { rid: m.rid, ok: false, error: err.message };
}
console.log(ms(), ` <cmd ${m.cmd}>`, JSON.stringify(m).slice(0, 90));
try { sock.send(JSON.stringify(reply)); } catch {}
return;
}
if (m.type === "say") { gotReply = true; says.push(m.text); console.log(ms(), `<< say: ${String(m.text).slice(0, 110)}`); }
else if (m.type === "models") console.log(ms(), `<< models(${m.models.length})`);
else if (m.type === "agent_status") console.log(ms(), `<< status ctx=${m.context_pct}`);
else console.log(ms(), `<< ${m.type}${m.kind ? "/" + m.kind : ""}`);
});
sock.on("error", (e) => console.error(ms(), "ERR", e.message));
setTimeout(() => {
const counts = {};
for (const c of commands) counts[c.cmd] = (counts[c.cmd] || 0) + 1;
console.log("\n===== SUMMARY =====");
console.log("reply:", gotReply ? "yes" : "NO");
console.log("commands:", JSON.stringify(counts));
console.log("graph_clear called:", counts.graph_clear ? `YES x${counts.graph_clear} (DESTRUCTIVE!)` : "no");
console.log("workflow_new called:", counts.workflow_new ? `yes x${counts.workflow_new} (correct for new workflow)` : "no");
console.log("final node count (active):", current.nodes.size, `(seeded ${seed})`);
console.log("workflows:", [...workflows.keys()].join(", "));
console.log("say count:", says.length);
sock.close();
process.exit(0);
}, duration);