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),
284 lines (268 loc) • 14.8 kB
JavaScript
// CODEX KNOWLEDGE-PARITY SMOKE — proves the Codex backend has the SAME bundled
// family expertise Claude gets natively. Claude loads all plugin skills; Codex
// loads none, but now reaches the identical knowledge through the comfyui MCP's
// list_packs tool (actions "skill_list" / "skill_read" / "list" / "read_workflow").
//
// Starts a codex-mode orchestrator + a headless mock panel (graph executors),
// points the headless comfyui MCP's COMFYUI_MCP_TOOL_TRACE at a temp JSONL file
// so we can observe the skill/pack tool calls (they ride the stdio MCP, not the
// panel bridge), then prompts "set up a krea2 workflow on my canvas" and asserts:
// 1) Codex DISCOVERED the krea2 family via action:"skill_list"/"skill_read" (or
// the pack equivalents action:"list"/"read_workflow") — NOT from-scratch guessing.
// 2) Codex APPLIED the pack / loaded the pack's ready workflow (action:"read_workflow"
// and/or apply_manifest, then built nodes on the canvas) — NOT a generic graph.
//
// node scripts/codex-knowledge-parity-smoke.mjs
//
// Env: TEST_PORT (default 9151), SCENARIO_CAP_MS (default 240000 — Codex + reading
// a full SKILL.md is slow).
import { spawn } from "node:child_process";
import { makeGraph, parityVerdict, MOCK_WORKFLOW_UUID } from "./knowledge-parity-mock-graph.mjs";
import { fileURLToPath } from "node:url";
import { tmpdir } from "node:os";
import { join } from "node:path";
import fs from "node:fs";
import net from "node:net";
import { WebSocket } from "ws";
/** Panel version the mock advertises (#1384). Kept in step with the product's own derived
* fence minimum by src/__tests__/scripts/knowledge-parity-mock-version.test.ts — a raised
* floor fails that test instead of silently refusing every graph write in the smoke. */
const MOCK_PANEL_VERSION = "0.15.101";
/** A well-formed workflow uuid, so the per-command stamp fence has something to fence on. */
// MOCK_WORKFLOW_UUID is imported above — it belongs with the executors that answer with it.
const PORT = Number(process.env.TEST_PORT || 9151);
const MCP_ENTRY = fileURLToPath(new URL("../dist/index.js", import.meta.url));
const COMFY_PATH = fileURLToPath(new URL("..", import.meta.url)); // a real dir w/ packs/ + plugin/
const DEAD_COMFY = "http://127.0.0.1:9";
const CAP_MS = Number(process.env.SCENARIO_CAP_MS || 240000);
const TRACE = join(tmpdir(), `comfyui-mcp-tooltrace-${PORT}-${Date.now()}.jsonl`);
function waitForPort(port, timeoutMs = 30000) {
const start = Date.now();
return new Promise((resolve, reject) => {
const tick = () => {
const s = net.connect(port, "127.0.0.1");
s.on("connect", () => { s.destroy(); resolve(true); });
s.on("error", () => { s.destroy(); if (Date.now() - start > timeoutMs) reject(new Error("port timeout")); else setTimeout(tick, 300); });
};
tick();
});
}
/** Wait for a line to appear in a log file the child is writing. */
function waitForLine(path, needle, timeoutMs = 60000) {
const start = Date.now();
return new Promise((resolve, reject) => {
const tick = () => {
let text = "";
try {
text = fs.readFileSync(path, "utf8");
} catch {
// Not created yet.
}
if (text.includes(needle)) return resolve(true);
if (Date.now() - start > timeoutMs) {
return reject(new Error(`timed out waiting for "${needle}" in ${path}`));
}
setTimeout(tick, 250);
};
tick();
});
}
function runScenario(task) {
return new Promise((resolve) => {
const { nodes, EXEC } = makeGraph();
const commands = [];
const says = [];
let done = false, idleTimer = null;
// SEND AN ORIGIN, because a real panel does (#1384). A browser sets this header on the
// upgrade and forbids page JS from overriding it, so the bridge treats it as trusted
// provenance of the page the socket runs in — and refuses every graph EDIT without one.
// The `ws` library sends no Origin by default, so the mock read as a relay connection
// of unknown provenance and the smoke reported a knowledge failure that was really its
// own handshake. The refusal is correct product behaviour; the mock was the thing
// pretending to be a browser without doing what a browser does.
const ws = new WebSocket(`ws://127.0.0.1:${PORT}`, {
headers: { Origin: DEAD_COMFY },
});
const hard = setTimeout(finish, CAP_MS);
function finish() {
if (done) return; done = true;
clearTimeout(hard); if (idleTimer) clearTimeout(idleTimer);
try { ws.close(); } catch {}
const counts = {};
for (const c of commands) counts[c] = (counts[c] || 0) + 1;
resolve({ counts, says, finalNodes: nodes.size });
}
ws.on("open", () => {
// #1384 — ADVERTISE A PANEL VERSION AND THE FENCE CAPABILITIES.
//
// The hello carried only tab_id and title, so the graph-write fence refused
// `graph_clear` before the mock executor ever saw it: "this tab advertised NO panel
// version". The smoke exercises graph writes, so a mock that cannot pass the write
// fence tests nothing it claims to.
//
// MOCK_PANEL_VERSION is asserted against the product's own derived minimum by
// src/__tests__/scripts/knowledge-parity-mock-version.test.ts. A literal here is a
// second copy of a number the code already computes — it went stale once (the fence
// floor moved to 0.11.35) and would have again, since #1359 raised
// requiredPanelVersion() to 0.13.0. The ratchet makes a raised floor fail a test
// that names this file, instead of surfacing months later as a fence bug.
ws.send(
JSON.stringify({
type: "hello",
tab_id: `codex-kp-smoke`,
title: "smoke",
panel_version: MOCK_PANEL_VERSION,
enforces_workflow_stamp: true,
enforces_workflow_stamp_at_write: true,
enforces_expected_node_type_at_write: true,
enforces_expected_scope_at_write: true,
enforces_expected_scope_graph_identity_at_write: true,
enforces_promoted_parent_rail_at_write: true,
workflow_uuid: MOCK_WORKFLOW_UUID,
}),
);
setTimeout(() => { console.log(` -> TASK: ${task}`); ws.send(JSON.stringify({ type: "user_message", text: task })); }, 1500);
});
ws.on("message", (buf) => {
let m; try { m = JSON.parse(buf.toString()); } catch { return; }
if (typeof m.rid === "string" && typeof m.cmd === "string") {
commands.push(m.cmd);
let reply;
// ENFORCE THE STAMP WE ADVERTISE (#1384, codex). The hello claims
// `enforces_workflow_stamp` and `enforces_workflow_stamp_at_write`; a mock that
// claims them and then executes whatever arrives is certifying behaviour it does
// not have, so the smoke would pass against a stale-stamp bug that a real panel
// refuses. A command carrying no stamp is left alone — reads are not fenced.
const stamp = m.workflow_uuid ?? m.expected_workflow_uuid;
if (typeof stamp === "string" && stamp !== MOCK_WORKFLOW_UUID) {
ws.send(
JSON.stringify({
rid: m.rid,
ok: false,
error: `workflow stamp mismatch: command carried ${stamp}, this tab holds ${MOCK_WORKFLOW_UUID}`,
}),
);
return;
}
try {
const fn = EXEC[m.cmd];
// SAY WHAT THIS IS. `unknown graph_outline` from a tab advertising a current
// panel version reads as a product defect, and an agent with a bug-report tool
// will file it as one. Naming the harness in the error costs nothing and stops
// the next run from opening an issue about a stub.
if (!fn)
throw new Error(
`unknown ${m.cmd} — this tab is the knowledge-parity SMOKE MOCK ` +
`(scripts/codex-knowledge-parity-smoke.mjs), not a real panel. It implements a ` +
`subset of the bridge. Do not file this as a panel defect; work with what is ` +
`available or say the command is unavailable here.`,
);
reply = { rid: m.rid, ok: true, result: fn(m) };
}
catch (e) { reply = { rid: m.rid, ok: false, error: e.message }; }
console.log(` <cmd ${m.cmd}>`);
try { ws.send(JSON.stringify(reply)); } catch {}
return;
}
if (m.type === "say") { says.push(m.text); console.log(` << say: ${String(m.text).slice(0, 160)}`); }
else if (m.type === "ack" && m.kind === "ready") console.log(` << ready (${m.agent}, backend=${m.backend})`);
else if (m.type === "ack" && m.kind === "degraded") console.log(` << DEGRADED ack — backend not healthy`);
if (m.type === "turn" && m.state === "done") {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(finish, 6000);
}
});
ws.on("error", () => {});
});
}
function readTrace() {
try {
return fs.readFileSync(TRACE, "utf8").trim().split(/\n+/).filter(Boolean).map((l) => JSON.parse(l));
} catch { return []; }
}
async function main() {
console.log(`[kp-smoke] starting CODEX orchestrator on :${PORT} (COMFYUI_URL=${DEAD_COMFY}, COMFYUI_PATH=${COMFY_PATH})`);
console.log(`[kp-smoke] tool trace → ${TRACE}`);
const env = {
...process.env,
PANEL_AGENT_BACKEND: "codex",
COMFYUI_MCP_BRIDGE_PORT: String(PORT),
COMFYUI_URL: DEAD_COMFY,
// Local mode so apply_manifest is available AND so the comfyui MCP resolves
// packs/ + plugin/ against this real repo dir.
COMFYUI_PATH: COMFY_PATH,
COMFYUI_MCP_TOOL_TRACE: TRACE,
};
delete env.COMFYUI_MCP_PARENT_PID;
const logPath = fileURLToPath(new URL("../codex-kp-smoke-orch.log", import.meta.url));
const logFd = fs.openSync(logPath, "w");
const orch = spawn(process.execPath, [MCP_ENTRY, "--panel-orchestrator"], { env, stdio: ["ignore", logFd, logFd] });
let exitCode = 1;
try {
// WAIT FOR THE ORCHESTRATOR TO SAY IT IS READY (#1384).
//
// The bridge binds FIRST and the per-tab message handler is installed much later —
// ~4 seconds apart on this machine. Probing the bridge port declared the orchestrator
// "up" while it was still booting, so the scenario's user_message arrived before
// `onPanelMessage` existed and was dropped: the run reported "Codex consulted bundled
// skills: NO" with an EMPTY tool trace, a harness race indistinguishable in the output
// from the knowledge failure this smoke exists to detect.
//
// Waiting for a LATER PORT is not a fix either (codex): the console binds before the
// handler is installed, and COMFYUI_MCP_CONSOLE_PORT can move it, so a hard-coded
// PORT+3 can wait for something that never binds. The "ready" line is printed after
// the handler is in place, and it is the orchestrator's own statement rather than an
// inference from one of its side effects.
await waitForPort(PORT);
await waitForLine(logPath, "[panel-orchestrator] ready —");
console.log("[kp-smoke] orchestrator up.\n");
console.log("• Scenario: set up a krea2 workflow on my canvas");
const r = await runScenario("Clear my workflow and set up a krea2 text-to-image workflow on my canvas. Use whatever ready expertise comfyui-mcp already ships if there is any — don't reinvent the graph.");
const trace = readTrace();
// Since 0.50.0 slice 9 every knowledge call is `list_packs` and the action
// rides in args, so the trace is keyed by action rather than by tool name.
const actions = trace.map((t) => t.args?.action);
const byAction = (action) => trace.filter((t) => t.args?.action === action);
const readSkillArgs = byAction("skill_read").map((t) => t.args?.name);
const readPackArgs = byAction("read_workflow").map((t) => t.args?.name);
const calledListSkills = actions.includes("skill_list");
const calledReadSkill = actions.includes("skill_read");
const calledListPacks = actions.includes("list");
const calledReadPack = actions.includes("read_workflow");
const discoveredKrea2 =
readSkillArgs.some((n) => String(n || "").includes("krea2")) ||
readPackArgs.some((n) => String(n || "").includes("krea2"));
// Discovery = consulted the bundled knowledge (skills and/or packs) for the family.
const discovery = calledListSkills || calledReadSkill || calledListPacks || calledReadPack;
// Applied ready expertise = read the pack workflow (the expert graph) and/or
// built nodes on the canvas from it.
// THE CANVAS, not one command that changes it (codex). The PREFERRED path is a single
// `graph_load` of the pack's ready workflow, which populates the canvas without a single
// `graph_add_node` — so counting adds alone failed the very route this smoke is meant to
// reward.
const builtOnCanvas = r.finalNodes > 0 || (r.counts.graph_add_node || 0) >= 1;
const appliedPack = calledReadPack || discoveredKrea2;
console.log(`\n===== CODEX KNOWLEDGE-PARITY SMOKE =====`);
console.log(`tool trace (list_packs actions): ${JSON.stringify(actions)}`);
console.log(`action:"skill_read" names: ${JSON.stringify(readSkillArgs)}`);
console.log(`action:"read_workflow" names: ${JSON.stringify(readPackArgs)}`);
console.log(`all bridge commands: ${JSON.stringify(r.counts)}`);
console.log(`---`);
console.log(`Codex consulted bundled skills/packs (list_packs actions skill_list/skill_read/list/read_workflow): ${discovery ? "YES" : "NO"}`);
console.log(`Codex discovered the krea2 family (krea2-* skill or pack): ${discoveredKrea2 ? "YES" : "NO"}`);
console.log(`Codex applied the pack / read its ready workflow: ${appliedPack ? "YES" : "NO"}`);
console.log(`Codex built nodes on the live canvas: ${builtOnCanvas ? "YES" : "NO"}`);
// PASS = it discovered the family knowledge, used the ready pack expertise, AND the
// canvas actually changed. `builtOnCanvas` was printed as a criterion and then left out
// of the verdict (codex), so a run that read the pack and applied NOTHING — the
// zero-node load this file also fixes — passed while reporting "built nodes: NO".
exitCode = parityVerdict({ discovery, discoveredKrea2, appliedPack, builtOnCanvas }) ? 0 : 1;
console.log(`\n${exitCode === 0 ? "PASS" : "FAIL"} — knowledge parity ${exitCode === 0 ? "achieved" : "NOT achieved"}.`);
} catch (e) {
console.error("[kp-smoke] ERROR:", e.message);
} finally {
try { orch.kill("SIGTERM"); } catch {}
setTimeout(() => { try { orch.kill("SIGKILL"); } catch {}; console.log(`[kp-smoke] orchestrator log: ${logPath}`); process.exit(exitCode); }, 1500);
}
}
main();