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

199 lines • 9.62 kB
import { RegistryError, unreachableHostMessage } from "../utils/errors.js"; import { logger } from "../utils/logger.js"; const REGISTRY_BASE = "https://api.comfy.org"; /** * The ComfyUI Registry returns `latest_version` (and `versions[]` entries) as * OBJECTS — e.g. `{ version: "8.28.3", changelog, createdAt, ... }` — not as * the bare version string. Naively rendering the object yields "[object * Object]", and passing it where a string is expected throws * "value.toLowerCase is not a function". Coerce to the version string here so * every consumer (search/details renderers, skill cache) gets a clean string. */ export function extractVersionString(value) { if (typeof value === "string") return value.trim() || undefined; if (value && typeof value === "object") { const v = value.version; if (typeof v === "string") return v.trim() || undefined; } return undefined; } async function registryFetch(path) { const url = `${REGISTRY_BASE}${path}`; logger.debug("Registry API request", { url }); // Third-party API: bound the wait so a stalled response cannot wedge the // turn. Same class as #1026 — an unbounded metadata call has no limit at // all and hangs until the caller gives up. // // #1136: a failure to REACH api.comfy.org is the one that misleads worst here, // because this endpoint backs "search for the pack you need" — the surface a // filtered user is most often sent to, and the one whose failure they read as // "the pack does not exist". Only errors thrown by fetch() itself take this // path; an HTTP status is a real answer from the host and keeps its own // wording below. const res = await fetch(url, { signal: AbortSignal.timeout(20_000) }).catch((err) => { const { message, code } = unreachableHostMessage(err, url, "the ComfyUI Registry lookup"); throw new RegistryError(message, { url, code }); }); if (!res.ok) { // unknown-ok: "" is interpolated into an ERROR MESSAGE and nothing else — the // HTTP status is reported either way, so an unreadable body costs detail in the // text, never a wrong conclusion. Verified there is no branch on this value. const body = await res.text().catch(() => ""); throw new RegistryError(`Registry API ${res.status}: ${res.statusText}`, { url, status: res.status, body }); } return res.json(); } export async function searchNodes(query, options = {}) { const { page = 1, limit = 10 } = options; // Upstream bug: api.comfy.org/nodes accepts a `search` query param but // ignores it server-side, always returning the default paginated list. // Workaround: fetch a larger window and rank-filter client-side by query // against id / name / description / author, boosting by total_install so // canonical packs win over obscure substring matches. // Originally diagnosed and patched by João Lucas (github.com/joaolvivas) // in joaolvivas/comfyui-mcp-byjlucas@f066b597 (2026-05-12). const fetchLimit = 100; const lowerQuery = query.trim().toLowerCase(); const params = new URLSearchParams({ page: "1", limit: String(fetchLimit), }); // Still pass the param in case upstream fixes the filter eventually. if (lowerQuery) params.set("search", query); const data = await registryFetch(`/nodes?${params}`); const allNodes = Array.isArray(data) ? data : (data.nodes ?? []); const matchScore = (n) => { if (!lowerQuery) return 0; const id = (n.id ?? "").toLowerCase(); const name = (n.name ?? "").toLowerCase(); const desc = (n.description ?? "").toLowerCase(); const author = (n.author ?? "").toLowerCase(); let score = 0; if (id === lowerQuery) score += 1000; else if (id.includes(lowerQuery)) score += 500; if (name === lowerQuery) score += 800; else if (name.includes(lowerQuery)) score += 300; if (author.includes(lowerQuery)) score += 200; if (desc.includes(lowerQuery)) score += 100; if (score === 0) return 0; // no textual match — drop, do NOT inflate via popularity if (typeof n.total_install === "number" && n.total_install > 0) { score += Math.min(50, Math.floor(Math.log10(n.total_install + 1) * 10)); } return score; }; const filtered = lowerQuery ? allNodes .map((n) => ({ node: n, score: matchScore(n) })) .filter(({ score }) => score > 0) .sort((a, b) => b.score - a.score) .map(({ node }) => node) : allNodes; const start = (page - 1) * limit; const paged = filtered.slice(start, start + limit); // #773 — the client-side filter only ever sees a 100-pack WINDOW of a // registry with thousands of packs, so a query naming a real pack outside // that window (including its exact id, e.g. "comfyui kjnodes" → // "comfyui-kjnodes") false-empties. When the filter matched NOTHING, try the // direct pack-details endpoint with the query normalized to a registry-id // slug before reporting no matches. The fallback can only ADD a result; only // an observed 404 leaves the honest empty answer — any other failure means // absence was never established and is refused with the reason. PAGE 1 ONLY: // the fallback has no pagination of its own, so it must not answer for a // later page (an out-of-window exact match would otherwise surface ON page 2). if (lowerQuery && filtered.length === 0 && page === 1) { const candidates = registryIdCandidatesFromQuery(query); // The candidates are guesses, not one answer: a 404 only rules out THAT id, // so keep trying the rest. A non-404 failure means absence was never // established for that candidate — remember it; if no candidate resolves, // reporting "no matches" would be an unearned negative, so refuse with the // reason instead of returning the empty page. let unresolved; for (const candidate of candidates) { try { const details = await getNodePackDetails(candidate); logger.info(`Registry search "${query}" matched nothing in the fetched window, ` + `but "${candidate}" resolved via the direct pack-details endpoint`); return [normalizeSearchResult(details)]; } catch (err) { const status = err instanceof RegistryError ? err.details?.status : undefined; if (status !== 404) { unresolved ??= err; continue; } logger.debug(`Registry exact-id fallback for "${query}": no pack with id "${candidate}" (404)`); } } if (unresolved !== undefined) { throw new RegistryError(`Registry search "${query}" matched nothing in the fetched pack window, ` + `and the exact-id fallback could not be resolved ` + `(${unresolved instanceof Error ? unresolved.message : String(unresolved)}) — so "no matches" ` + `cannot be confirmed. Retry, or look the pack up directly with ` + `search_custom_nodes (action:"details").`); } } logger.info(`Registry search "${query}": fetched ${allNodes.length}, matched ${filtered.length}, returning ${paged.length} (page ${page}, limit ${limit})`); return paged.map(normalizeSearchResult); } /** * Normalize a free-text query to candidate Comfy Registry pack ids, most * literal first: lowercase, runs of non-alphanumerics collapsed to single * hyphens, edges trimmed ("comfyui kjnodes" → "comfyui-kjnodes"). * * #773 recurrence (0.52.1): one slug is not enough. Registry ids are derived * from GitHub repo names, and most packs live under a "ComfyUI-" repo prefix — * so "WanVideoWrapper" slugs to "wanvideowrapper" while the real pack is * "comfyui-wanvideowrapper" (same for VideoHelperSuite, SAM3). The endpoint is * case-insensitive but not prefix-insensitive, so the prefixed form is offered * as a second candidate unless the slug already carries it. */ export function registryIdCandidatesFromQuery(query) { const slug = query .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); if (slug.length === 0) return []; return slug.startsWith("comfyui-") ? [slug] : [slug, `comfyui-${slug}`]; } /** Coerce the registry's object-shaped `latest_version` into a string. */ function normalizeSearchResult(node) { return { ...node, latest_version: extractVersionString(node.latest_version), }; } export async function getNodePackDetails(id) { const data = await registryFetch(`/nodes/${encodeURIComponent(id)}`); logger.info(`Fetched details for node pack "${id}"`); const rawVersions = data.versions; const versions = []; if (Array.isArray(rawVersions)) { for (const v of rawVersions) { const version = extractVersionString(v?.version); if (version) { versions.push({ version, changelog: v?.changelog }); } } } return { ...data, latest_version: extractVersionString(data.latest_version), versions, }; } //# sourceMappingURL=registry-client.js.map