comfyui-mcp 0.50.21 → 0.50.23

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.
@@ -10,10 +10,50 @@
10
10
  * crashed on a missing file. Unit tests (`npm test`) never exercise the packed
11
11
  * tarball, so this is the gap. Run in CI and as a pre-publish gate.
12
12
  */
13
- import { execSync, spawnSync } from "node:child_process";
14
- import { mkdtempSync, writeFileSync, existsSync, readdirSync } from "node:fs";
13
+ import { execSync, spawn, spawnSync } from "node:child_process";
14
+ import { mkdtempSync, writeFileSync, existsSync, readdirSync, readFileSync } from "node:fs";
15
15
  import { tmpdir } from "node:os";
16
16
  import { join } from "node:path";
17
+ import { pathToFileURL } from "node:url";
18
+
19
+ /**
20
+ * Ask the installed server for its tool list over MCP stdio. Resolves to the
21
+ * names, or null if it never answers within the budget — a silence this script
22
+ * reports rather than reading as "no tools".
23
+ */
24
+ function listTools(entry) {
25
+ return new Promise((resolve) => {
26
+ const child = spawn(process.execPath, [entry], {
27
+ stdio: ["pipe", "pipe", "pipe"],
28
+ env: { ...process.env, COMFYUI_URL: "http://127.0.0.1:59999" },
29
+ });
30
+ const send = (o) => child.stdin.write(JSON.stringify(o) + "\n");
31
+ let buf = "";
32
+ const done = (v) => { try { child.kill(); } catch { /* already gone */ } resolve(v); };
33
+ const timer = setTimeout(() => done(null), 60_000);
34
+ child.stdout.on("data", (d) => {
35
+ buf += d.toString();
36
+ let i;
37
+ while ((i = buf.indexOf("\n")) >= 0) {
38
+ const line = buf.slice(0, i);
39
+ buf = buf.slice(i + 1);
40
+ if (!line.trim()) continue;
41
+ let msg;
42
+ try { msg = JSON.parse(line); } catch { continue; }
43
+ if (msg.id === 1) send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} });
44
+ if (msg.id === 2) {
45
+ clearTimeout(timer);
46
+ done((msg.result?.tools ?? []).map((t) => t.name));
47
+ }
48
+ }
49
+ });
50
+ child.on("error", () => { clearTimeout(timer); done(null); });
51
+ send({
52
+ jsonrpc: "2.0", id: 1, method: "initialize",
53
+ params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "smoke", version: "1" } },
54
+ });
55
+ });
56
+ }
17
57
 
