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

138 lines • 8.7 kB
import { z } from "zod"; import { searchNodes, getNodePackDetails } from "../services/registry-client.js"; import { errorToToolResult } from "../utils/errors.js"; /** * The two public-registry DISCOVERY tools collapsed into one * action-parameterized `search_custom_nodes` tool (0.50.0 surface * consolidation, slice 12). * * WHY THIS IS ITS OWN TOOL. The first cut of this slice folded search and * details into `install_custom_node` alongside the nine lifecycle actions, * because discovery→install is the most common path through the family. The * owner ruled the other way, and the reason is the same one the admission work * in this slice made concrete: these two actions are READ-ONLY, NETWORK-ONLY * lookups against registry.comfy.org that need neither a running ComfyUI nor * COMFYUI_PATH, while every action on `install_custom_node` either runs * third-party pack code or mutates what is installed. Keeping "look something * up" in the same tool as "execute a stranger's install script" put two very * different blast radii behind one name — and a name is the unit both the * call_tool whitelist and the ollama loop-breaker reason about. * * The cost is one tool slot: once every 0.50.0 slice has landed the fully * consolidated surface will be 33 tools rather than 32. (MAX_TOOLS tracks the * surface as it stands today, mid-consolidation, and is far higher.) * * 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 — `query` belongs to * "search" and `id` to "details". Every VALUE constraint the old tools had is * unchanged at the zod layer (`limit` keeps .int().min(1).max(50), `page` keeps * .int().min(1)); the handler enforces per-action presence and names the missing * field. Each branch calls the same registry-client function the old tool * called, with the same arguments, and returns the identical content block. */ export function registerRegistrySearchTools(server) { server.tool("search_custom_nodes", "Discover ComfyUI custom node PACKS in the public ComfyUI Registry (registry.comfy.org). Read-only and network-only: queries the hosted registry over HTTP and does NOT require a running ComfyUI or COMFYUI_PATH. This searches node PACKS, not models (use download_model action:\"search\") and not local installs (use list_local_models action:\"list\"). To actually install what you find, or to manage packs already installed, use install_custom_node. Driven by the `action` parameter:\n" + '- action:"search" — Search by keyword; `query` required. Returns a ranked list of packs with id, name, author, install count, and latest version. The keyword search ranks a fixed window of packs client-side, so when it matches nothing the query is also tried as an exact registry id automatically (e.g. \'comfyui kjnodes\' → \'comfyui-kjnodes\'). Pass a returned id to action:"details" for full info, or to install_custom_node (action:"install").\n' + '- action:"details" — Full details for ONE pack by its exact registry id: description, author, license, repository, install count, latest version, the node types it provides, and recent version changelogs. Look up the id via action:"search" first.', { action: z .enum(["search", "details"]) .describe('Which registry lookup to perform. "search" requires `query` (and takes optional `limit`/`page`); "details" requires `id`.'), query: z .string() .optional() .describe('action:"search" — REQUIRED. Keyword(s) to match against pack name/description, e.g. \'impact\', \'controlnet aux\'.'), limit: z .number() .int() .min(1) .max(50) .optional() .describe('action:"search" — max results to return (default 10).'), page: z .number() .int() .min(1) .optional() .describe('action:"search" — page number for pagination (default 1).'), id: z .string() .optional() .describe('action:"details" — REQUIRED. Exact registry pack id (the \'id\' field from action:"search"), e.g. \'comfyui-impact-pack\'.'), }, async (args) => { try { switch (args.action) { case "search": { // `query`/`id` cannot be schema-required in a flat shape, so the // handler enforces per-action presence and names the missing field. // // ABSENCE only, never falsiness: `query: ""` passed z.string() // before this consolidation and reached searchNodes, which answers // for an empty query (the "no matches" path, then the exact-id // retry). A `!query` guard would replace that live answer with // generic text. if (args.query === undefined) { throw new Error('search_custom_nodes action:"search" requires `query` — the keyword(s) to match against pack name/description.'); } const results = await searchNodes(args.query, { limit: args.limit, page: args.page, }); const text = results.length === 0 ? `No custom nodes found for "${args.query}".` : results .map((r, i) => `${i + 1}. **${r.name}** (${r.id})\n` + ` ${r.description ?? "No description"}\n` + ` Author: ${r.author} | Installs: ${r.total_install ?? "N/A"} | Version: ${r.latest_version ?? "N/A"}`) .join("\n\n"); return { content: [{ type: "text", text }] }; } case "details": { if (args.id === undefined) { throw new Error('search_custom_nodes action:"details" requires `id` — the exact registry pack id to look up.'); } const details = await getNodePackDetails(args.id); const lines = [ `# ${details.name}`, "", details.description ?? "", "", `- **Author**: ${details.author}`, `- **License**: ${details.license ?? "N/A"}`, `- **Repository**: ${details.repository ?? "N/A"}`, `- **Total Installs**: ${details.total_install ?? "N/A"}`, `- **Latest Version**: ${details.latest_version ?? "N/A"}`, `- **Created**: ${details.created_at ?? "N/A"}`, `- **Updated**: ${details.updated_at ?? "N/A"}`, ]; if (details.nodes?.length) { lines.push("", "## Nodes Provided", ...details.nodes.map((n) => `- ${n}`)); } if (details.versions?.length) { lines.push("", "## Recent Versions", ...details.versions.slice(0, 5).map((v) => `- **${v.version}**${v.changelog ? `: ${v.changelog}` : ""}`), // #809: say how many were dropped. This preview has no parameter to raise, // so the honest remedy is the upstream index, not an invented lever. ...(details.versions.length > 5 ? [ `- …and ${details.versions.length - 5} older version(s) not shown (fixed 5-version preview — full history at https://registry.comfy.org/)`, ] : [])); } return { content: [{ type: "text", text: lines.join("\n") }] }; } 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 search_custom_nodes action "${String(exhaustive)}". Expected one of: search, details.`); } } } catch (err) { return errorToToolResult(err); } }); } //# sourceMappingURL=registry-search.js.map