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),
151 lines (136 loc) • 5.81 kB
JavaScript
// E2E: a small local model (via Ollama) drives the compact tool mode
// (COMFYUI_MCP_TOOL_MODE=compact) over real MCP stdio — the setup issue #97
// asks for. Requires: `npm run build`, an Ollama server, and a pulled
// tool-calling model (default qwen3:4b; gemma3 has no native tool support).
//
// npm run test:local-llm
// OLLAMA_MODEL=llama3.2:3b OLLAMA_HOST=http://127.0.0.1:11434 npm run test:local-llm
//
// Pass criteria:
// 1. the compact server exposes exactly 3 meta-tools
// 2. the model navigates the catalog (list_tools and/or describe_tool) unaided
// 3. call_tool dispatches a real underlying tool successfully
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const OLLAMA = process.env.OLLAMA_HOST ?? "http://127.0.0.1:11434";
const MODEL = process.env.OLLAMA_MODEL ?? "qwen3:4b";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const fail = (msg) => {
console.error(`FAIL: ${msg}`);
process.exit(1);
};
try {
const res = await fetch(`${OLLAMA}/api/version`);
if (!res.ok) throw new Error(`http ${res.status}`);
} catch (err) {
fail(`Ollama not reachable at ${OLLAMA} (${err.message}). Start it with \`ollama serve\` and pull the model: \`ollama pull ${MODEL}\`.`);
}
const transport = new StdioClientTransport({
command: process.execPath,
args: [join(ROOT, "dist", "index.js")],
env: {
...process.env,
COMFYUI_MCP_TOOL_MODE: "compact",
COMFYUI_MCP_PANEL_AUTOINSTALL: "0",
COMFYUI_MCP_AUTOUPDATE: "0",
LOG_LEVEL: "error",
},
});
const mcp = new Client({ name: "local-llm-e2e", version: "0.0.0" });
await mcp.connect(transport);
const { tools } = await mcp.listTools();
console.log(`[mcp] compact mode exposes: ${tools.map((t) => t.name).join(", ")}`);
if (tools.length !== 3) fail(`expected exactly 3 meta-tools, got ${tools.length}`);
const ollamaTools = tools.map((t) => ({
type: "function",
function: { name: t.name, description: t.description, parameters: t.inputSchema },
}));
const messages = [
{
role: "system",
content:
"You control a ComfyUI MCP server through exactly three tools: list_tools (catalog), " +
"describe_tool (one tool's parameters), call_tool (run a tool by name with args). " +
"Always look up a tool with describe_tool before running it with call_tool. " +
"Catalog entries are tool NAMES, not data — never answer from the catalog alone; " +
"complete every task by actually running a tool with call_tool.",
},
{
role: "user",
content:
"Find a tool that can search for ComfyUI custom node packs, then use it to search for " +
"'controlnet' and tell me the name of one node pack from its results.",
},
];
const stats = { toolCalls: [], sawCallTool: false, callToolOk: false, nudges: 0 };
for (let round = 0; round < 10; round++) {
const res = await fetch(`${OLLAMA}/api/chat`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model: MODEL,
messages,
tools: ollamaTools,
stream: false,
options: { num_ctx: 16384, temperature: 0 },
}),
});
if (!res.ok) fail(`ollama /api/chat http ${res.status}: ${await res.text()}`);
const { message } = await res.json();
messages.push(message);
if (!message.tool_calls?.length) {
// Small models sometimes answer straight off the catalog. Real harnesses
// (Hermes, OpenClaw) nudge here; allow the same correction once.
if (!stats.sawCallTool && stats.nudges < 1) {
stats.nudges++;
console.log(`[nudge] model answered without running a tool — correcting`);
messages.push({
role: "user",
content:
"You have not run any tool yet — catalog entries are tool names, not data. " +
"Use describe_tool on a search tool, then run it with call_tool, and answer from its results.",
});
continue;
}
console.log(`\n[model final answer]\n${message.content}\n`);
break;
}
for (const tc of message.tool_calls) {
const name = tc.function.name;
const args =
typeof tc.function.arguments === "string"
? JSON.parse(tc.function.arguments)
: tc.function.arguments;
console.log(`[round ${round + 1}] model -> ${name}(${JSON.stringify(args).slice(0, 160)})`);
stats.toolCalls.push(name);
if (name === "call_tool") stats.sawCallTool = true;
let text;
try {
const result = await mcp.callTool({ name, arguments: args });
text = (result.content ?? [])
.filter((c) => c.type === "text")
.map((c) => c.text)
.join("\n");
if (name === "call_tool" && !result.isError) stats.callToolOk = true;
} catch (err) {
text = `MCP error: ${err.message}`;
}
console.log(` [mcp result ${text.length} chars] ${text.slice(0, 120).replace(/\n/g, " ")}…`);
messages.push({ role: "tool", tool_name: name, content: text.slice(0, 12000) });
}
}
await mcp.close();
// let the stdio child finish tearing down — avoids a libuv assert on Windows
// when process.exit races the transport's child-process close.
await new Promise((r) => setTimeout(r, 250));
console.log(`[stats] toolCalls=[${stats.toolCalls.join(", ")}]`);
const usedCatalog =
stats.toolCalls.includes("list_tools") || stats.toolCalls.includes("describe_tool");
if (stats.sawCallTool && stats.callToolOk && usedCatalog) {
console.log("PASS: local model completed the compact-mode loop (catalog -> dispatch -> answer).");
process.exit(0);
}
fail(`sawCallTool=${stats.sawCallTool} callToolOk=${stats.callToolOk} usedCatalog=${usedCatalog}`);