18
58
  const run = (cmd, opts = {}) => {
19
59
  console.log(`$ ${cmd}${opts.cwd ? ` (cwd: ${opts.cwd})` : ""}`);
@@ -67,4 +107,95 @@ if (boot.signal === "SIGTERM" || boot.status === 0) {
67
107
  process.exit(1);
68
108
  }
69
109
 
70
- console.log("✅ pack/install smoke passed — tarball installs cleanly and boots");
110
+ // 5. The published surface actually REGISTERS. Booting proves the process starts;
111
+ // it does not prove the tools are there. A packaging or registration
112
+ // regression that leaves the server up but short a tool would pass step 4 and
113
+ // ship — the user finds out when the tool they wanted is simply absent.
114
+ //
115
+ // Driven over real MCP stdio against the INSTALLED tarball, so this is the
116
+ // surface a user gets, not the one the repo builds. COMFYUI_URL points at a
117
+ // dead port on purpose: registration must not depend on a reachable ComfyUI.
118
+ // The ledger IS the list, not a ceiling: TOOL_NAMES holds every core tool, and
119
+ // MAX_TOOLS beside it is a budget target that merely happens to match today.
120
+ // Counting the list keeps this honest if the two ever diverge.
121
+ const LEDGER = readFileSync(join(process.cwd(), "src/tools/vocabulary.ts"), "utf-8");
122
+ const EXPECTED_CORE = (
123
+ LEDGER.match(/TOOL_NAMES\s*=\s*\[[\s\S]*?\n\]/)?.[0].match(/"[a-z][a-z0-9_]*"/g) ?? []
124
+ ).length;
125
+ const surface = await listTools(join(pkg, "dist/index.js"));
126
+ if (surface === null) {
127
+ console.error("❌ the installed server never answered tools/list");
128
+ process.exit(1);
129
+ }
130
+ // The three compact-mode meta tools (list_tools / describe_tool / call_tool) are
131
+ // registered ALONGSIDE the core surface and are deliberately absent from the
132
+ // ledger, so they are counted out rather than expected in it.
133
+ const META = ["list_tools", "describe_tool", "call_tool"];
134
+ const core = surface.filter((n) => !META.includes(n));
135
+ const missingMeta = META.filter((n) => !surface.includes(n));
136
+ if (EXPECTED_CORE > 0 && core.length !== EXPECTED_CORE) {
137
+ console.error(
138
+ `❌ installed surface has ${core.length} core tools, ledger declares ${EXPECTED_CORE}` +
139
+ `\n registered: ${core.join(", ")}`,
140
+ );
141
+ process.exit(1);
142
+ }
143
+ if (missingMeta.length) {
144
+ console.error(`❌ compact-mode tools missing from the installed surface: ${missingMeta.join(", ")}`);
145
+ process.exit(1);
146
+ }
147
+ console.log(`✅ installed surface registers ${core.length} core tools + ${META.length} compact-mode tools`);
148
+
149
+ // 6. The PANEL surface too. Those 91 tools are the whole reason the panel can
150
+ // drive a live canvas, they ship in the same tarball, and nothing else here
151
+ // checks that the published build still builds them. buildPanelToolDefs is
152
+ // pure — no server, no socket — so this is a direct call into the INSTALLED
153
+ // dist rather than another stdio round trip.
154
+ //
155
+ // Compared against THIS REPO'S build, not against docs/design/panel-surface.txt.
156
+ // That baseline is frozen HISTORY, not the current surface: check-tool-vocabulary
157
+ // requires live ⊆ baseline, so the baseline only ever grows, and a retired panel
158
+ // tool keeps its line there forever. Counting against it passes today only
159
+ // because nothing has been retired yet — the first legitimate retirement would
160
+ // read as "installed build makes 90, ledger declares 92" and fail a RELEASE, the
161
+ // one gate a false alarm is most expensive in. The ledger relationship is already
162
+ // enforced on every PR by check:vocabulary, from both sides; what only this script
163
+ // can see is whether the TARBALL carries the same surface the repo builds, which
164
+ // is a packaging question and is what it now asks.
165
+ //
166
+ // `prepare: tsc` runs during npm pack in step 1, so ./dist is a fresh build of
167
+ // this source. Two separate module instances, same process and env.
168
+ async function panelNames(root, label) {
169
+ try {
170
+ const mod = await import(pathToFileURL(join(root, "dist/orchestrator/panel-tools.js")).href);
171
+ if (typeof mod.buildPanelToolDefs !== "function") {
172
+ console.error(`❌ the ${label} build exports no buildPanelToolDefs`);
173
+ process.exit(1);
174
+ }
175
+ return mod.buildPanelToolDefs().map((d) => d.name);
176
+ } catch (err) {
177
+ console.error(`❌ could not load the ${label} panel tools: ${err?.message ?? err}`);
178
+ process.exit(1);
179
+ }
180
+ }
181
+ const installedPanel = await panelNames(pkg, "installed");
182
+ const repoPanel = await panelNames(process.cwd(), "repo");
183
+ // A comparison of two empty lists is equal and proves nothing — the vacuous pass
184
+ // that the sibling gate tests each carry a floor assertion against.
185
+ if (repoPanel.length < 50) {
186
+ console.error(`❌ the repo build makes only ${repoPanel.length} panel tools — check is not looking at a real surface`);
187
+ process.exit(1);
188
+ }
189
+ const onlyInstalled = installedPanel.filter((n) => !repoPanel.includes(n));
190
+ const onlyRepo = repoPanel.filter((n) => !installedPanel.includes(n));
191
+ if (onlyInstalled.length || onlyRepo.length) {
192
+ console.error(
193
+ `❌ the tarball's panel surface differs from this repo's build` +
194
+ (onlyRepo.length ? `\n in the repo but NOT in the tarball: ${onlyRepo.join(", ")}` : "") +
195
+ (onlyInstalled.length ? `\n in the tarball but NOT in the repo: ${onlyInstalled.join(", ")}` : ""),
196
+ );
197
+ process.exit(1);
198
+ }
199
+ console.log(`✅ tarball's ${installedPanel.length} panel tools match this repo's build`);
200
+
201
+ console.log("✅ pack/install smoke passed — tarball installs cleanly, boots, and registers its tools");
@@ -9,9 +9,13 @@
9
9
  * npx tsx scripts/tools-dump.mts # JSON: {count, tools:[…]}
10
10
  * npx tsx scripts/tools-dump.mts --names # one name per line, registration order
11
11
  * npx tsx scripts/tools-dump.mts --max 30 # exit 1 if count > 30
12
- * npx tsx scripts/tools-dump.mts --golden docs/design/tool-surface.txt
12
+ * npx tsx scripts/tools-dump.mts --golden path/to/expected.txt
13
13
  * # diff names vs a committed golden
14
- * npx tsx scripts/tools-dump.mts --write-golden docs/design/tool-surface.txt
14
+ * npx tsx scripts/tools-dump.mts --write-golden path/to/expected.txt
15
+ *
16
+ * The golden is a snapshot of the LIVE surface. It is NOT docs/design/tool-surface.txt
17
+ * or docs/design/panel-surface.txt — those are hash-pinned retirement baselines and
18
+ * both invocations are refused against them; see RETIREMENT_BASELINES below.
15
19
  *
16
20
  * COMFYUI_URL is set so src/config.ts skips its network port-probe at import
17
21
  * time (same reason npm run docs:gen sets it).
@@ -116,6 +120,48 @@ if (max !== undefined) {
116
120
  }
117
121
  }
