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

495 lines • 32.7 kB
import { z } from "zod"; import { isLocalMode } from "../config.js"; import { installCustomNode, updateCustomNode, reinstallCustomNode, fixCustomNode, disableCustomNode, enableCustomNode, uninstallCustomNode, listInstalledNodes, syncNodeDependencies, parseGitUrl, } from "../services/node-management.js"; import { errorToToolResult } from "../utils/errors.js"; import { getComfyCliVersion, resolveComfyCliExecutable, shouldUseComfyCli } from "../services/comfy-cli.js"; import { assertPanelNotTargetedUnverifiable, targetsPanelPackExactly, } from "../services/panel-pin-guard.js"; import { runPanelAction } from "../services/panel-installer.js"; import { SEMVER_RE } from "../services/ui-bridge.js"; /** * The sidebar panel pack is an ordinary custom node pack, so `install_custom_node` * action:"install" / action:"update" / action:"reinstall" can target it by id. The * generic services report success straight off the ComfyUI-Manager queue result, * which a stale Manager 3.x drains WITHOUT doing any work (#639) — and they know * nothing about `.bak` shadow copies (#641). Reaching the panel through them * would therefore reintroduce both bugs through a side door. * * So a call that names the panel is REDIRECTED into the verified path, which * re-reads the pack from disk and fails closed unless it provably moved. (The pin * is enforced deeper still, in the services themselves — see panel-pin-guard.ts — * so bulk targets like "all" are covered even though they cannot be redirected.) */ /** * Can the verified panel path honour this git ref as a registry version? * * The verified path installs from the Comfy Registry, so the only refs it can * honour are the ones that ARE registry versions: the channel names, or a * strict semver (ui-bridge's canonical grammar; a leading-"v" tag means the * same version, and the registry spelling is the bare form). Anything else — a * branch, a sha — would silently become something other than what was asked. */ function registryInstallableRef(ref) { if (ref === "nightly" || ref === "latest") return ref; if (!SEMVER_RE.test(ref)) return undefined; return ref.replace(/^v(?=\d)/, ""); } async function runVerifiedPanelAction(action, id, opts = {}) { // Options the verified path cannot honour are REFUSED, not dropped. Silently // ignoring a caller's `ref` and then reporting success would be doing // something other than what was asked — a smaller cousin of the exact lie this // whole feature guards against. (`version` IS honoured; it is threaded through.) if (opts.ref || opts.source === "git") { throw new Error(`"${id}" is the comfyui-mcp sidebar panel pack, which is managed through the ` + `verified panel path (it re-reads the pack from disk afterwards and fails ` + `closed on a ComfyUI-Manager no-op or a shadow copy). That path installs from ` + `the Comfy Registry, so a git \`ref\`/\`source: "git"\` cannot be honoured and ` + `will NOT be silently ignored. Drop the git options to install it normally, ` + `or manage a git checkout of the panel yourself (a symlinked dev install is ` + `never touched by these tools).`); } // A ref EMBEDDED in the id itself ("...comfyui-mcp-panel.git@v0.11.28", // ".../tree/v0.11.28") is just as much a requested version as the `version` // option — and it used to DIE here: targetsPanelPackExactly matched the URL, // the redirect threaded only `version`, and the user got NIGHTLY while the // response implied success. Honour the refs the registry path can honour; // refuse the rest, exactly like the git options above. const embeddedRef = parseGitUrl(id).ref; let version = opts.version; if (embeddedRef) { const registryVersion = registryInstallableRef(embeddedRef); if (!registryVersion) { throw new Error(`"${id}" embeds git ref "${embeddedRef}", which the verified panel path ` + `cannot honour: it installs the comfyui-mcp sidebar panel pack from the ` + `Comfy Registry, where only a strict semver (e.g. v0.11.28), "nightly" or ` + `"latest" names a version. Use a release tag, or pass \`version\` instead.`); } if (version && version !== registryVersion) { throw new Error(`Conflicting panel versions: the id embeds "@${embeddedRef}" but ` + `\`version\` says "${version}". Pick one — this tool will not guess.`); } if (action === "update") { throw new Error(`"${id}" embeds git ref "${embeddedRef}", but update always pulls the ` + `channel tip — a ref cannot be honoured. Use install/reinstall with the ` + `ref to get a specific panel version.`); } version = registryVersion; } const result = await runPanelAction(action, undefined, { version }); return { content: [ { type: "text", text: JSON.stringify({ ...result, routedVia: "install_comfyui(action:\'panel\')", note: `"${id}" is the comfyui-mcp sidebar panel pack, so this ${action} ran ` + `through the verified panel path: the version above was RE-READ from ` + `disk after the operation, and a Manager no-op or a shadow copy would ` + `have failed instead of reporting success. ` + (version ? embeddedRef ? `The ref embedded in the URL (@${embeddedRef}) was honoured as ` + `the target version (${version}). ` : `Your requested version (${version}) was used as the target. ` : `It targets the 'nightly' channel. `) + `The mode/channel/useCmCli options do not apply on this path. Use ` + `install_comfyui(action:\'panel\') directly for status/sync/pin.`, }, null, 2), }, ], }; } /** Graceful "not supported remotely" tool result (no isError), matching the * degrade-don't-throw pattern list_local_models uses. */ function remoteUnsupported(message) { return { content: [{ type: "text", text: message }] }; } function preferLocalComfyCli(explicit) { if (explicit !== undefined) return explicit; if (!isLocalMode()) return false; const executable = resolveComfyCliExecutable(); if (!executable) return false; return shouldUseComfyCli(undefined, true, executable, getComfyCliVersion()); } /** * `mode` means two DIFFERENT things in this family, and the fold has to carry * both without renaming either. * * - install/update/reinstall/fix took `mode: "remote"|"local"|"cache"` — the * ComfyUI-Manager DATA SOURCE. * - list took `mode: "default"|"imported"` — WHICH installed packs to report: the * live custom_nodes/ scan, or that same scan frozen at server startup. * * A flat schema has one `mode`, so the enum is the union and the handler * refuses a value the chosen action cannot honour, naming the ones it can. That * refusal is not new behaviour: the retired installed-list tool rejected * mode:"remote" too, at the zod layer — the fold only MOVES the * rejection into the handler, where it can say which values are valid for the * action you asked for instead of listing all five. * * Renaming one of them (`list_mode`) was the obvious alternative and is worse: * DEAD_NAMES advertises a mechanical migration (the retired list tool's `{mode}` → * `install_custom_node({action:"list", mode})`), and a rename breaks exactly * that rewrite with an "unrecognized key" error that names no remedy. */ const MANAGER_MODES = ["remote", "local", "cache"]; const LIST_MODES = ["default", "imported"]; const modeSchema = z .enum([...MANAGER_MODES, ...LIST_MODES]) .optional() .describe('Two distinct meanings, one per action group. For actions "install"/"update"/"reinstall"/"fix": the ComfyUI-Manager data source (default \'remote\'); \'remote\' fetches the live node list, \'local\'/\'cache\' use bundled/cached data. For action:"list": \'default\' lists the installed packs as they are on disk NOW; \'imported\' returns that SAME custom_nodes/ scan frozen at ComfyUI startup, so the only difference is packs installed or removed since the server booted. \'imported\' is NOT an import-success filter: it includes DISABLED packs and cannot tell you whether the Python of a pack actually loaded — for that use node_pack action:"verify", which checks the node class_types of the pack against /object_info on the running server. Passing a value from the wrong group is refused, naming the ones the action accepts.'); const useCmCliSchema = z .boolean() .optional() .describe('Prefer the official comfy-cli subprocess instead of the ComfyUI-Manager HTTP API. Local operations use comfy-cli by default; set false to force Manager HTTP. Requires a local ComfyUI install — for actions "install"/"disable"/"enable"/"uninstall"/"list", an unavailable or unsupported CLI falls back to Manager HTTP automatically (disclosed in the result); "update"/"reinstall"/"fix" do not fall back.'); const channelSchema = z .string() .optional() .describe("ComfyUI-Manager channel name (default 'default')."); /** * #2215: mode:"imported" reads as an import-success filter and is not one. * * ComfyUI-Manager (3.41, 4.2.x; both the `glob` and `legacy` server modules) * answers /customnode/installed?mode=imported out of a module-scope binding: * * # freeze imported version * startup_time_installed_node_packs = core.get_installed_node_packs() * * "imported" there means the MANAGER MODULE was imported — i.e. server startup. * It is the identical get_installed_node_packs() filesystem scan of custom_nodes/ * that mode:"default" runs live, and that scan deliberately keeps disabled packs * (`is_disabled = not y.endswith('.disabled')`, stored with enabled: False). So * the ONLY difference between the two modes is staleness, and on a server nobody * has installed into since boot the two lists are byte-identical — disabled * entries and all. * * Reported as a broken filter because our own description promised one. The * filter is not repairable here: an ENABLED pack that dies on a torch/CUDA * mismatch is exactly the case the reporter cares about, and it sits in the scan * either way, because the scan never opens the Python. Dropping the disabled * entries would return a MORE plausible list that is just as unable to answer the * question — so disclose instead, and name the tool that can. */ const IMPORTED_MODE_DISCLOSURE = 'NOTE — mode:"imported" is NOT an import-success list. ComfyUI-Manager answers it with ' + 'the same custom_nodes/ scan as mode:"default", frozen at server startup; disabled packs ' + 'are included, and nothing here reflects whether the Python of a pack imported cleanly. ' + 'The only difference from mode:"default" is packs installed or removed since the server ' + 'booted. To find out whether a pack actually loaded, use node_pack {action:"verify", ' + 'name:"<pack>", restart:false} — it checks the node class_types of that pack against ' + '/object_info on the running server.'; /** cm-cli `show installed` takes no mode parameter, so useCmCli drops `mode` * entirely rather than degrading it. Silently returning the live on-disk list * under a mode that was never sent is the same misreport in a second costume. */ const CM_CLI_MODE_DROPPED = 'NOTE — if a usable comfy-cli handled this request, `mode` was NOT applied: `cm-cli show ' + 'installed` has no mode parameter, so that result is the current on-disk pack list ' + 'regardless of the mode requested. If comfy-cli was absent or its version was ' + 'unrecognized/unsupported, this read-only request fell back to Manager HTTP and `mode` ' + 'was applied there.'; function formatInstalledNodes(nodes) { if (nodes.length === 0) return "No custom nodes installed."; return nodes .map((n, i) => { const idParts = [ n.cnrId ? `cnr:${n.cnrId}` : null, n.auxId ? `git:${n.auxId}` : null, ].filter(Boolean); const idStr = idParts.length ? ` [${idParts.join(", ")}]` : ""; const state = n.enabled === undefined ? "state unknown" : n.enabled ? "enabled" : "disabled"; return (`${i + 1}. ${n.module}${idStr}\n` + ` version: ${n.version ?? "unknown"} | ${state}`); }) .join("\n"); } /** * The nine custom-node LIFECYCLE tools collapsed into one action-parameterized * `install_custom_node` tool (0.50.0 surface consolidation, slice 12). * * SHAPE: a FLAT object with an `action` enum — deliberately NOT a * z.discriminatedUnion, which the MCP SDK renders as a schema with ZERO visible * parameters, hiding every input from the model. * * REQUIREDNESS: only `action` can be schema-required — `id` is required for the * seven pack-targeting actions and meaningless for "list"/"sync_deps". Every * VALUE constraint the old tools had is unchanged at the zod layer (`source` * keeps its enum, `mode` the union of the two vocabularies documented above); * the handler enforces per-action presence and names the missing field — the one * deliberate behavioural difference a flat enum permits. Each branch calls the * same service function the old tool called, with the same arguments, and * returns the identical content block (JSON for the mutations, the formatted * list for "list"). * * NINE actions, not the RFC's ~8. `disable`/`enable`/`uninstall` shipped in #775 * AFTER the consolidation plan was measured, and they are custom-node lifecycle * verbs by any reading — a pack you install is a pack you disable and remove. * * REGISTRY DISCOVERY IS NOT HERE. Searching registry.comfy.org and reading one * pack's details live in `search_custom_nodes`, which survives as its own * two-action tool. Everything on THIS tool either runs third-party pack code * (install/update/reinstall/fix/sync_deps) or mutates what is installed * (uninstall/enable/disable); the two registry lookups are read-only and * network-only and need neither a running ComfyUI nor COMFYUI_PATH. Different * blast radius — and the NAME is the unit that both the call_tool whitelist and * the ollama loop-breaker reason about, so the split is what lets each of them * stay honest without per-action special cases. * * ADMISSION: `install_custom_node` is on the orchestrator's direct call_tool * whitelist, which authorizes by NAME and then forwards arbitrary action * arguments. Only the retired install entry and the retired installed-list * entry were ever whitelisted, so the folded name is ACTION-scoped to exactly * install+list — see CALL_TOOL_ACTION_WHITELIST in * src/orchestrator/call-tool-admission.ts. */ export function registerNodeManagementTools(server) { server.tool("install_custom_node", "Install, repair, enable/disable and remove ComfyUI custom node packs on this ComfyUI. To FIND a pack in the public registry first, use search_custom_nodes. Driven by the `action` parameter:\n" + '- action:"install" — Install a pack by registry id, git URL, or name. Local installs prefer official comfy-cli when available; remote or CLI-unavailable installs use the ComfyUI-Manager HTTP API. A ComfyUI restart may be required. Targeting the comfyui-mcp sidebar panel pack (\'comfyui-agent-panel\' / \'comfyui-mcp-panel\') is routed through the verified install_comfyui(action:\'panel\') path (the version is re-read from disk afterwards) and is REFUSED while the panel is version-pinned.\n' + '- action:"update" — Update an installed pack, or pass id:\'all\' to update every installed pack. Local operations prefer official comfy-cli; remote operations use Manager HTTP. Targeting the sidebar panel pack is routed through the verified install_comfyui(action:\'panel\') path. While the panel is version-pinned, BOTH a direct panel target and \'all\' are REFUSED — \'all\' would move the pinned panel too; clear the pin with install_comfyui(action:\'panel\', panel_action:\'unpin\') or update other packs individually.\n' + '- action:"reinstall" — Reinstall a pack. Local operations prefer official comfy-cli; remote operations use Manager HTTP. A ComfyUI restart may be required. A panel target is routed through the verified install_comfyui(action:\'panel\') path and is REFUSED while the panel is version-pinned.\n' + '- action:"fix" — Repair a pack\'s install and Python dependencies, or pass id:\'all\' to repair every pack. Local operations prefer official comfy-cli; remote single-pack repairs use Manager HTTP. REFUSES the sidebar panel pack — \'fix\' has no verified on-disk check, so use install_comfyui(action:\'panel\') for the panel — and refuses \'all\' while the panel is version-pinned.\n' + '- action:"uninstall" — Uninstall a pack (removes it). IRREVERSIBLE through this tool — for a cleanup audit prefer action:"disable", which is reversible. The pack must be one ComfyUI-Manager tracks: an id that resolves nowhere is REFUSED before anything is queued (a drained queue would otherwise read exactly like a success), and a pack that is on disk but unmanaged is named so you can remove its directory yourself. After the queue drains the installed-pack list is re-read and the pack must be GONE before anything claims \'uninstalled\'. A ComfyUI restart is required to unload it fully. REFUSES the sidebar panel pack.\n' + '- action:"disable" — Disable an installed pack WITHOUT removing it — the reversible first step of a cleanup (re-enable with action:"enable"; action:"uninstall" removes a pack outright). Uses the ComfyUI-Manager HTTP API (works against remote instances) or official comfy-cli locally, and re-reads the installed-pack list afterwards so a Manager no-op is reported as NOT disabled rather than as success. A ComfyUI restart is required for the change to take effect. REFUSES the sidebar panel pack.\n' + '- action:"enable" — Re-enable a pack previously disabled with action:"disable". Same Manager/comfy-cli mechanics and the same post-op re-read, so a Manager no-op is reported as NOT enabled rather than as success. A ComfyUI restart is required for the change to take effect. REFUSES the sidebar panel pack.\n' + '- action:"list" — List installed packs with their version and enabled/disabled state. Uses the ComfyUI-Manager HTTP API (works against remote instances); useCmCli:true uses cm-cli when its installed version is supported, otherwise falls back to Manager HTTP, while the cm-cli path returns names only. Read-only.\n' + '- action:"sync_deps" — Reconcile the Python dependencies of ALL installed packs through official `comfy node restore-dependencies`. Requires a local ComfyUI install and comfy-cli; takes no other parameters.', { action: z .enum([ "install", "update", "reinstall", "fix", "uninstall", "enable", "disable", "list", "sync_deps", ]) .describe('Which custom-node operation to perform. "install", "update", "reinstall", "fix", "uninstall", "enable" and "disable" require `id`; "list" and "sync_deps" take no required parameters.'), id: z .string() .optional() .describe('The pack to act on. REQUIRED for actions "install", "update", "reinstall", "fix", "uninstall", "enable" and "disable". For "install" this is a registry id, git URL, or node-pack name (find one with search_custom_nodes); for "update"/"fix" it may also be \'all\' (every installed pack); for "update"/"reinstall"/"fix"/"uninstall"/"enable"/"disable" it is a registry id / module name of an INSTALLED pack.'), source: z .enum(["registry", "git", "auto"]) .optional() .describe('action:"install" — how to interpret `id` (default \'auto\', which detects git URLs vs registry ids).'), version: z .string() .optional() .describe('Version to install. action:"install" — e.g. \'latest\', \'nightly\', or a semver; for git installs this is treated as a git ref unless `ref` is also provided, and registry installs default to \'latest\'. action:"reinstall" — version to reinstall (default \'latest\').'), ref: z .string() .optional() .describe('action:"install" — git ref (commit SHA, branch, or tag) to pin when installing a git URL. Overrides any ref parsed from the URL and any `version` value. Ignored for registry-id installs.'), mode: modeSchema, channel: channelSchema, useCmCli: useCmCliSchema, }, async (args) => { // `id`/`query` cannot be schema-required in a flat shape, so the handler // enforces per-action presence and names the missing field — the same // information the old per-tool schemas gave a caller. // // ABSENCE only, never falsiness: `id: ""` passed z.string() before this // consolidation and reached the service, which answers with its own // resolves-nowhere / not-found error (and for uninstall, a REFUSAL before // anything is queued). A `!id` guard would swallow that path and answer // with generic text instead. const requireId = (action, what) => { if (args.id === undefined) { throw new Error(`install_custom_node action:"${action}" requires \`id\` — ${what}.`); } return args.id; }; // The `mode` union split back into the two meanings it carries — see // modeSchema. Refusal, never a silent drop: quietly ignoring a `mode` the // action cannot honour would run a DIFFERENT operation than the one asked // for and report it as success. const managerMode = (action) => { if (args.mode === undefined) return undefined; if (MANAGER_MODES.includes(args.mode)) { return args.mode; } throw new Error(`install_custom_node action:"${action}" does not accept mode:"${args.mode}" — ` + `that value belongs to action:"list". Here \`mode\` selects the ComfyUI-Manager ` + `data source: ${MANAGER_MODES.map((m) => `"${m}"`).join(", ")}.`); }; const listMode = () => { if (args.mode === undefined) return undefined; if (LIST_MODES.includes(args.mode)) { return args.mode; } throw new Error(`install_custom_node action:"list" does not accept mode:"${args.mode}" — ` + `that value is the ComfyUI-Manager data source used by ` + `action:"install"/"update"/"reinstall"/"fix". Here \`mode\` selects WHICH ` + `installed packs to report: ${LIST_MODES.map((m) => `"${m}"`).join(", ")}.`); }; const json = (value) => ({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }], }); // `fix` id="all" degrades gracefully rather than throwing, and that check // ran BEFORE the try/catch in the retired tool (it is a capability // statement, not an error). Order preserved: the remote refusal still // wins over anything the try block would report. if (args.action === "fix" && args.id?.trim().toLowerCase() === "all" && !isLocalMode()) { // "all" repairs every pack via the cm-cli subprocess, which needs a local // ComfyUI install. Single-pack repair uses the Manager HTTP API and works // remotely, so only guard the "all" case here. return remoteUnsupported('install_custom_node action:"fix" with id="all" is not supported against a remote ComfyUI. ' + "Repairing every pack runs the cm-cli subprocess, which requires a " + "local ComfyUI install (COMFYUI_PATH). Repair a single pack by id " + "instead (that uses the ComfyUI-Manager HTTP API and works remotely)."); } if (args.action === "sync_deps" && !isLocalMode()) { return remoteUnsupported('install_custom_node action:"sync_deps" is not supported against a remote ComfyUI. It ' + "runs the cm-cli restore-dependencies subprocess, which requires a " + "local ComfyUI install (COMFYUI_PATH). Reconcile dependencies on the " + "ComfyUI host instead."); } try { switch (args.action) { case "install": { const id = requireId("install", "the registry id, git URL, or node-pack name to install"); // BEFORE the panel branch, deliberately. `mode` was a zod-validated // field on every retired tool, so an invalid value was rejected // before ANY handler logic ran — including the panel redirect. Doing // the redirect first would let mode:"imported" ride into a verified // panel mutation that silently ignores it, turning a rejection into // an acceptance. (The redirect ignoring a VALID mode is pre-existing // and disclosed in its own note; a value the action never accepted // is not.) const mode = managerMode("install"); if (targetsPanelPackExactly(id)) { return await runVerifiedPanelAction("install", id, { version: args.version, ref: args.ref, source: args.source, }); } return json(await installCustomNode({ id, source: args.source, version: args.version, ref: args.ref, mode, channel: args.channel, useCmCli: preferLocalComfyCli(args.useCmCli), })); } case "update": { const id = requireId("update", "the registry id / module name to update, or 'all'"); const mode = managerMode("update"); // before the panel branch — see action:"install" if (targetsPanelPackExactly(id)) { return await runVerifiedPanelAction("update", id); } return json(await updateCustomNode({ id, mode, channel: args.channel, useCmCli: preferLocalComfyCli(args.useCmCli), })); } case "reinstall": { const id = requireId("reinstall", "the registry id / module name to reinstall"); const mode = managerMode("reinstall"); // before the panel branch — see action:"install" if (targetsPanelPackExactly(id)) { return await runVerifiedPanelAction("reinstall", id, { version: args.version, }); } return json(await reinstallCustomNode({ id, version: args.version, mode, channel: args.channel, useCmCli: preferLocalComfyCli(args.useCmCli), })); } case "fix": { const id = requireId("fix", "the registry id / module name to repair, or 'all'"); const mode = managerMode("fix"); // before the panel branch — see action:"install" // `fix` has no verified on-disk equivalent (a drained queue is not a // repair — #639/#2490; the HTTP path now fails closed on Manager's // per-task not-found/error, but still cannot prove the pack moved), // so a panel target is refused rather than redirected. Bulk "all" // is handled by the pin guard inside the service. if (targetsPanelPackExactly(id)) { assertPanelNotTargetedUnverifiable('install_custom_node action:"fix"', id); } return json(await fixCustomNode({ id, mode, channel: args.channel, useCmCli: preferLocalComfyCli(args.useCmCli), })); } case "uninstall": { const id = requireId("uninstall", "the registry id / module name of the installed pack to uninstall"); if (targetsPanelPackExactly(id)) { assertPanelNotTargetedUnverifiable('install_custom_node action:"uninstall"', id); } return json(await uninstallCustomNode({ id, useCmCli: preferLocalComfyCli(args.useCmCli), })); } case "disable": { const id = requireId("disable", "the registry id / module name of the installed pack to disable"); // Same refusal as action:"fix": this path's post-state check reads // the Manager list, which cannot see a ".bak" shadow copy (#641) — the // panel goes through the verified install_comfyui(action:\'panel\') path only. if (targetsPanelPackExactly(id)) { assertPanelNotTargetedUnverifiable('install_custom_node action:"disable"', id); } return json(await disableCustomNode({ id, useCmCli: preferLocalComfyCli(args.useCmCli), })); } case "enable": { const id = requireId("enable", "the registry id / module name of the installed pack to enable"); if (targetsPanelPackExactly(id)) { assertPanelNotTargetedUnverifiable('install_custom_node action:"enable"', id); } return json(await enableCustomNode({ id, useCmCli: preferLocalComfyCli(args.useCmCli), })); } case "list": { const mode = listMode(); const nodes = await listInstalledNodes({ mode, useCmCli: args.useCmCli, }); // The disclosure rides on the RESULT, not only on the schema: the // caller who has already been misled is the one reading this text. // See IMPORTED_MODE_DISCLOSURE. const notes = [ mode === "imported" ? IMPORTED_MODE_DISCLOSURE : undefined, mode !== undefined && args.useCmCli ? CM_CLI_MODE_DROPPED : undefined, ].filter((n) => n !== undefined); const text = [formatInstalledNodes(nodes), ...notes].join("\n\n"); return { content: [{ type: "text", text }] }; } case "sync_deps": return json(await syncNodeDependencies()); default: { // Unreachable given the zod enum, but a clear runtime guard beats a // silent undefined if the schema and switch ever drift apart. const exhaustive = args.action; throw new Error(`Unknown install_custom_node action "${String(exhaustive)}". Expected one of: install, update, reinstall, fix, uninstall, enable, disable, list, sync_deps.`); } } } catch (err) { return errorToToolResult(err); } }); } //# sourceMappingURL=node-management.js.map