comfyui-mcp 0.23.4 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -9
- package/dist/index.js +55 -5
- package/dist/index.js.map +1 -1
- package/dist/orchestrator/agent-backend.js +18 -0
- package/dist/orchestrator/agent-backend.js.map +1 -1
- package/dist/orchestrator/backend-readiness.js +20 -0
- package/dist/orchestrator/backend-readiness.js.map +1 -1
- package/dist/orchestrator/index.js +44 -5
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/ollama-backend.js +668 -0
- package/dist/orchestrator/ollama-backend.js.map +1 -0
- package/dist/services/agent-setup.js +122 -0
- package/dist/services/agent-setup.js.map +1 -0
- package/dist/services/ui-bridge.js +58 -0
- package/dist/services/ui-bridge.js.map +1 -1
- package/dist/tools/catalog.js +77 -0
- package/dist/tools/catalog.js.map +1 -0
- package/dist/tools/compact.js +174 -0
- package/dist/tools/compact.js.map +1 -0
- package/dist/tools/index.js +74 -47
- package/dist/tools/index.js.map +1 -1
- package/dist/transport/cli.js +45 -0
- package/dist/transport/cli.js.map +1 -1
- package/package.json +5 -2
- package/scripts/arena-bestof.mjs +88 -0
- package/scripts/arena-graphic.mjs +119 -0
- package/scripts/llm-arena.mjs +553 -0
- package/scripts/panel-smoke.mjs +217 -0
- package/scripts/test-local-llm.mjs +150 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Panel smoke test for LLM backends: for each model, spawn a dedicated panel
|
|
3
|
+
// orchestrator (on its own bridge port so a live one is untouched), speak the
|
|
4
|
+
// panel's bridge protocol (hello → ready → user turn), and verify the model
|
|
5
|
+
// completes a real tool-using turn without choking.
|
|
6
|
+
//
|
|
7
|
+
// npm run build && node scripts/panel-smoke.mjs
|
|
8
|
+
// SMOKE_MODELS="gemma4:e4b,qwen3:4b" node scripts/panel-smoke.mjs
|
|
9
|
+
// SMOKE_MODELS="xiaomi/mimo-v2.5,deepseek/deepseek-v3.2" OPENROUTER_API_KEY=sk-... \
|
|
10
|
+
// node scripts/panel-smoke.mjs
|
|
11
|
+
//
|
|
12
|
+
// Model spec: an Ollama tag (has ":", e.g. gemma4:e4b) runs via local Ollama;
|
|
13
|
+
// a vendor slug (has "/", e.g. xiaomi/mimo-v2.5) runs via the OpenAI-compatible
|
|
14
|
+
// dialect against SMOKE_BASE_URL (default OpenRouter) with OPENROUTER_API_KEY.
|
|
15
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
16
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import WebSocket from "ws";
|
|
20
|
+
|
|
21
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
22
|
+
const BRIDGE_PORT = Number(process.env.SMOKE_BRIDGE_PORT ?? 9280);
|
|
23
|
+
const BASE_URL = process.env.SMOKE_BASE_URL ?? "https://openrouter.ai/api/v1";
|
|
24
|
+
const TURN_TIMEOUT_MS = Number(process.env.SMOKE_TURN_TIMEOUT_MS ?? 240_000);
|
|
25
|
+
const MODELS = (process.env.SMOKE_MODELS ?? "gemma4:e4b")
|
|
26
|
+
.split(",")
|
|
27
|
+
.map((m) => m.trim())
|
|
28
|
+
.filter(Boolean);
|
|
29
|
+
const PROMPT =
|
|
30
|
+
"Check whether the ComfyUI server is healthy and tell me the GPU name and free VRAM. Keep it short.";
|
|
31
|
+
|
|
32
|
+
function envFor(model) {
|
|
33
|
+
const hosted = model.includes("/");
|
|
34
|
+
return {
|
|
35
|
+
...process.env,
|
|
36
|
+
PANEL_AGENT_BACKEND: "ollama",
|
|
37
|
+
COMFYUI_MCP_BRIDGE_PORT: String(BRIDGE_PORT),
|
|
38
|
+
COMFYUI_MCP_OLLAMA_MODEL: model,
|
|
39
|
+
...(hosted
|
|
40
|
+
? {
|
|
41
|
+
COMFYUI_MCP_OLLAMA_API: "openai",
|
|
42
|
+
COMFYUI_MCP_OLLAMA_BASE_URL: BASE_URL,
|
|
43
|
+
...(process.env.OPENROUTER_API_KEY
|
|
44
|
+
? { COMFYUI_MCP_OLLAMA_API_KEY: process.env.OPENROUTER_API_KEY }
|
|
45
|
+
: {}),
|
|
46
|
+
}
|
|
47
|
+
: {}),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function startOrchestrator(model) {
|
|
52
|
+
const child = spawn(process.execPath, [join(ROOT, "dist", "index.js"), "--panel-orchestrator"], {
|
|
53
|
+
env: envFor(model),
|
|
54
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
55
|
+
});
|
|
56
|
+
let log = "";
|
|
57
|
+
child.stdout.on("data", (d) => (log += d));
|
|
58
|
+
child.stderr.on("data", (d) => (log += d));
|
|
59
|
+
return { child, log: () => log };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function waitForPort(port, timeoutMs = 30_000) {
|
|
63
|
+
const started = Date.now();
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
const tryOnce = () => {
|
|
66
|
+
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
|
67
|
+
ws.on("open", () => {
|
|
68
|
+
ws.close();
|
|
69
|
+
resolve(undefined);
|
|
70
|
+
});
|
|
71
|
+
ws.on("error", () => {
|
|
72
|
+
if (Date.now() - started > timeoutMs) reject(new Error(`bridge :${port} never came up`));
|
|
73
|
+
else setTimeout(tryOnce, 500);
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
tryOnce();
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function runTurn(model) {
|
|
81
|
+
return new Promise((resolve) => {
|
|
82
|
+
const tab = `smoke-${Math.random().toString(36).slice(2, 8)}`;
|
|
83
|
+
let helloTimer = null;
|
|
84
|
+
const ws = new WebSocket(`ws://127.0.0.1:${BRIDGE_PORT}`);
|
|
85
|
+
const r = {
|
|
86
|
+
model,
|
|
87
|
+
ready: false,
|
|
88
|
+
degraded: false,
|
|
89
|
+
streamed: 0,
|
|
90
|
+
toolChips: 0,
|
|
91
|
+
sayText: "",
|
|
92
|
+
turnDone: false,
|
|
93
|
+
error: "",
|
|
94
|
+
seconds: 0,
|
|
95
|
+
};
|
|
96
|
+
const started = Date.now();
|
|
97
|
+
let sent = false;
|
|
98
|
+
const finish = () => {
|
|
99
|
+
r.seconds = Math.round((Date.now() - started) / 1000);
|
|
100
|
+
try {
|
|
101
|
+
ws.close();
|
|
102
|
+
} catch {
|
|
103
|
+
/* closing */
|
|
104
|
+
}
|
|
105
|
+
if (helloTimer) clearInterval(helloTimer);
|
|
106
|
+
resolve(r);
|
|
107
|
+
};
|
|
108
|
+
const timer = setTimeout(() => {
|
|
109
|
+
r.error ||= `timeout after ${TURN_TIMEOUT_MS / 1000}s`;
|
|
110
|
+
finish();
|
|
111
|
+
}, TURN_TIMEOUT_MS);
|
|
112
|
+
|
|
113
|
+
// The bridge LISTENS before the orchestrator finishes wiring its message
|
|
114
|
+
// handler (~1s gap) — a single early hello is dropped silently. The real
|
|
115
|
+
// panel re-sends hello; do the same until the orchestrator answers.
|
|
116
|
+
ws.on("open", () => {
|
|
117
|
+
const hello = JSON.stringify({ type: "hello", tab_id: tab, backend: "ollama", title: "smoke" });
|
|
118
|
+
ws.send(hello);
|
|
119
|
+
helloTimer = setInterval(() => {
|
|
120
|
+
if (!r.ready && !sent) ws.send(hello);
|
|
121
|
+
else clearInterval(helloTimer);
|
|
122
|
+
}, 2000);
|
|
123
|
+
});
|
|
124
|
+
ws.on("error", (err) => {
|
|
125
|
+
r.error = `ws: ${err.message}`;
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
finish();
|
|
128
|
+
});
|
|
129
|
+
ws.on("message", (raw) => {
|
|
130
|
+
let msg;
|
|
131
|
+
try {
|
|
132
|
+
msg = JSON.parse(String(raw));
|
|
133
|
+
} catch {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (msg.type === "ack" && msg.kind === "ready" && !sent) {
|
|
137
|
+
r.ready = true;
|
|
138
|
+
sent = true;
|
|
139
|
+
ws.send(JSON.stringify({ type: "user_message", tab_id: tab, mid: "m1", text: PROMPT }));
|
|
140
|
+
} else if (msg.type === "ack" && msg.kind === "degraded") {
|
|
141
|
+
r.degraded = true;
|
|
142
|
+
r.error = "degraded ack (backend could not enumerate models)";
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
finish();
|
|
145
|
+
} else if (msg.type === "stream") {
|
|
146
|
+
r.streamed++;
|
|
147
|
+
} else if (msg.type === "agent_event" && msg.event?.type === "tool_call") {
|
|
148
|
+
r.toolChips++;
|
|
149
|
+
} else if (msg.type === "say" && sent && !String(msg.text).startsWith("🟢")) {
|
|
150
|
+
r.sayText = String(msg.text).slice(0, 200);
|
|
151
|
+
} else if (msg.type === "turn" && msg.state === "done" && sent) {
|
|
152
|
+
r.turnDone = true;
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
setTimeout(finish, 300);
|
|
155
|
+
} else if (msg.type === "run_error") {
|
|
156
|
+
r.error = String(msg.message ?? "run_error").slice(0, 200);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Refuse to run against a squatter: if something already listens on the smoke
|
|
163
|
+
// port, waitForPort would "succeed" against a foreign/stale orchestrator and
|
|
164
|
+
// every verdict would be meaningless.
|
|
165
|
+
try {
|
|
166
|
+
await new Promise((resolve, reject) => {
|
|
167
|
+
const probe = new WebSocket(`ws://127.0.0.1:${BRIDGE_PORT}`);
|
|
168
|
+
probe.on("open", () => {
|
|
169
|
+
probe.close();
|
|
170
|
+
reject(new Error(`port ${BRIDGE_PORT} is already in use — stop that process or set SMOKE_BRIDGE_PORT`));
|
|
171
|
+
});
|
|
172
|
+
probe.on("error", () => resolve(undefined)); // connection refused = free
|
|
173
|
+
});
|
|
174
|
+
} catch (err) {
|
|
175
|
+
console.error(String(err.message ?? err));
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
mkdirSync(join(process.cwd(), "arena-results"), { recursive: true });
|
|
180
|
+
const results = [];
|
|
181
|
+
for (const model of MODELS) {
|
|
182
|
+
process.stdout.write(`\n══ smoke: ${model} ══\n`);
|
|
183
|
+
const orch = startOrchestrator(model);
|
|
184
|
+
let r;
|
|
185
|
+
try {
|
|
186
|
+
await waitForPort(BRIDGE_PORT);
|
|
187
|
+
r = await runTurn(model);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
r = { model, ready: false, error: err.message, turnDone: false };
|
|
190
|
+
}
|
|
191
|
+
// Windows SIGTERM doesn't reap a node process tree — a survivor squats the
|
|
192
|
+
// bridge port and every later model silently talks to STALE code. Tree-kill.
|
|
193
|
+
if (process.platform === "win32") {
|
|
194
|
+
try {
|
|
195
|
+
execFileSync("taskkill", ["/pid", String(orch.child.pid), "/T", "/F"], { stdio: "ignore" });
|
|
196
|
+
} catch {
|
|
197
|
+
/* already gone */
|
|
198
|
+
}
|
|
199
|
+
} else {
|
|
200
|
+
orch.child.kill("SIGTERM");
|
|
201
|
+
}
|
|
202
|
+
await new Promise((res) => {
|
|
203
|
+
orch.child.once("exit", res);
|
|
204
|
+
setTimeout(res, 3000);
|
|
205
|
+
});
|
|
206
|
+
const verdict = r.turnDone && r.sayText && !r.error ? "PASS" : r.ready ? "CHOKED" : "NO-START";
|
|
207
|
+
r.verdict = verdict;
|
|
208
|
+
results.push(r);
|
|
209
|
+
process.stdout.write(
|
|
210
|
+
`${verdict}: ready=${r.ready} turnDone=${r.turnDone} streams=${r.streamed ?? 0} say="${(r.sayText ?? "").slice(0, 80).replace(/\n/g, " ")}" ${r.error ? `error=${r.error}` : ""} (${r.seconds ?? "?"}s)\n`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
writeFileSync(join(process.cwd(), "arena-results", "panel-smoke.json"), JSON.stringify(results, null, 2));
|
|
215
|
+
process.stdout.write(`\n══ PANEL SMOKE ══\n`);
|
|
216
|
+
for (const r of results) process.stdout.write(`${String(r.verdict).padEnd(9)} ${r.model}\n`);
|
|
217
|
+
process.exit(results.every((r) => r.verdict === "PASS") ? 0 : 1);
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// E2E: a small local model (via Ollama) drives the compact tool mode
|
|
3
|
+
// (COMFYUI_MCP_TOOL_MODE=compact) over real MCP stdio — the setup issue #97
|
|
4
|
+
// asks for. Requires: `npm run build`, an Ollama server, and a pulled
|
|
5
|
+
// tool-calling model (default qwen3:4b; gemma3 has no native tool support).
|
|
6
|
+
//
|
|
7
|
+
// npm run test:local-llm
|
|
8
|
+
// OLLAMA_MODEL=llama3.2:3b OLLAMA_HOST=http://127.0.0.1:11434 npm run test:local-llm
|
|
9
|
+
//
|
|
10
|
+
// Pass criteria:
|
|
11
|
+
// 1. the compact server exposes exactly 3 meta-tools
|
|
12
|
+
// 2. the model navigates the catalog (list_tools and/or describe_tool) unaided
|
|
13
|
+
// 3. call_tool dispatches a real underlying tool successfully
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
17
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
18
|
+
|
|
19
|
+
const OLLAMA = process.env.OLLAMA_HOST ?? "http://127.0.0.1:11434";
|
|
20
|
+
const MODEL = process.env.OLLAMA_MODEL ?? "qwen3:4b";
|
|
21
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
22
|
+
|
|
23
|
+
const fail = (msg) => {
|
|
24
|
+
console.error(`FAIL: ${msg}`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const res = await fetch(`${OLLAMA}/api/version`);
|
|
30
|
+
if (!res.ok) throw new Error(`http ${res.status}`);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
fail(`Ollama not reachable at ${OLLAMA} (${err.message}). Start it with \`ollama serve\` and pull the model: \`ollama pull ${MODEL}\`.`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const transport = new StdioClientTransport({
|
|
36
|
+
command: process.execPath,
|
|
37
|
+
args: [join(ROOT, "dist", "index.js")],
|
|
38
|
+
env: {
|
|
39
|
+
...process.env,
|
|
40
|
+
COMFYUI_MCP_TOOL_MODE: "compact",
|
|
41
|
+
COMFYUI_MCP_PANEL_AUTOINSTALL: "0",
|
|
42
|
+
COMFYUI_MCP_AUTOUPDATE: "0",
|
|
43
|
+
LOG_LEVEL: "error",
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
const mcp = new Client({ name: "local-llm-e2e", version: "0.0.0" });
|
|
47
|
+
await mcp.connect(transport);
|
|
48
|
+
|
|
49
|
+
const { tools } = await mcp.listTools();
|
|
50
|
+
console.log(`[mcp] compact mode exposes: ${tools.map((t) => t.name).join(", ")}`);
|
|
51
|
+
if (tools.length !== 3) fail(`expected exactly 3 meta-tools, got ${tools.length}`);
|
|
52
|
+
|
|
53
|
+
const ollamaTools = tools.map((t) => ({
|
|
54
|
+
type: "function",
|
|
55
|
+
function: { name: t.name, description: t.description, parameters: t.inputSchema },
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
const messages = [
|
|
59
|
+
{
|
|
60
|
+
role: "system",
|
|
61
|
+
content:
|
|
62
|
+
"You control a ComfyUI MCP server through exactly three tools: list_tools (catalog), " +
|
|
63
|
+
"describe_tool (one tool's parameters), call_tool (run a tool by name with args). " +
|
|
64
|
+
"Always look up a tool with describe_tool before running it with call_tool. " +
|
|
65
|
+
"Catalog entries are tool NAMES, not data — never answer from the catalog alone; " +
|
|
66
|
+
"complete every task by actually running a tool with call_tool.",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
role: "user",
|
|
70
|
+
content:
|
|
71
|
+
"Find a tool that can search for ComfyUI custom node packs, then use it to search for " +
|
|
72
|
+
"'controlnet' and tell me the name of one node pack from its results.",
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
const stats = { toolCalls: [], sawCallTool: false, callToolOk: false, nudges: 0 };
|
|
77
|
+
|
|
78
|
+
for (let round = 0; round < 10; round++) {
|
|
79
|
+
const res = await fetch(`${OLLAMA}/api/chat`, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { "content-type": "application/json" },
|
|
82
|
+
body: JSON.stringify({
|
|
83
|
+
model: MODEL,
|
|
84
|
+
messages,
|
|
85
|
+
tools: ollamaTools,
|
|
86
|
+
stream: false,
|
|
87
|
+
options: { num_ctx: 16384, temperature: 0 },
|
|
88
|
+
}),
|
|
89
|
+
});
|
|
90
|
+
if (!res.ok) fail(`ollama /api/chat http ${res.status}: ${await res.text()}`);
|
|
91
|
+
const { message } = await res.json();
|
|
92
|
+
messages.push(message);
|
|
93
|
+
|
|
94
|
+
if (!message.tool_calls?.length) {
|
|
95
|
+
// Small models sometimes answer straight off the catalog. Real harnesses
|
|
96
|
+
// (Hermes, OpenClaw) nudge here; allow the same correction once.
|
|
97
|
+
if (!stats.sawCallTool && stats.nudges < 1) {
|
|
98
|
+
stats.nudges++;
|
|
99
|
+
console.log(`[nudge] model answered without running a tool — correcting`);
|
|
100
|
+
messages.push({
|
|
101
|
+
role: "user",
|
|
102
|
+
content:
|
|
103
|
+
"You have not run any tool yet — catalog entries are tool names, not data. " +
|
|
104
|
+
"Use describe_tool on a search tool, then run it with call_tool, and answer from its results.",
|
|
105
|
+
});
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
console.log(`\n[model final answer]\n${message.content}\n`);
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const tc of message.tool_calls) {
|
|
113
|
+
const name = tc.function.name;
|
|
114
|
+
const args =
|
|
115
|
+
typeof tc.function.arguments === "string"
|
|
116
|
+
? JSON.parse(tc.function.arguments)
|
|
117
|
+
: tc.function.arguments;
|
|
118
|
+
console.log(`[round ${round + 1}] model -> ${name}(${JSON.stringify(args).slice(0, 160)})`);
|
|
119
|
+
stats.toolCalls.push(name);
|
|
120
|
+
if (name === "call_tool") stats.sawCallTool = true;
|
|
121
|
+
|
|
122
|
+
let text;
|
|
123
|
+
try {
|
|
124
|
+
const result = await mcp.callTool({ name, arguments: args });
|
|
125
|
+
text = (result.content ?? [])
|
|
126
|
+
.filter((c) => c.type === "text")
|
|
127
|
+
.map((c) => c.text)
|
|
128
|
+
.join("\n");
|
|
129
|
+
if (name === "call_tool" && !result.isError) stats.callToolOk = true;
|
|
130
|
+
} catch (err) {
|
|
131
|
+
text = `MCP error: ${err.message}`;
|
|
132
|
+
}
|
|
133
|
+
console.log(` [mcp result ${text.length} chars] ${text.slice(0, 120).replace(/\n/g, " ")}…`);
|
|
134
|
+
messages.push({ role: "tool", tool_name: name, content: text.slice(0, 12000) });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
await mcp.close();
|
|
139
|
+
// let the stdio child finish tearing down — avoids a libuv assert on Windows
|
|
140
|
+
// when process.exit races the transport's child-process close.
|
|
141
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
142
|
+
|
|
143
|
+
console.log(`[stats] toolCalls=[${stats.toolCalls.join(", ")}]`);
|
|
144
|
+
const usedCatalog =
|
|
145
|
+
stats.toolCalls.includes("list_tools") || stats.toolCalls.includes("describe_tool");
|
|
146
|
+
if (stats.sawCallTool && stats.callToolOk && usedCatalog) {
|
|
147
|
+
console.log("PASS: local model completed the compact-mode loop (catalog -> dispatch -> answer).");
|
|
148
|
+
process.exit(0);
|
|
149
|
+
}
|
|
150
|
+
fail(`sawCallTool=${stats.sawCallTool} callToolOk=${stats.callToolOk} usedCatalog=${usedCatalog}`);
|