/** * 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 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(); 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 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)}…`, ); }