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),
166 lines • 8.47 kB
JavaScript
import { getObjectInfo, getSystemStats } from "../comfyui/client.js";
import { searchCivitaiModels } from "../services/civitai-resolver.js";
import { searchHuggingFaceModels } from "../services/model-resolver.js";
import { findMissingModels, resolveCandidatesDetailed, } from "../services/missing-models.js";
import { errorToToolResult, ValidationError } from "../utils/errors.js";
import { config } from "../config.js";
function parseWorkflow(input) {
if (typeof input === "string") {
try {
const parsed = JSON.parse(input);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new ValidationError("Workflow JSON must be an object with node IDs as keys");
}
return parsed;
}
catch (err) {
if (err instanceof ValidationError)
throw err;
throw new ValidationError(`Invalid JSON string: ${err.message}`);
}
}
if (typeof input === "object" && input !== null && !Array.isArray(input)) {
return input;
}
throw new ValidationError("Workflow must be a JSON string or object");
}
/** Weight files worth offering; skip READMEs, configs, previews. */
const WEIGHT_RE = /\.(safetensors|ckpt|pt|pth|bin|gguf|sft)$/i;
/**
* List a HuggingFace repo's weight files WITH sizes. HF search returns REPOS,
* not files, so without this expansion a candidate has no size, no precision and
* no fit verdict — i.e. none of what makes the list useful on a small GPU.
*
* Must be `/tree/main`: the plain `/api/models/{id}` response carries
* `siblings[].rfilename` but NO size, and `?blobs=true` 400s. Verified live.
* The repo id is `org/name` — its slash is part of the PATH, so it must not be
* percent-encoded (encoding the whole id yields a 404).
*/
async function hfRepoFiles(repoId) {
const path = repoId.split("/").map(encodeURIComponent).join("/");
// Captured ONCE: the credential getters resolve from the canonical store on
// every access, so testing one read and interpolating another can send a
// different token — or `Bearer undefined` — if the store is rewritten in
// between (codex gate, round 6, finding 3).
const hfToken = config.huggingfaceToken;
const res = await fetch(`https://huggingface.co/api/models/${path}/tree/main`, {
// config.huggingfaceToken resolves at ACCESS time from the canonical
// ~/.comfyui-mcp/.env and honours the HUGGINGFACE_TOKEN alias too (#826):
// reading process.env.HF_TOKEN directly made a token saved after this
// process started invisible here, while the save reported live pickup.
headers: hfToken ? { authorization: `Bearer ${hfToken}` } : undefined,
// Third-party API: bound the wait (same class as #1026).
signal: AbortSignal.timeout(20_000),
});
if (!res.ok)
return [];
const body = (await res.json());
if (!Array.isArray(body))
return [];
return body
.filter((e) => e.type === "file" && typeof e.path === "string" && WEIGHT_RE.test(e.path))
.map((e) => ({ filename: e.path, size_bytes: typeof e.size === "number" ? e.size : undefined }));
}
function liveDeps() {
return {
searchCivitai: async (query, types) => {
const res = await searchCivitaiModels(query, { types, limit: 6 });
return res.hits.map((h) => ({
name: h.name,
model_id: h.model_id,
version_id: h.version_id,
size_mb: h.size_mb,
base_model: h.base_model,
}));
},
searchHf: async (query) => (await searchHuggingFaceModels(query, { limit: 4 })).map((m) => ({ id: m.id })),
hfRepoFiles,
vramBytes: async () => {
const stats = await getSystemStats();
const vram = stats.devices?.[0]?.vram_total;
return typeof vram === "number" && vram > 0 ? vram : undefined;
},
};
}
function human(bytes) {
if (!bytes || bytes <= 0)
return "?";
const gb = bytes / 1024 ** 3;
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${Math.round(bytes / 1024 ** 2)} MB`;
}
const FIT_ICON = { fits: "✓ fits", tight: "~ tight", too_big: "✗ won't fit", unknown: "? unknown" };
function renderCandidate(c) {
const bits = [
`- **${c.filename}**`,
`${human(c.size_bytes)}`,
c.precision === "unknown" ? "" : c.quant ? `${c.precision.toUpperCase()} ${c.quant}` : c.precision.toUpperCase(),
c.source,
FIT_ICON[c.fit ?? "unknown"] ?? "",
c.base_model ? `base: ${c.base_model}` : "",
c.match === "exact" ? "**exact name**" : c.match === "stem" ? "same name, different format" : "",
].filter(Boolean);
let line = bits.join(" · ");
if (c.requires_pack)
line += `\n ⚠️ needs the **${c.requires_pack}** node pack — a GGUF will NOT load in CheckpointLoaderSimple.`;
if (c.url)
line += `\n ${c.url}`;
else if (c.civitai_model_id)
line += `\n civitai model ${c.civitai_model_id}${c.civitai_version_id ? ` (version ${c.civitai_version_id})` : ""} → download_model action:"download_civitai"`;
return line;
}
/**
* The missing-model resolver, no longer a tool of its own.
*
* 0.50.0 slice 11 folded it into `download_model` (action:"resolve_missing");
* see ./model-management.ts for the dispatcher. The body below is the old
* handler verbatim — same object-info read, same candidate lookup, same
* "could not look" vs "nothing exists" distinction (#796).
*/
export async function resolveMissingModelsAction(args) {
try {
const workflow = parseWorkflow(args.workflow);
const objectInfo = await getObjectInfo();
const missing = findMissingModels(workflow, objectInfo);
if (missing.length === 0) {
return {
content: [
{
type: "text",
text: "## No missing models\n\nEvery model this workflow references is present on the connected ComfyUI. (If it still fails to run, check missing custom node packs with list_packs (action:\"extract_deps\").)",
},
],
};
}
const deps = liveDeps();
const lines = [`## ${missing.length} missing model(s)`, ""];
for (const m of missing) {
lines.push(`### \`${m.name}\``, `needed by node ${m.node_id} (${m.node_type} · ${m.widget})${m.directory ? ` → installs to \`models/${m.directory}/\`` : ""}`, "");
const lookup = await resolveCandidatesDetailed(m, deps, { limit: args.limit ?? 8 }).catch((err) => ({
candidates: [],
failedProviders: [
`the candidate lookup itself (${err instanceof Error ? err.message : String(err)})`,
],
}));
if (lookup.candidates.length === 0) {
lines.push(lookup.failedProviders.length > 0
? `_No candidates — but the lookup FAILED (${lookup.failedProviders.join("; ")}), so ` +
`this is "could not look", NOT "nothing exists". Check the network/provider and retry, ` +
`or try download_model action:"search" / action:"search_civitai" with a different query._`
: `_No candidates found on CivitAI or HuggingFace — try download_model action:"search" / action:"search_civitai" with a different query._`, "");
continue;
}
if (lookup.failedProviders.length > 0) {
lines.push(`_(Partial results — the ${lookup.failedProviders.join("; ")} lookup failed; other sources may have more.)_`, "");
}
lines.push(...lookup.candidates.map(renderCandidate), "");
}
lines.push("---", "Nothing was downloaded. Pick a candidate and call **download_model** with " +
'`action:"download"` (url) or `action:"download_civitai"` (id)' +
", passing the model's directory as `target_subfolder`.", "Names can collide across quantisations and base models — prefer an **exact name** match, and check `base:` before taking a fuzzy one.");
return { content: [{ type: "text", text: lines.join("\n") }] };
}
catch (err) {
return errorToToolResult(err);
}
}
//# sourceMappingURL=missing-models.js.map