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),
180 lines (171 loc) • 9.42 kB
text/typescript
/**
* Emit the tool vocabulary as JSON, for consumers that cannot import TypeScript.
*
* npm run vocab:export # rewrite the committed artefact
* npm run vocab:export -- --check # fail if it is stale (CI)
*
* The consumer that matters is the PANEL, whose browser JS calls core tools by
* string literal — `callTool("download_model")` — and names panel tools inside
* hint strings. Nothing type-checks either, so a rename in this repo ships a
* panel whose buttons fail at runtime with a tool-not-found the user reads as
* "the panel is broken". The panel vendors this file and validates every literal
* against it, which turns that class of break into a red build here rather than a
* bug report there.
*
* Deliberately NOT recorded: the git commit, and — after CI taught me the lesson twice
* — the package VERSION either. An artefact that embeds its own commit can never be
* verified as current, because writing it changes HEAD. The version is a milder case of
* exactly the same flaw: it makes the artefact stale on every RELEASE even when the
* vocabulary is untouched. That is precisely what happened — main released 0.48.10 while
* this branch was open, and CI failed with a diff whose only line was
* `-"mcpVersion": "0.48.6" / +"0.48.10"` while `counts.core` sat unchanged at 181. A gate
* that cries wolf on every release gets muted.
*
* Provenance is a HASH OF THE VOCABULARY ITSELF, which is stable across releases and
* changes exactly when the thing it identifies changes. It is also the better primitive
* for the Phase 7 handshake: a server can report its vocabularyHash at runtime and the
* panel can compare, which a version string can never do reliably (two builds of 0.48.10
* could differ, and two vocabularies with different versions could be identical).
*
* `core` is the LEDGER (src/tools/vocabulary.ts), not a live probe: this file is
* about what the vocabulary is DECLARED to be, and registry-surface.test.ts is
* what proves the running server matches. `panel` has no ledger of its own — it is
* read live from buildPanelToolDefs() until the canvas_* consolidation gives it one,
* so an accidental panel rename regenerates cleanly HERE and this script will never
* flag it.
*
* That is a statement about THIS script, not about the repo. check-tool-vocabulary.mts
* closes it from both sides against the frozen docs/design/panel-surface.txt:
* `panelRetiredNotDeclared` (baseline \ live ⊆ DEAD_NAMES) catches the old name
* vanishing without a DEAD_NAMES entry, and `panelMissingFromBaseline` (live ⊆
* baseline) catches the new name appearing without joining the ratchet. A rename
* trips both. Said plainly because the older wording here read as "panel renames are
* unguarded" and cost a later reader a re-implementation of a ratchet that exists.
*
* What `--check` proves is narrow, and worth stating so it is not over-trusted: the
* committed artefact matches this repo's ledger. It says nothing about whether the
* PANEL's vendored copy is current (nothing cross-repo can, short of the Phase 7
* runtime handshake), and nothing about schemas, descriptions, annotations or
* handlers, which can all change while every name stays identical.
*/
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { assembleVocabularyHash, DEAD_NAMES, MAX_TOOLS, TOOL_NAMES } from "../src/tools/vocabulary.js";
import { CALL_TOOL_WHITELIST } from "../src/orchestrator/call-tool-admission.js";
import { buildPanelToolDefs } from "../src/orchestrator/panel-tools.js";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const outPath = join(root, "docs", "design", "tool-vocabulary.json");
const check = process.argv.slice(2).includes("--check");
const panel = buildPanelToolDefs().map((d) => d.name);
// Rejected HERE, at the point the names are collected, because every consumer downstream
// puts them in a Set — the exporter, both vocabulary gates, the panel's own checks — so a
// duplicate definition is invisible to all of them and then throws
// "Tool <name> is already registered" out of the real SDK when the per-tab HTTP panel
// server starts. Passing every gate and failing at startup is the worst available order.
{
const seen = new Set<string>();
const dupes = [...new Set(panel.filter((n) => (seen.has(n) ? true : (seen.add(n), false))))];
if (dupes.length > 0) {
console.error(
`[vocab:export] buildPanelToolDefs() returns duplicate tool name(s): ${dupes.join(", ")}.\n` +
`The MCP SDK throws "Tool <name> is already registered", so this would break the\n` +
`per-tab HTTP panel server at startup. Remove the duplicate definition.`,
);
process.exit(1);
}
}
const core = [...TOOL_NAMES];
const panelSorted = [...panel].sort();
const dead = DEAD_NAMES.map((d) => ({ name: d.name, since: d.since, replacement: d.replacement }));
/** Identifies the VOCABULARY, not the build that produced it.
*
* Computed by assembleVocabularyHash() in src/tools/vocabulary.ts rather than
* inline here, because the RUNTIME handshake compares against this value. Two
* copies of the rule would drift, and a drifted hash reports a mismatch that is
* not real — which is worse than having no check at all. That applies to the
* INPUT as much as to the digest, so the core/panel-sorted/dead assembly moved
* in there with it; this file no longer decides what gets hashed (#236). */
const vocabularyHash = assembleVocabularyHash(panel);
const artefact = {
$comment:
"GENERATED by scripts/export-vocabulary.mts — do not edit by hand. Run `npm run vocab:export`. " +
"Vendored by comfyui-mcp-panel, whose browser JS calls these names as string literals.",
vocabularyHash,
maxTools: MAX_TOOLS,
counts: { core: TOOL_NAMES.length, panel: panel.length, dead: DEAD_NAMES.length },
/** Core MCP tools, in registration order. */
core,
/** Panel agent tools, sorted — registration order is not observable for these. */
panel: panelSorted,
/** Removed names, with what to use instead. `allowedIn` is repo-local and omitted. */
dead,
/**
* Core tools the DIRECT `call_tool` channel will admit — the canvas-less path
* mobile, mirrored tabs and the panel's own buttons use.
*
* Exists because #908: PR #278 correctly removed pod create/start (now
* `runpod` actions) from that channel (both start BILLING, and a
* confirmation-less mirrored tab must not spend money), but the panel's
* cmcp-runpod-ui.js kept calling them over exactly that channel. Both buttons
* were inert for about two weeks, returning a bare "not permitted". Each repo
* was correct alone; nothing could see across the seam.
*
* Publishing the admitted set lets the panel gate its OWN call sites the same
* way it already gates retired names — at build time, instead of a user
* discovering it by clicking a dead button.
*
* NOT part of `vocabularyHash`, deliberately. The hash identifies which tools
* EXIST; admission is a separate question about one channel, and folding it in
* would invalidate every vendored copy on a policy change that renamed nothing.
*/
directCallable: [...CALL_TOOL_WHITELIST].sort(),
};
const serialized = `${JSON.stringify(artefact, null, 2)}\n`;
if (check) {
let current: string | undefined;
try {
current = readFileSync(outPath, "utf8");
} catch {
console.error(`[vocab:export] ${outPath} is missing — run \`npm run vocab:export\`.`);
process.exit(1);
}
if (current !== serialized) {
console.error(
`[vocab:export] docs/design/tool-vocabulary.json is STALE.\n` +
`The vocabulary changed but the artefact the panel vendors did not, so the panel would\n` +
`keep validating its callTool() literals against the old surface.\n` +
`Fix: npm run vocab:export && commit, then re-vendor it into comfyui-mcp-panel.\n`,
);
// A unified diff of the two name lists is far more useful than "files differ".
// The scratch file goes to the OS temp dir, never inside docs/: the CI docs
// gate runs `git add --intent-to-add -A docs/`, so a stray file there would be
// reported as an uncommitted generated page.
const tmp = join(mkdtempSync(join(tmpdir(), "comfyui-mcp-vocab-")), "expected.json");
try {
writeFileSync(tmp, serialized);
execFileSync("git", ["--no-pager", "diff", "--no-index", "--", outPath, tmp], {
stdio: "inherit",
});
} catch {
/* git diff --no-index exits 1 when files differ; the diff itself already printed */
} finally {
rmSync(dirname(tmp), { recursive: true, force: true });
}
process.exit(1);
}
console.error(
`[vocab:export] OK — artefact matches the ledger ` +
`(${artefact.counts.core} core, ${artefact.counts.panel} panel, ${artefact.counts.dead} dead).`,
);
} else {
writeFileSync(outPath, serialized);
console.error(
`[vocab:export] wrote docs/design/tool-vocabulary.json ` +
`(${artefact.counts.core} core, ${artefact.counts.panel} panel, ${artefact.counts.dead} dead) ` +
`vocabularyHash ${vocabularyHash.slice(0, 12)}…`,
);
}