118
122
 
123
+ /**
124
+ * The two hash-pinned RETIREMENT BASELINES. Neither is a live-surface golden, and
125
+ * pointing this script at either is a footgun in both directions.
126
+ *
127
+ * They are history, not state: docs/design/tool-surface.txt holds every core name
128
+ * that has EVER existed (195), against which check-tool-vocabulary enforces
129
+ * `BASELINE \ TOOL_NAMES ⊆ DEAD_NAMES`. The live surface is 37 after the 0.50.0
130
+ * consolidation and will never equal it again.
131
+ *
132
+ * So `--golden docs/design/tool-surface.txt` — an invocation this script's own
133
+ * usage text used to advertise — reports 158 removals and exits 1 on a perfectly
134
+ * healthy repo. That alone is only noise. The danger is the remedy printed
135
+ * underneath it: "re-run with --write-golden", which would overwrite the baseline
136
+ * with the 37 live names and DELETE the other 158. Deleting a line from a
137
+ * retirement baseline is exactly how the ratchet is disarmed for that name — every
138
+ * stale reference to it stops being an error. One typo-free, instruction-following
139
+ * run would have silently disarmed the repo's main vocabulary gate for 158 names,
140
+ * leaving only the SHA pin, whose own error text invites updating the hash.
141
+ *
142
+ * Refused rather than warned: there is no correct reading of either invocation.
143
+ * The baseline is maintained by APPENDING a newly shipped name and updating its
144
+ * SHA deliberately, which is a reviewed edit rather than a bulk rewrite.
145
+ */
146
+ const RETIREMENT_BASELINES = ["tool-surface.txt", "panel-surface.txt"];
147
+ const isRetirementBaseline = (p: string): boolean =>
148
+ RETIREMENT_BASELINES.some((b) => p.replace(/\\/g, "/").endsWith(`docs/design/${b}`));
149
+
150
+ for (const opt of ["--golden", "--write-golden"] as const) {
151
+ const p = value(opt);
152
+ if (p !== undefined && isRetirementBaseline(p)) {
153
+ usage(
154
+ `${p} is a hash-pinned RETIREMENT BASELINE (every name that has ever existed), ` +
155
+ `not a golden of the live surface — ${opt} would ` +
156
+ (opt === "--write-golden"
157
+ ? `overwrite it with today's ${names.length} live names and delete the rest, disarming the dead-name ratchet for every name dropped.`
158
+ : `report every retired name as a removal and exit 1 on a healthy repo.`) +
159
+ `\n What enforces it: npm run check:vocabulary (BASELINE \\ TOOL_NAMES ⊆ DEAD_NAMES).` +
160
+ `\n To add a newly shipped tool: append the name, then update BASELINE_SHA256 in src/tools/vocabulary.ts.`,
161
+ );
162
+ }
163
+ }
164
+
119
165
  const writeGolden = value("--write-golden");
120
166
  if (writeGolden) {
121
167
  writeFileSync(writeGolden, `${names.join("\n")}\n`, "utf8");