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),
381 lines • 15.3 kB
JavaScript
import { getComfyUIBaseUrl } from "../config.js";
import { comfyuiFetch } from "../comfyui/fetch.js";
import { detectManagerApi, managerApiPrefixFor } from "./node-management.js";
/** Same cap as panel_search_nodes / the panel's SEARCH_LIMIT_CAP (#1287). */
export const SEARCH_LIMIT_CAP = 40;
export const HOST_HTTP_SEARCH_NOTE = "Searched via ComfyUI-Manager HTTP because the panel's browser request to " +
"Manager getmappings did not complete. Live canvas reads still work; this is a " +
"panel-origin transport failure, not a Manager outage or a missing pack.";
export const GIT_ONLY_SEARCH_NOTE = "Hits with git:true are git repositories, not Manager registry ids. " +
"panel_install_node cannot install those on Manager v4 because v4 ignores the " +
"repository URL and resolves by bare name. Use install_custom_node(source:'git') " +
"only when its local target is the same ComfyUI as this panel.";
/** A raw git clone URL — never a valid panel_install_node registry id (#1539). */
export function isRawGitUrlInstallId(id) {
const s = id.trim();
return /^(https?|ssh|git):\/\//i.test(s) || /^git\+/i.test(s) || /^git@/i.test(s);
}
export function gitMappingRepoName(url) {
const trimmed = url.trim().replace(/\.git$/i, "").replace(/\/+$/, "");
const parts = trimmed.split(/[/:]/).filter(Boolean);
const last = parts[parts.length - 1];
return last && !/^(github\.com|gitlab\.com|bitbucket\.org)$/i.test(last) ? last : trimmed;
}
function joinNotes(...parts) {
const joined = parts.filter((p) => typeof p === "string" && p.length > 0).join(" ");
return joined || undefined;
}
function mappingInstallId(candidates) {
const strings = candidates
.map((c) => (c == null || c === "" ? "" : String(c)))
.filter((s) => s.length > 0);
const registry = strings.find((s) => !isRawGitUrlInstallId(s));
const url = strings.find((s) => isRawGitUrlInstallId(s));
if (registry)
return url ? { id: registry, repository: url } : { id: registry };
if (url)
return { id: gitMappingRepoName(url), repository: url, git: true };
return { id: strings[0] ?? "" };
}
function withGitSearchNote(result) {
if (!result.results?.some((hit) => hit.git))
return result;
const note = joinNotes(result.note, GIT_ONLY_SEARCH_NOTE);
return note === result.note ? result : { ...result, note };
}
function rewriteGitUrlHit(hit) {
if (!isRawGitUrlInstallId(hit.id))
return hit;
return {
...hit,
id: gitMappingRepoName(hit.id),
repository: hit.repository ?? hit.id,
git: true,
};
}
function isSearchEnvelope(value) {
return !Array.isArray(value);
}
function tryParseSearchEnvelope(text) {
try {
const parsed = JSON.parse(text);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
return undefined;
return parsed;
}
catch {
return undefined;
}
}
/**
* Rewrite a search payload (object or ToolResult JSON) so `id` is never a raw
* Git URL. Panel nodes_search still keys some hits on the mapping URL; passing
* that `id` to panel_install_node queues a Manager v4 registry lookup (#1539).
*/
export function sanitizeSearchInstallIds(value) {
if (!isSearchEnvelope(value))
return value;
if (Array.isArray(value.content)) {
if (value.isError === true)
return value;
let changed = false;
const content = value.content.map((block) => {
if (!block || block.type !== "text" || typeof block.text !== "string")
return block;
const parsed = tryParseSearchEnvelope(block.text);
if (parsed === undefined)
return block;
const sanitized = sanitizeSearchInstallIds(parsed);
if (sanitized === parsed)
return block;
changed = true;
return { ...block, text: JSON.stringify(sanitized, null, 2) };
});
return changed ? { ...value, content } : value;
}
if (!Array.isArray(value.results))
return value;
let changed = false;
const results = value.results.map((raw) => {
if (!raw || typeof raw !== "object")
return raw;
if (typeof raw.id !== "string")
return raw;
const next = rewriteGitUrlHit(raw);
if (next !== raw)
changed = true;
return next;
});
const payload = changed ? { ...value, results } : value;
const noted = withGitSearchNote(payload);
return noted === value ? value : noted;
}
const MODES = ["cache", "remote", "local"];
let fetchMappingsOverride;
/** Test seam — restore with `undefined` in afterEach. */
export function setFetchMappingsForTests(fn) {
fetchMappingsOverride = fn;
}
function queryTerms(query) {
return String(query ?? "")
.toLowerCase()
.split(/\s+/)
.filter(Boolean);
}
function matchesAllTerms(hay, terms) {
return terms.every((t) => hay.includes(t));
}
function extractText(value) {
if (typeof value === "string")
return value;
if (value instanceof Error)
return value.message;
if (value && typeof value === "object") {
const rec = value;
if (Array.isArray(rec.content)) {
return rec.content.map((c) => (typeof c?.text === "string" ? c.text : "")).join("\n");
}
if (typeof rec.message === "string")
return rec.message;
}
return "";
}
/** HTTP 5xx from Manager's getmappings — a Manager outage, not a missing pack. */
export function isManagerMappingsServerError(value) {
const text = extractText(value);
return /getmappings/i.test(text) && /HTTP\s*5\d\d\b/.test(text);
}
/**
* Panel wrap from comfyui-mcp-panel#1130 / comfyui-mcp#1472: the browser's
* Manager `fetch` never completed, so there is no HTTP status or body.
*
* Anchored on the wrap + a transport cause at the START of that cause.
* A Manager-originated rejection that merely mentions "Failed to fetch" or
* "NetworkError" mid-sentence is not this. AbortError is the caller's own
* cancellation. Mutating Manager routes (install/queue) do not inherit the
* read-only host-HTTP fallback this predicate gates.
*/
export function isManagerTransportFetchFailure(value) {
const text = extractText(value).replace(/^(?:Error:\s*)+/i, "").trim();
if (!text)
return false;
const match = /^ComfyUI-Manager request to (\S+) did not complete:\s*([\s\S]+)$/i.exec(text);
if (!match)
return false;
const path = match[1] ?? "";
const cause = (match[2] ?? "").trim();
if (!/\/(?:v2\/)?customnode\/(?:getmappings|installed)(?:\?|$)/i.test(path))
return false;
if (/^AbortError\b/i.test(cause))
return false;
if (/\bHTTP\s*\d{3}\b/.test(cause))
return false;
return (/^(?:TypeError:\s*)?Failed to fetch\b/i.test(cause) ||
/^fetch failed\.?$/i.test(cause) ||
/^NetworkError\b/i.test(cause));
}
export function dualCauseSearchFailure(query, panelErr, hostErr) {
const panel = extractText(panelErr) || "panel Manager request did not complete";
const host = extractText(hostErr) || "host Manager HTTP failed";
return {
supported: false,
count: 0,
results: [],
query: query == null ? "" : String(query),
reason: panel,
message: "Node-registry search could not complete: the panel's ComfyUI-Manager request " +
"did not complete (Failed to fetch), and host Manager HTTP mappings also failed. " +
`Panel: ${panel} Host: ${host} ` +
"That is not a Manager outage inferred from a transport-only fetch failure, and " +
"not proof the pack is missing. Retry after Manager recovers, or search with " +
'search_custom_nodes action:"search" (queries api.comfy.org directly, not through Manager).',
};
}
export function catalogueSize(data) {
if (Array.isArray(data))
return data.length;
if (data && typeof data === "object")
return Object.keys(data).length;
return 0;
}
/**
* Normalize a ComfyUI-Manager `/customnode/getmappings` payload into
* `{ count, results:[{id,title,description}] }`. Mirrors the panel parser
* (array or repo-key map). `id` is a CNR/reference id or the repo name derived
* from a git mapping key — never a raw Git URL, never title (#1539).
*/
export function parseNodeMappings(data, query, limit) {
const terms = queryTerms(query);
const out = [];
const push = (candidates, title, desc, classNames) => {
const install = mappingInstallId(candidates);
if (!install.id)
return;
const titleStr = title == null ? install.id : String(title);
const descStr = String(desc ?? "").slice(0, 160);
const classes = Array.isArray(classNames) ? classNames.map(String).join(" ") : "";
const hay = `${candidates.join(" ")} ${install.id} ${titleStr} ${descStr} ${classes}`.toLowerCase();
if (matchesAllTerms(hay, terms)) {
out.push({
id: install.id,
title: titleStr,
description: descStr,
...(install.repository ? { repository: install.repository } : {}),
...(install.git ? { git: true } : {}),
});
}
};
if (Array.isArray(data)) {
for (const p of data) {
if (!p || typeof p !== "object")
continue;
const pack = p;
push([pack.id, pack.reference, pack.title], pack.title ?? pack.title_aux, pack.description, pack.nodename);
}
}
else if (data && typeof data === "object") {
for (const [key, val] of Object.entries(data)) {
const classes = Array.isArray(val) && Array.isArray(val[0]) ? val[0] : undefined;
const meta = Array.isArray(val) ? val[1] : val;
const m = meta && typeof meta === "object"
? meta
: {};
push([m.id, m.reference, key], m.title ?? m.title_aux, m.description, classes);
}
}
const requested = Number(limit) || 15;
const max = Math.min(requested, SEARCH_LIMIT_CAP);
const result = withGitSearchNote({
count: out.length,
results: out.slice(0, max),
catalogue_size: catalogueSize(data),
});
if (requested > SEARCH_LIMIT_CAP)
result.limit_cap = SEARCH_LIMIT_CAP;
return result;
}
export function managerMappingsOutageResult(query, err) {
const reason = extractText(err) || "ComfyUI-Manager mappings HTTP 500";
return {
supported: false,
manager_outage: true,
count: 0,
results: [],
query: query == null ? "" : String(query),
reason,
message: "Node-registry search could not complete: ComfyUI-Manager's mappings endpoint is " +
"returning HTTP 5xx. That is a Manager outage, not proof this pack is missing. " +
"Retry after Manager recovers, or search with search_custom_nodes action:\"search\" " +
"(queries api.comfy.org directly, not through Manager).",
};
}
async function defaultFetchMappings(mode) {
const base = getComfyUIBaseUrl();
const api = await detectManagerApi(base);
const path = `${managerApiPrefixFor(api)}/customnode/getmappings?mode=${encodeURIComponent(mode)}`;
const res = await comfyuiFetch(`${base}${path}`);
if (!res.ok) {
throw Object.assign(new Error(`Manager customnode/getmappings?mode=${mode}: HTTP ${res.status}`), {
status: res.status,
});
}
return res.json();
}
function resolveFetch(fn) {
return fn ?? fetchMappingsOverride ?? defaultFetchMappings;
}
/**
* Search installable packs via Manager getmappings.
*
* Cache is tried first (what the panel asks for). An HTTP 5xx from that
* endpoint is a Manager outage on one source, not a missing pack: retry
* remote, then local. If every mode 5xxs, return a structured outage —
* never throw a "not found" and never fail the whole search.
*/
export async function searchNodesViaMappings(opts) {
const fetchMappings = resolveFetch(opts.fetchMappings);
let lastErr;
let data;
let usedMode;
let sawServerError = false;
for (const mode of MODES) {
try {
data = await fetchMappings(mode);
usedMode = mode;
break;
}
catch (err) {
lastErr = err;
if (isManagerMappingsServerError(err)) {
sawServerError = true;
continue;
}
if (sawServerError)
continue;
throw err;
}
}
if (usedMode === undefined) {
return managerMappingsOutageResult(opts.query, lastErr);
}
const parsed = parseNodeMappings(data, opts.query, opts.limit);
if (usedMode === "cache")
return parsed;
return {
...parsed,
requested_mode: usedMode,
degraded_from: "cache",
message: `ComfyUI-Manager's cache mappings endpoint returned HTTP 5xx; searched via ` +
`mode=${usedMode} instead. That 500 is a Manager outage on the cache source, ` +
`not proof the pack is missing.`,
};
}
async function hostSearchAfterTransport(opts, panelErr) {
try {
const parsed = await searchNodesViaMappings(opts);
if (parsed.manager_outage) {
return {
...dualCauseSearchFailure(opts.query, panelErr, parsed.reason ?? parsed.message),
manager_outage: true,
};
}
return { ...parsed, source: "host_http", note: joinNotes(HOST_HTTP_SEARCH_NOTE, parsed.note) };
}
catch (hostErr) {
return dualCauseSearchFailure(opts.query, panelErr, hostErr);
}
}
function shouldHostSearch(value) {
return isManagerTransportFetchFailure(value) || isManagerMappingsServerError(value);
}
/**
* Panel nodes_search first. If the panel surfaces a mappings HTTP 5xx
* (throw or fail() ToolResult), degrade to searchNodesViaMappings.
* If the panel's Manager fetch never completed (`Failed to fetch` wrap),
* the same host-HTTP search is used — that is a panel-origin transport
* failure, not a Manager outage and not a missing pack.
*/
export async function searchPanelNodes(opts) {
try {
const value = await opts.panelSearch();
if (!shouldHostSearch(value)) {
const sanitized = value && typeof value === "object" && !Array.isArray(value)
? sanitizeSearchInstallIds(value)
: value;
return { via: "panel", value: sanitized };
}
if (isManagerTransportFetchFailure(value)) {
return { via: "fallback", value: await hostSearchAfterTransport(opts, value) };
}
return { via: "fallback", value: await searchNodesViaMappings(opts) };
}
catch (err) {
if (!shouldHostSearch(err))
throw err;
if (isManagerTransportFetchFailure(err)) {
return { via: "fallback", value: await hostSearchAfterTransport(opts, err) };
}
return { via: "fallback", value: await searchNodesViaMappings(opts) };
}
}
//# sourceMappingURL=manager-node-search.js.map