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),
267 lines • 15.2 kB
JavaScript
import { registerWorkflowExecuteTools } from "./workflow-execute.js";
import { registerSystemStatsTools } from "./system-stats.js";
import { registerWorkflowVisualizeTools } from "./workflow-visualize.js";
import { registerWorkflowComposeTools } from "./workflow-compose.js";
import { registerQueueManagementTools } from "./queue-management.js";
import { registerBatchTools } from "./batches.js";
import { registerRegistrySearchTools } from "./registry-search.js";
import { registerModelManagementTools } from "./model-management.js";
import { registerDiagnosticsTools } from "./diagnostics.js";
import { registerRunpodTools } from "./runpod.js";
import { registerWorkflowLibraryTools } from "./workflow-library.js";
import { registerProcessControlTools } from "./process-control.js";
import { registerImageManagementTools } from "./image-management.js";
import { registerMemoryManagementTools } from "./memory-management.js";
import { registerAutoloadedWorkflows } from "./workflow-autoload.js";
import { registerDefaultsTools } from "./defaults.js";
import { registerGenerateImageTool } from "./generate-image.js";
import { registerNodeSnapshotsTools } from "./node-snapshots.js";
import { registerNodeBisectTools } from "./node-bisect.js";
import { registerNodeManagementTools } from "./node-management.js";
import { registerReportIssueTools } from "./report-issue.js";
import { registerNodePackTools } from "./node-pack.js";
import { registerInstallComfyUITools } from "./install-comfyui.js";
import { registerWorkspaceEnvTools } from "./workspace-env.js";
import { registerApiNodesTools } from "./api-nodes.js";
import { registerManifestTools } from "./manifest.js";
import { registerModelExplorerTools } from "./model-explorer.js";
// 0.50.0 slice 15: the image-convert / color-analysis / storage-upload
// registrars are gone — their three tools became actions on `get_image` and
// `upload_image` (registerImageManagementTools). The SERVICES they called are
// untouched and now imported there.
import { registerSkillsAccessTools } from "./skills-access.js";
import { registerCalculateTools } from "./calculate.js";
import { registerKitchenTools } from "./kitchen.js";
import { registerComfyCliTools } from "./comfy-cli.js";
import { registerTrainTools } from "./train.js";
import { registerAppsTools } from "./apps.js";
import { DefaultsManager } from "../services/defaults-manager.js";
import { ToolCatalog } from "./catalog.js";
import { registerCompactTools } from "./compact.js";
import { logger } from "../utils/logger.js";
import { resolveToolSurfacePolicy, withToolSurfaceFilter } from "./tool-surface-filter.js";
/**
* Every static tool group, in registration order (order is observable in
* tools/list, so it must not change), tagged with the category used by the
* compact tool mode's list_tools manifest.
*/
const TOOL_GROUPS = [
["comfy-cli", registerComfyCliTools],
["workflows", registerWorkflowExecuteTools],
["workflows", registerSystemStatsTools],
["workflow-authoring", registerWorkflowVisualizeTools],
["workflow-authoring", registerWorkflowComposeTools],
["workflows", registerQueueManagementTools],
["custom-nodes", registerRegistrySearchTools],
["models", registerModelManagementTools],
// (0.50.0 slice 9: the skill-generator group's single tool folded into
// `list_packs` action:"generate_skill", registered by registerSkillsAccessTools
// further down — the survivor keeps its own slot.)
["diagnostics", registerDiagnosticsTools],
["runpod", registerRunpodTools],
["workflow-authoring", registerWorkflowLibraryTools],
["server", registerProcessControlTools],
["images-assets", registerImageManagementTools],
["server", registerMemoryManagementTools],
["skills-config", registerDefaultsTools],
// 0.50.0 slice 16 folded eight sibling generation tools into `generate_image`
// and four observability tools into `get_history`, so the groups that used to
// register them are gone from this list (their handlers live on as action
// functions imported by the survivors). `generate_image` keeps this slot.
["generation", registerGenerateImageTool],
["custom-nodes", registerNodeSnapshotsTools],
["custom-nodes", registerNodeBisectTools],
["custom-nodes", registerNodeManagementTools],
["diagnostics", registerReportIssueTools],
// (0.50.0 slice 9: the workflow-deps group's two tools folded into `list_packs`
// actions "extract_deps"/"install_deps" — same note as skill-generator above.)
["server", registerInstallComfyUITools],
["models", registerModelExplorerTools],
["server", registerWorkspaceEnvTools],
["generation", registerApiNodesTools],
["custom-nodes", registerNodePackTools],
["models", registerManifestTools],
["skills-config", registerSkillsAccessTools],
["diagnostics", registerCalculateTools],
// registerComfyUISettingsTools used to sit here. 0.50.0 slice 7 folded its two
// tools into `get_defaults` as action:"get_ui"/"set_ui", so the group is gone
// rather than empty — every surviving name keeps its position, which is what
// registry-surface.test.ts pins.
//
// registerNodeDevTools sat here too, until 0.50.0 slice 12 folded its six
// tools into `node_pack` — registered at the slot its family’s first member
// (the scaffold tool) held, further up.
["training", registerTrainTools],
["apps", registerAppsTools],
// Appended (not inserted next to queue-management) because tools/list order
// is observable and must not shift for existing tools.
["workflows", registerBatchTools],
// Appended: tools/list order is observable and must not shift existing names.
["diagnostics", registerKitchenTools],
];
// ── Blind content mode (panel issue #90) ────────────────────────────────────
// When COMFYUI_MCP_BLIND=1 (set on the tool-server spawn by the orchestrator
// for tabs whose panel Blind toggle is ON), NO tool may deliver image pixels
// to the model. Enforced MECHANICALLY at the single registration boundary both
// tool paths share — the live McpServer and the compact-mode ToolCatalog both
// receive handlers wrapped here — so the guarantee holds for every current and
// future image-returning tool (get_image's fetch/view/convert/colour-measure
// actions, generate_image, get_image, ...) without per-tool opt-ins. It
// wraps the RESULT, so folding several image-returning tools into one name
// (0.50.0 slice 15) changes nothing here: the wrapper never reads a tool name
// or an action.
const blindMode = () => process.env.COMFYUI_MCP_BLIND === "1";
function scrubImageBlocks(result) {
if (!result || typeof result !== "object")
return result;
const r = result;
if (!Array.isArray(r.content))
return result;
let scrubbed = 0;
const content = r.content.map((block) => {
if (!block || block.type !== "image")
return block;
scrubbed++;
const bytes = typeof block.data === "string" ? Math.round((block.data.length * 3) / 4) : 0;
const size = bytes ? `${Math.max(1, Math.round(bytes / 1024))} KB, ` : "";
return {
type: "text",
text: `[Blind mode: image withheld (${size}${String(block.mimeType ?? "image")}). ` +
"The user's Blind setting means you NEVER receive image pixels — work from " +
"filenames/metadata, and tell the user to inspect the image themselves if it matters.]",
};
});
if (!scrubbed)
return result;
return { ...r, content };
}
/** Wrap a registrar so every tool handler enforces Blind mode on its RESULT.
* Works for both the live McpServer and ToolCatalog.asRegistrar() (the compact
* call_tool router) — each captures/registers the wrapped handler. */
function withBlindImageGate(server) {
const orig = server.tool.bind(server);
const tool = (...args) => {
const handler = args[args.length - 1];
if (typeof handler === "function") {
const wrapped = async (...hargs) => {
const result = await handler(...hargs);
return blindMode() ? scrubImageBlocks(result) : result;
};
return orig(...args.slice(0, -1), wrapped);
}
return orig(...args);
};
return new Proxy(server, {
get(target, prop, receiver) {
if (prop === "tool")
return tool;
// oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy get trap; Reflect.get forwards the receiver to getters, typed access cannot
return Reflect.get(target, prop, receiver);
},
});
}
export async function registerAllTools(server) {
// Hydrate persisted defaults before any tool registration so subsequent
// tools can consult DefaultsManager.apply() against a fully-resolved view.
await DefaultsManager.load();
// #873 — the operator's tool-surface policy, applied in the SAME position as the
// blind-image gate. collectToolCatalog() applies it too, which is what stops the
// compact `call_tool` facade being a way around it.
const policy = resolveToolSurfacePolicy();
const filtered = [];
const gated = withToolSurfaceFilter(withBlindImageGate(server), policy, (n) => filtered.push(n));
for (const [, register] of TOOL_GROUPS)
register(gated);
await registerAutoloadedWorkflows(gated);
if (policy.active) {
// Logged, never pushed to the model: the point is that it does not learn these
// exist. The OPERATOR needs to see it, and a silent filter is how a misconfigured
// allow list becomes an afternoon of "why can't it render".
logger.info(`[tools] surface restricted by policy${policy.preset ? ` (preset "${policy.preset}")` : ""} — ` +
`${filtered.length} tool(s) withheld` +
(filtered.length ? `: ${filtered.slice(0, 12).join(", ")}${filtered.length > 12 ? ", …" : ""}` : "") +
(policy.actionAllow.length
? `; ${policy.actionAllow.length} exact tool action(s) allowed`
: ""));
}
}
/**
* Default (non-compact) registration WITH the compact facade layered on top.
*
* #616 — reconnect resilience: a code-execution MCP client (the SDK's
* `exec_main.mjs` harness that exposes each tool as a callable
* `tools.mcp__comfyui__<tool>`) snapshots the tool surface from a `tools/list`.
* After the user restarts ComfyUI and the panel session resumes, that snapshot
* can go stale — a previously-bound direct tool is briefly absent from the
* refreshed surface, and calling the cached binding throws
* `TypeError: tools.install_comfyui (action:"environment") is not a function` BEFORE
* dispatch. Layering the facade (`list_tools` / `describe_tool` / `call_tool`)
* onto the full direct surface guarantees EVERY advertised surface — compact or
* full — carries a STABLE `call_tool` escape hatch, so such a client can always
* route a missing direct tool through `call_tool({ name, args })` instead of
* dead-ending. The direct tools and the facade are registered in ONE atomic
* pass here, before the transport is connected, so a resumed turn never sees a
* half-built catalog (facade-only) missing the direct tools.
*
* The facade adds only 3 tools to the surface and reuses the SAME underlying
* handlers (via collectToolCatalog), so there is no divergence between a direct
* call and its `call_tool` route. Opt out with COMFYUI_MCP_NO_FACADE=1 (env) or
* `{ facade: false }`.
*/
export async function registerFullTools(server, opts = {}) {
await registerAllTools(server);
const facade = opts.facade ?? process.env.COMFYUI_MCP_NO_FACADE !== "1";
if (facade) {
// Capture the same registration into a catalog; call_tool dispatches to these
// handlers, so the facade route is behaviorally identical to a direct call.
// Built and registered in-sequence with the direct surface above and BEFORE
// server.connect(), so the whole snapshot is atomic (no half-built tools/list).
//
// NOTE: this is a SECOND filesystem discovery of autoloaded workflows. In the
// (sub-millisecond) window between the two passes a workflow could be added or
// removed; the divergence self-heals on the next spawn — call_tool routes
// through THIS catalog (internally consistent), and at worst a just-removed
// workflow stays directly listed but unreachable via call_tool, or a just-added
// one is reachable via call_tool before it appears in the direct list. Neither
// drops an established tool. The one race that could otherwise CRASH startup —
// a reserved-name workflow present in pass 1 (registered live) but gone from
// pass 2's catalog, so `skip` misses it and the facade double-registers — is
// caught inside registerCompactTools (it swallows a duplicate-name throw).
const catalog = await collectToolCatalog();
// Collision guard (fast path): if a user's autoloaded workflow is literally
// named after a facade meta-tool, registerAllTools already claimed that name on
// the live server. Skip the colliding meta(s) cleanly (no error noise) and keep
// the direct tool + the rest of the facade. registerCompactTools's try/catch
// still backstops any collision this snapshot-based check misses. (#616 / codex P1)
const reserved = ["list_tools", "describe_tool", "call_tool"];
const skip = new Set(reserved.filter((name) => catalog.get(name)));
if (skip.size > 0) {
logger.warn(`[tools] facade meta-tool name(s) already claimed by a direct tool — not layering the facade for: ${[...skip].join(", ")}. ` +
`Rename the conflicting workflow file(s) to expose the full facade.`);
}
registerCompactTools(server, catalog, { skip });
}
}
/**
* Run the same registration pass against a capturing ToolCatalog instead of a
* live server. Used by the compact tool mode (small/local LLMs): the catalog
* backs the list_tools / describe_tool / call_tool meta-tools.
*/
export async function collectToolCatalog() {
await DefaultsManager.load();
const catalog = new ToolCatalog();
// #873 — THE LOAD-BEARING HALF. `call_tool` dispatches through this catalog and
// `list_tools` is built from it, so filtering here is what makes the policy a boundary
// rather than a suggestion: a denied tool is neither listed nor reachable by name.
// Filtering only the direct registration above would leave the facade as a clean
// bypass, which is precisely what the report calls out.
const registrar = withToolSurfaceFilter(withBlindImageGate(catalog.asRegistrar()), resolveToolSurfacePolicy());
for (const [category, register] of TOOL_GROUPS) {
catalog.setCategory(category);
register(registrar);
}
catalog.setCategory("saved-workflows");
await registerAutoloadedWorkflows(registrar);
return catalog;
}
//# sourceMappingURL=index.js.map