UNPKG

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),

463 lines • 24.1 kB
/** * #873 — OPERATOR-LEVEL RESTRICTION OF THE TOOL SURFACE. * * Reported from a Docker Compose + Open WebUI deployment behind llama-swap, where the * operator is NOT the person prompting. That is a different trust model from the one the * rest of this codebase assumes: locally, operator and prompter are the same person and a * tool filter would be theatre. In a shared frontend the operator needs to guarantee the * model cannot reach install/delete/restart tools whatever a user types. * * WHAT THIS IS AND IS NOT. It is a boundary against the MODEL and the people prompting * it. It is not a boundary against whoever sets the environment — they can unset it — and * it is not a substitute for not handing an untrusted party the ComfyUI host. Saying that * plainly matters, because an operator who believes this is more than it is will deploy * something they should not. * * Compact mode narrows DISCOVERY and is explicitly not a boundary: `call_tool` dispatches * by name, so a filter applied only to the registered list leaves the facade as a bypass. * This is applied as a registrar decorator, in the same position as the blind-image gate, * which both `registerAllTools` and `collectToolCatalog` already run — and `call_tool` * dispatches through that catalog. So both routes are covered by construction rather than * by remembering to cover them. */ export const MUTATING_TOOLS = [ "apply_manifest", // action:"import" installs an app from the public registry and creates it locally. "apps", "bisect", "clear_vram", "comfy_cli", "create_workflow", "download_model", // action:"set" writes persistent config. "get_defaults", "install_comfyui", "install_custom_node", // action:"apply" writes a widget, names a restart flag, or swaps a checkpoint. "kitchen", // action:"remove" deletes a model FILE; add_path/remove_path rewrite // extra_model_paths.yaml. "list_local_models", // action:"install_deps" resolves and INSTALLS custom-node packs through // ComfyUI-Manager — it downloads and RUNS third-party code on the ComfyUI host. // Missed by every earlier version of this list, including the pattern-matching // completeness test, because the verb is `install_deps` and the pattern was `^install$`. "list_packs", "node_pack", "node_snapshot", // action:"clear"/"cancel" destroys work — someone ELSE's, in the shared deployment // this feature exists for. "queue", "restart_comfyui", "runpod", "runpod_watch", "save_workflow", // Sets up the trainer itself: docker, GPU passthrough, venv. "train_doctor", "train_prepare_dataset", "train_start", "upload_image", "workspace", ]; /** * Queues work, spends money, writes a file, or publishes something. `safe` allows these * — rendering is the point of the product — and only `readonly` withholds them. */ export const EXECUTION_TOOLS = [ "batch", "enqueue_workflow", "generate_image", // action:"convert" writes a converted file, and video/audio outputs are SAVED to a // model-chosen directory. Fetching is a read; this tool does not only fetch. "get_image", // Runs hosted partner nodes that spend PAID credits. "list_api_nodes", // Files a PUBLIC GitHub issue. Nothing on the machine changes, so `safe` permits it; // `readonly` promises "nothing written", and a public issue written by whoever is // prompting a shared frontend is squarely a write. // // My first note here said "under the operator's token", which is wrong: the GitHub // token is server-side in the intake Worker and the client key is a soft anti-spam gate // (report-issue.ts:18-23). The classification is unaffected — the issue is still public // and still attributable to this deployment — but a wrong premise in this ledger is // exactly what produced the `search_custom_nodes` inversion one round ago, so it gets // corrected rather than left as harmless-sounding filler. "report_issue", ]; /** * Neither mutates nor spends. Allowed by every preset. * * `search_custom_nodes` IS ON THIS LIST, AND USED TO BE ON THE MUTATING ONE. I denied it * claiming `action:"install"` installs a node pack. It has exactly two actions, `search` * and `details`, and is read-only and network-only; the installing tool is * `install_custom_node`, already denied. The claim came from its description MENTIONING * `install_custom_node (action:"install")` — the same cross-reference confusion that * `declaredActions()` below exists to strip, made by me while reading rather than by the * scanner. The net effect was an inversion worth stating plainly: `safe` withheld a pure * registry read while allowing `list_packs`, which genuinely installs. */ export const READONLY_TOOLS = [ "calculate", "get_history", "get_system_stats", "get_workflow", // action:"propose" says so itself: "This does NOT write". Read + network only. "model_metadata", "search_custom_nodes", "visualize_workflow", ]; /** * Action names that mean a tool can change something. A SECONDARY cross-check, and it is * important to be honest about how little it is worth on its own. * * The first version of this feature relied on a scan like this AS the completeness * guarantee, with the pattern anchored to whole verbs — `^install$`. `list_packs` declares * `action:"install_deps"`, so the scan did not match it, and `list_packs` INSTALLS AND RUNS * third-party code on the ComfyUI host. The test passed 17/17 with that hole wide open, * which is the worst possible outcome for a check whose entire job is to notice holes: it * was read as coverage. * * A pattern over tool descriptions can only ever catch the cases someone already thought * of. So the real guarantee is now the LEDGER — every live tool must appear in exactly one * of MUTATING/EXECUTION/READONLY, and a new tool in none of them fails a test. That cannot * silently rot, because the failure mode is "unclassified", not "assumed harmless". * * What this pattern is still good for: catching a tool someone classified READONLY that * advertises a mutating action — a wrong hand-classification rather than a missing one. * The prefix form (`install_deps`, `add_path`, `generate_skill`) is deliberate. */ export const MUTATING_ACTION_NAMES = /^(remove|delete|clear|cancel|uninstall|install|reset|kill|stop|restart|purge|prune|apply|create|upload|train|add|set|save|write|switch|update|fix|disable|enable|import|convert|generate|prepare|download|move|rename)(_|$)/i; /** * Verbs that mean "queue work / spend", which `safe` deliberately ALLOWS — rendering is * the point of the product — and only `readonly` withholds. Kept separate so the * completeness check does not flag `enqueue_workflow` as a hole in `safe`. */ export const EXECUTION_ACTION_NAMES = /^(enqueue|start|submit|run)$/i; /** * Extract the actions a tool declares for ITSELF, from its description. * * Cross-references are excluded, and that is not a detail: `generate_image`'s description * mentions `get_defaults (action:"set")` while explaining where defaults come from. A * naive scan reads that as generate_image having a `set` action and reports a hole that * does not exist. A completeness check that cries wolf gets muted, and then it is not a * check. */ export function declaredActions(description, // REQUIRED, and both fields with it. They read like optional refinements and are the // only thing standing between this function and the over-stripping its own comment // calls the dangerous direction: with the guards defaulted off, every `name action:"x"` // is deleted, so `install_comfyui` loses its own pin/unpin/unlock and // `list_local_models` loses `download`. A caller that forgets them gets a silently // blinded result and a check that reports no holes. Make it impossible to forget. opts) { const { selfName, knownTools } = opts; // Both spellings occur in the real descriptions: `get_defaults (action:"set")` and // `download_model action:"download_civitai"`. Only the first was stripped, so // model_metadata — which is read-only and merely mentions where a sidecar comes from — // was reported as advertising a download. const withoutCrossRefs = description.replace(/\b([a-z_0-9]+)\s*(?:\(\s*)?action:"[a-z_0-9]+"\s*\)?/gi, (match, refName) => { // OVER-STRIPPING IS THE DANGEROUS DIRECTION: a tool whose own declaration got eaten // looks action-free, and a check that sees nothing reports no hole. So a reference // is removed ONLY when it names a tool that is not this one — a self-reference // ("model_metadata action:\"read\"") is kept, and so is a name nobody recognises. if (selfName && refName === selfName) return match; if (knownTools && !knownTools.has(refName)) return match; return ""; }); return [...new Set([...withoutCrossRefs.matchAll(/action:"([a-z_0-9]+)"/g)].map((m) => m[1]))]; } /** * Named presets, so an operator is not hand-maintaining a list against a surface that * grows every release — the reason a hand-written denylist rots. * * `safe` — everything except tools that change the machine or the model library. * Rendering still works; installing, deleting and restarting do not. * `readonly` — inspection only. No renders queued, nothing written, nothing spent. */ /** * THE PANEL SURFACE IS DENIED WHOLESALE BY BOTH PRESETS (codex, P0). * * `registerPanelTools` registers 91 `panel_*` tools through a different method * (`registerTool`) on a separate server, so they bypassed the filter entirely: under * `readonly`, `panel_run` still queued renders. That is the "a filter with a hole is * worse than none" case — the operator is told the surface is restricted and it is not. * * Denied as a FAMILY rather than tool-by-tool, deliberately. I classified those 91 by * name first and got several wrong in both directions (`panel_set_property`, * `panel_move_node`, `panel_add_subgraph` and `panel_update_node` all mutate and none * match an obvious write-ish pattern). A hand list over a surface that grows every * release is the rot this feature already tripped over once. `panel_*` is also exactly * what a hosted operator would withhold: it drives a live canvas their users share. * * Opt back in explicitly — `COMFYUI_MCP_TOOL_ALLOW=panel_graph_outline,panel_query_graph` * — which is the direction that fails safe when someone forgets one. */ const PANEL_SURFACE = ["panel_*"]; export const TOOL_PRESETS = { safe: [...MUTATING_TOOLS, ...PANEL_SURFACE], readonly: [...MUTATING_TOOLS, ...EXECUTION_TOOLS, ...PANEL_SURFACE], }; /** * A variable that is SET BUT EMPTY throws, rather than silently meaning "no rules". * * `COMFYUI_MCP_TOOL_ALLOW=${ALLOWED_TOOLS}` in a docker-compose file with `ALLOWED_TOOLS` * unset expands to an empty string. Treating that as "no rules configured" produces the * full tool surface with no log — the identical fail-open the unknown-preset throw was * added to eliminate, reached by a different route. Setting the variable is the operator * stating an intent; an empty value cannot satisfy it either way, so neither answer is * safe to assume. */ function splitList(raw, varName) { if (raw === undefined) return []; const items = raw .split(",") .map((s) => s.trim()) .filter(Boolean); if (items.length === 0) { throw new Error(`${varName} is set but empty. Refusing to start: this is usually an unexpanded ` + `shell variable (e.g. \`${varName}=\${ALLOWED_TOOLS}\` with ALLOWED_TOOLS unset), and ` + `treating it as "no rules" would expose the FULL tool surface while you believe it is ` + `restricted. Give it a value, or remove the variable entirely.`); } return items; } /** * Parse a fail-closed action allow list. * * Rules are exact `tool:action` pairs. Globs are deliberately unsupported: allowing * `queue:*` would silently admit a newly-added destructive action after an upgrade, * which is exactly the release-drift problem an allow list is meant to prevent. */ function splitActionAllow(raw) { const items = splitList(raw, "COMFYUI_MCP_TOOL_ACTION_ALLOW"); const rules = []; const seen = new Set(); for (const item of items) { const match = /^([a-z_][a-z0-9_]*):([a-z_][a-z0-9_]*)$/.exec(item); if (!match) { throw new Error(`COMFYUI_MCP_TOOL_ACTION_ALLOW contains invalid rule "${item}". ` + `Expected exact tool:action pairs such as "queue:list"; wildcards and empty ` + `names are not allowed. Refusing to start because ignoring a malformed rule ` + `could expose an action the operator meant to withhold.`); } const key = `${match[1]}:${match[2]}`; if (seen.has(key)) continue; seen.add(key); rules.push({ tool: match[1], action: match[2] }); } return rules; } /** * Does `name` match `pattern`? Exact, or a trailing `*` glob (`train_*`). * * Deliberately not a full glob: an operator writing a denylist is naming tools, and a * richer syntax buys expressiveness at the cost of a rule that quietly matches more than * it reads like. Prefix globs cover the real grouping in this surface (`train_*`, * `runpod*`) and cannot surprise anyone. */ export function toolMatches(name, pattern) { if (pattern === name) return true; if (pattern.endsWith("*")) return name.startsWith(pattern.slice(0, -1)); return false; } /** * Read the policy from the environment. * * AN UNKNOWN PRESET THROWS (codex, P0). The first version resolved a typo — `saef` for * `safe` — to no rules at all, which made `active` false, which meant the decorator * returned the registrar unwrapped AND the "surface restricted" log never fired. The * server came up with its complete tool surface and said nothing, while the operator * believed they had locked it down. Worse, I had written a test asserting exactly that * and called it correct, on the reasoning that "the log will tell them" — the log is * gated on the same flag. * * For a control whose whole purpose is a guarantee, silence on a misconfiguration is the * one unacceptable outcome. Refusing to start is loud, immediate, and cannot be mistaken * for success. */ export function resolveToolSurfacePolicy(env = process.env) { const allow = splitList(env.COMFYUI_MCP_TOOL_ALLOW, "COMFYUI_MCP_TOOL_ALLOW"); const denyRaw = splitList(env.COMFYUI_MCP_TOOL_DENY, "COMFYUI_MCP_TOOL_DENY"); const actionAllow = splitActionAllow(env.COMFYUI_MCP_TOOL_ACTION_ALLOW); const presetName = (env.COMFYUI_MCP_TOOL_PRESET ?? "").trim(); if (env.COMFYUI_MCP_TOOL_PRESET !== undefined && presetName === "") { throw new Error(`COMFYUI_MCP_TOOL_PRESET is set but empty. Refusing to start: this is usually an ` + `unexpanded shell variable, and treating it as "no preset" would expose the FULL tool ` + `surface while you believe it is restricted. Valid presets: ` + `${Object.keys(TOOL_PRESETS).join(", ")}. Give it a value, or remove the variable.`); } if (presetName && !Object.prototype.hasOwnProperty.call(TOOL_PRESETS, presetName)) { throw new Error(`COMFYUI_MCP_TOOL_PRESET="${presetName}" is not a known preset. ` + `Valid presets: ${Object.keys(TOOL_PRESETS).join(", ")}. ` + `Refusing to start: continuing would expose the FULL tool surface while you believe ` + `it is restricted, which is worse than no filter at all. Fix the value, or drop the ` + `variable and use COMFYUI_MCP_TOOL_DENY / COMFYUI_MCP_TOOL_ALLOW directly.`); } const preset = presetName ? TOOL_PRESETS[presetName] : undefined; return { allow, deny: denyRaw, presetDeny: preset ?? [], actionAllow, active: allow.length > 0 || denyRaw.length > 0 || actionAllow.length > 0 || preset !== undefined, ...(preset ? { preset: presetName } : {}), }; } /** * Decide whether one parsed tool invocation may dispatch. * * The policy is absolute for calls that carry `action`: once configured, an action on * ANY tool must be named. Calls without an action field are unchanged and remain governed * by the tool-level surface policy. This makes a partial list fail closed: forgetting * `enqueue_workflow:rerun` disables reruns instead of leaving every rerun variant open. */ export function toolActionAllowed(name, args, policy) { if (policy.actionAllow.length === 0) return true; if (!args || typeof args !== "object" || Array.isArray(args)) return true; const action = args.action; // ABSENT is not an action call. Those stay governed by the tool-level surface policy, // which is the documented composition: bound the tools with ALLOW/PRESET, bound the // actions with this list. if (action === undefined || action === null) return true; // PRESENT BUT NOT A STRING IS REFUSED, not waved through. Every one of the 34 live // `action` fields is a REQUIRED `z.enum([...])` and both dispatch routes validate // before the handler runs (the SDK on the direct route, an explicit safeParse in // compact.ts), so nothing real reaches here with a non-string action — which is // exactly why the branch has to be decided rather than defaulted. "Not a string" was // returning ALLOWED, so the one shape this function cannot classify was resolved in // the permissive direction, and it would stay that way if a future tool ever widened // an action field or a caller reused this helper somewhere the schema is looser. An // unclassifiable call is not an allowed call. if (typeof action !== "string") return false; // EXACT on BOTH halves, and both halves of the SAME rule. Matching `tool` against one // rule and `action` against another would let `ALLOW=queue:list,download_model:download` // admit `queue:download`; a prefix or case-insensitive compare would let `list_all`, // `LIST` or `queue_admin` through. See the over-permissive tests, which are the only // thing that can see any of those. return policy.actionAllow.some((rule) => rule.tool === name && rule.action === action); } export function toolActionPolicyError(name, args, policy) { if (toolActionAllowed(name, args, policy)) return undefined; const action = args && typeof args === "object" ? String(args.action) : "unknown"; return { content: [ { type: "text", text: `Action "${name}:${action}" is withheld by ` + "COMFYUI_MCP_TOOL_ACTION_ALLOW.", }, ], isError: true, }; } /** * Is this tool permitted? * * ALLOW BOUNDS THE SURFACE, and is absolute when present: naming an allow list is a * statement that the surface is exactly those tools, so a name absent from it is denied * even if no deny rule mentions it. That is the safer reading of an operator's intent — * the alternative (allow as a mere exemption from deny) turns an incomplete list into a * wide-open surface, which is the failure mode nobody notices until it matters. * * DENY THEN APPLIES ON TOP, rather than being switched off by the presence of an allow * list. The first version returned early on the allow match, so * `ALLOW=list_local_models,get_history` plus `DENY=list_local_models` allowed * `list_local_models` — a tool whose `action:"remove"` deletes a model file — because the * allow list won outright. Both variables are the operator saying "not this"; the * intersection is the only reading where neither statement is silently discarded, and it * is the one that errs toward a smaller surface. */ export function toolAllowed(name, policy) { const allowed = policy.allow.length === 0 || policy.allow.some((p) => toolMatches(name, p)); if (!allowed) return false; // An EXPLICIT deny beats an allow list — both are the operator speaking, and the // smaller surface wins the tie. if (policy.deny.some((p) => toolMatches(name, p))) return false; // A PRESET is a broad default, so NAMING a tool in the allow list is how you refine it // (`PRESET=safe` + `ALLOW=panel_graph_outline,panel_query_graph`). Without this, the // documented opt-back-in would silently do nothing. // // AN EXACT NAME REFINES A PRESET; A GLOB DOES NOT. Measured on the first version, which // let any allow match override: `PRESET=safe` + `ALLOW=list_*` re-admitted `list_packs` // — the tool whose action:"install_deps" downloads and runs third-party code, and the // headline finding of the round that added the preset. `ALLOW=*` made every preset // entirely inert while reporting itself active. // // The difference is what the operator actually asserted. Writing `panel_graph_outline` // is a decision about one tool, made with it in mind. Writing `list_*` is a decision // about a shape, and it sweeps in whatever the next release happens to name that way — // which is precisely what a preset exists to protect against. So a glob narrows the // surface like any allow entry, and cannot re-open what the preset closed. if (policy.allow.includes(name)) return true; return !policy.presetDeny.some((p) => toolMatches(name, p)); } /** * The three facade tools are NEVER filtered. * * Removing `call_tool` from a compact-mode surface leaves a client with no way to reach * ANY tool — the filter would read as "restrict the surface" and behave as "break the * server". The facade is a router; what it can route to is already governed by the same * policy through the shared catalog, so exempting it grants nothing. */ const FACADE_TOOLS = new Set(["list_tools", "describe_tool", "call_tool"]); /** * Wrap a registrar so denied tools are never registered. * * Denied names are ABSENT rather than erroring on call: a tool that refuses is still a * tool the model can see, reason about and keep retrying. Absent means it never learns * the capability exists, which is what the reporter asked for and also the cheaper * outcome in tokens. */ export function withToolSurfaceFilter(server, policy, onFiltered) { if (!policy.active) return server; const orig = server.tool.bind(server); const tool = (...args) => { const name = typeof args[0] === "string" ? args[0] : undefined; if (name && !FACADE_TOOLS.has(name) && !toolAllowed(name, policy)) { onFiltered?.(name); // Registration is skipped entirely. The SDK's `tool()` returns a handle some // callers chain on, so hand back something inert rather than undefined. return { name, disabled: true }; } const handler = args[args.length - 1]; if (name && typeof handler === "function" && policy.actionAllow.length > 0) { const wrapped = async (...handlerArgs) => { const policyError = toolActionPolicyError(name, handlerArgs[0], policy); if (policyError) return policyError; return await handler(...handlerArgs); }; 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); }, }); } //# sourceMappingURL=tool-surface-filter.js.map