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),
437 lines • 25.5 kB
JavaScript
// Model Explorer metadata-curation tools, on the SHARED comfyui MCP surface so
// EVERY backend (Claude, Kimi, Codex, Gemini, Grok, Ollama…) can call them — not
// just the panel/Claude in-process path. They HTTP-proxy the ComfyUI
// `comfyui-model-explorer` node's routes (the node is the single source of truth
// for embedded safetensors metadata). "read" + "propose" only — the human Confirms
// the write in the diff-review window, so there is intentionally no write tool here.
import { z } from "zod";
import { open, readFile } from "node:fs/promises";
import { errorToToolResult } from "../utils/errors.js";
import { getComfyUIBaseUrl, isRemoteMode } from "../config.js";
import { comfyuiFetch } from "../comfyui/fetch.js";
import { resolveExistingModelFile } from "../services/model-resolver.js";
/**
* The connected ComfyUI's base URL.
*
* This used to re-derive the address from raw env (`COMFYUI_URL` / `COMFYUI_PORT`,
* else loopback:8188), which silently disagreed with the resolver every other
* caller uses on three counts: it ignored the configured PROTOCOL (an https
* ComfyUI got http), the config-resolved HOST, and the BASE PATH that a
* reverse-proxied install needs (`/comfyapi`). So model_metadata pointed at the
* wrong address on exactly the deployments #952 and #954 were about.
*/
function comfyBase() {
return getComfyUIBaseUrl().replace(/\/$/, "");
}
const okText = (value) => ({
content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }],
});
// A 404 on a /model_explorer/* route has two possible causes: (1) the optional
// `comfyui-model-explorer` custom node isn't installed, so the route itself does
// not exist (the common case — the raw 404 that motivated #363); or (2) the node
// IS installed but reports the requested model file as not found. HTTP status
// alone can't distinguish them, so surface an actionable message covering BOTH
// rather than leaking the bare 404 or over-claiming that the node is absent. A
// `detail` string from the upstream body (when present) is appended as a hint.
// Other statuses (500, 503, …) are real upstream errors and pass through.
export function explorerHttpError(route, status, body) {
if (status === 404) {
const hint = body && body.trim() ? ` Upstream detail: ${body.trim().slice(0, 200)}.` : "";
return new Error(`GET /model_explorer/${route} returned HTTP 404. Most likely the optional ` +
`'comfyui-model-explorer' custom node is not installed on the connected ComfyUI ` +
`(install it via ComfyUI-Manager or install_custom_node, then restart). If that node ` +
`IS installed, the 404 instead means it could not find the requested model file — ` +
`check that 'category' (model folder) and 'name' (filename incl. .safetensors) are ` +
`correct and the file exists.${hint}`);
}
return new Error(`model_explorer ${route} HTTP ${status}`);
}
// #541: model_metadata action:"fetch_civitai" proxies the OPTIONAL `comfyui-model-explorer`
// node, which does a local SHA256 → CivitAI by-hash lookup. When that node isn't
// installed the route 404s. Rather than hard-failing, degrade: if the caller gave a
// `version_id` we can fetch the SAME data straight from CivitAI's public REST API
// (no auth, no custom node, no hash) and return a normalized shape.
export async function fetchCivitaiVersionDirect(versionId) {
try {
const res = await fetch(`https://civitai.com/api/v1/model-versions/${versionId}`, {
// CivitAI is a third party: bound the wait so a stalled response cannot
// wedge the turn (same class as #1026).
signal: AbortSignal.timeout(15_000),
});
if (!res || !res.ok)
return null;
const v = (await res.json());
const modelId = v?.modelId ?? null;
const examplePrompts = Array.isArray(v?.images)
? v.images
.map((im) => im?.meta?.prompt)
.filter((p) => typeof p === "string" && p.trim().length > 0)
.slice(0, 20)
: [];
return {
_source: "civitai-public-api",
_note: "Fetched directly from civitai.com (the optional comfyui-model-explorer node was unavailable). Version-endpoint fields only.",
source_url: modelId
? `https://civitai.com/models/${modelId}?modelVersionId=${versionId}`
: `https://civitai.com/api/v1/model-versions/${versionId}`,
model_version_id: versionId,
model_id: modelId,
version_name: v?.name ?? null,
model_name: v?.model?.name ?? null,
model_type: v?.model?.type ?? null,
base_model: v?.baseModel ?? null,
nsfw: v?.model?.nsfw ?? null,
trainedWords: Array.isArray(v?.trainedWords) ? v.trainedWords : [],
description: v?.description ?? null,
example_prompts: examplePrompts,
download_url: v?.downloadUrl ?? null,
};
}
catch {
return null;
}
}
// Graceful "optional feature unavailable" message for the CivitAI enrichment tool
// when the node route 404s (node absent OR node present but model not found). Points
// at the dependency-free `version_id` path so the caller can recover without the node.
export function civitaiFetchUnavailableError(body, hadVersionId) {
const hint = body && body.trim() ? ` Upstream detail: ${body.trim().slice(0, 200)}.` : "";
const recover = hadVersionId
? `A version_id was supplied, but the direct fallback to CivitAI's public API ` +
`(https://civitai.com/api/v1/model-versions/<id>) also failed — that version may ` +
`not exist, or civitai.com was unreachable.`
: `To fetch metadata WITHOUT the node, pass 'version_id' (the CivitAI modelVersionId) — ` +
`this tool then reads it straight from CivitAI's public API (no node, no auth).`;
return new Error(`model_metadata action:"fetch_civitai": CivitAI enrichment is unavailable. This is an OPTIONAL ` +
`feature that proxies the optional 'comfyui-model-explorer' custom node (its ` +
`/model_explorer/civitai route returned 404). Most likely the node is not installed — ` +
`install it via ComfyUI-Manager or install_custom_node and restart to enable automatic ` +
`by-hash lookup. If that node IS installed, the 404 instead means it could not find the ` +
`requested model file — check 'category' (model folder) and 'name' (filename incl. ` +
`.safetensors). ${recover}${hint}`);
}
async function readBodyText(res) {
try {
return res.text ? await res.text() : undefined;
}
catch {
return undefined;
}
}
// ---------------------------------------------------------------------------
// #363 (reopen): local metadata fallback when the optional node is absent. A
// 404 from /model_explorer/detail used to surface as a raw isError even for
// models that exist locally — with no metadata at all. When the file is
// reachable on the LOCAL filesystem, degrade instead: return a structured
// missing-capability result carrying what comfyui-mcp can read WITHOUT the
// node (file stat, the CivitAI sidecar, the safetensors embedded __metadata__).
// ---------------------------------------------------------------------------
/** Cap on the safetensors JSON header we'll buffer — far beyond any real
* checkpoint/LoRA header (KBs–low MBs); guards against a corrupt/ hostile
* length prefix making us allocate the file's whole size. */
export const SAFETENSORS_HEADER_MAX_BYTES = 64 * 1024 * 1024;
/**
* Extract the embedded `__metadata__` map from a safetensors file prefix
* (8-byte little-endian header length + that many bytes of header JSON).
* Returns null for anything that isn't a well-formed, capped header — the
* fallback is best-effort and never throws.
*/
export function parseSafetensorsEmbeddedMetadata(buf) {
if (buf.length < 8)
return null;
const headerLen = Number(buf.readBigUInt64LE(0));
if (!Number.isSafeInteger(headerLen) || headerLen <= 0 || headerLen > SAFETENSORS_HEADER_MAX_BYTES) {
return null;
}
if (buf.length < 8 + headerLen)
return null;
try {
const header = JSON.parse(buf.toString("utf8", 8, 8 + headerLen));
const meta = header["__metadata__"];
if (!meta || typeof meta !== "object" || Array.isArray(meta))
return null;
return meta;
}
catch {
return null;
}
}
/**
* Read JUST the safetensors header of a local file and return its embedded
* `__metadata__` (modelspec, ss_*, model_card/prompt_director blobs, …) — the
* same raw evidence the Model Explorer node namespaces into its curated view.
* Only the header bytes are touched, never the multi-GB tensor payload.
* Non-safetensors formats (.ckpt/.pt/.bin) have no such header → null.
*/
export async function readSafetensorsEmbeddedMetadata(filePath) {
if (!/\.safetensors$/i.test(filePath))
return null;
let fh;
try {
fh = await open(filePath, "r");
const lenBuf = Buffer.alloc(8);
if ((await fh.read(lenBuf, 0, 8, 0)).bytesRead < 8)
return null;
const headerLen = Number(lenBuf.readBigUInt64LE(0));
if (!Number.isSafeInteger(headerLen) || headerLen <= 0 || headerLen > SAFETENSORS_HEADER_MAX_BYTES) {
return null;
}
const headerBuf = Buffer.alloc(headerLen);
if ((await fh.read(headerBuf, 0, headerLen, 8)).bytesRead < headerLen)
return null;
return parseSafetensorsEmbeddedMetadata(Buffer.concat([lenBuf, headerBuf]));
}
catch {
return null;
}
finally {
await fh?.close().catch(() => undefined);
}
}
/** Best-effort read of the `<file>.civitai.json` sidecar written next to each
* download_model action:"download_civitai" install (trainedWords, baseModel, sourceUrl, …). */
async function readCivitaiSidecar(filePath) {
try {
const parsed = JSON.parse(await readFile(`${filePath}.civitai.json`, "utf8"));
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed
: null;
}
catch {
return null;
}
}
/**
* Build optional local evidence for a 404'd model_metadata action:"read".
* Returns null whenever a local answer would be the WRONG answer. The caller
* still returns a structured unavailable result without that evidence:
* - REMOTE mode: the model lives on the remote server's filesystem. An
* explicit COMFYUI_PATH on this MCP host still resolves (config.ts warns
* but honors it), so without this gate the fallback would return metadata
* from the HOST's unrelated file tree — wrong-machine data presented as
* the answer. The fallback is only meaningful when server and MCP host
* share a file tree.
* - The resolver matched a DIRECTORY, not a regular file — it returns dir
* hits so callers can craft a precise "not a file" error, and a directory
* has no metadata evidence to fall back on.
* - No local file is reachable at all: do not claim any file metadata.
*/
async function localMetadataFallback(category, name) {
try {
if (isRemoteMode())
return null;
const { path, info } = await resolveExistingModelFile(`${category}/${name}`);
if (!info.isFile())
return null;
return {
file: { path, size_bytes: info.size, modified: info.mtime.toISOString() },
embedded_metadata: await readSafetensorsEmbeddedMetadata(path),
civitai_sidecar: await readCivitaiSidecar(path),
};
}
catch {
return null;
}
}
/**
* The three Model Explorer metadata-curation tools collapsed into one
* action-parameterized `model_metadata` tool (0.49.0 surface consolidation,
* slice 5).
*
* 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 — `category`+`name` are
* required by all three actions and `fields` by propose, while `note` and
* `version_id` stay optional. Every VALUE constraint the old tools had is
* unchanged at the zod layer; the handler enforces per-action presence and
* names the missing field — the one deliberate behavioural difference a flat
* enum permits. Each branch runs the identical logic the old tool ran: the same
* fetches against the same /model_explorer/* routes, the same 404 degradations
* (local-metadata fallback for read, CivitAI public-API fallback for
* fetch_civitai), and the identical content blocks.
*/
export function registerModelExplorerTools(server) {
server.tool("model_metadata", "Curate a model file's embedded .safetensors metadata (Model Explorer). Driven by the `action` parameter:\n" +
'- action:"read" — Read a model file\'s CURRENT embedded metadata + evidence, for curating it. ' +
"Returns classify (asset_type/base/precision/rank), the current model_card and prompt_director namespaces, " +
"read-only modelspec, top training tags (ss_tag_frequency), the Civitai description, and example prompts. " +
"Call this FIRST when the user wants to improve/curate a model's embedded .safetensors metadata, so you " +
"propose from real data. NOTE: this is the embedded-in-the-tensor metadata (model_card/prompt_director/" +
"modelspec/ss_*) — NOT the separate lora_catalog. `category` = ComfyUI model folder ('loras','checkpoints'," +
"'vae',…); `name` = filename incl. .safetensors — BOTH required for read/propose, e.g. " +
'{action:"read", category:"loras", name:"my_model.safetensors"}. DEPENDENCY: the curated read proxies the OPTIONAL ' +
"'comfyui-model-explorer' custom node. When that node is absent but the model file is reachable on the " +
"LOCAL filesystem, the tool does NOT hard-fail — it degrades to a structured 'model_explorer: unavailable' " +
"result with local evidence (file stat, the download_model action:\"download_civitai\" sidecar, and the raw embedded " +
"safetensors metadata). Without local filesystem access, it still returns the same structured " +
"unavailable result, but without file evidence.\n" +
'- action:"propose" — PROPOSE cleaned embedded metadata into the user\'s diff-review window. This does NOT write ' +
"the file — the user sees your proposed fields vs current, edits/discusses, and their Confirm does the write. " +
"Call whenever you have a proposal OR the user asks you to revise one; each call REPLACES the live proposal, " +
"so send the FULL field set you're proposing. Include only fields you're confident about. Keys: display_name, " +
"description_clean, semantic_intent, prompt_guidance, preservation_guidance, trigger_tokens[] (EXACT tokens — " +
"never invent), activation_phrases[], negative_tokens[], tags[], compatible_families[], default_strength_model, " +
"default_strength_clip, strength_min, strength_max. NEVER write metadata directly.\n" +
'- action:"fetch_civitai" — READ-ONLY: pull this model\'s data from Civitai (civitai.com) — the rich description, ' +
"trainedWords, example prompts (with the prompt text used in the sample images), tags, nsfw flag, " +
"and source_url — WITHOUT writing anything. Call this when the embedded metadata is thin (empty " +
"model_card/prompt_director, no ss_tag_frequency) or to flesh out details before proposing. Treat the " +
"result as RAW input: distill the (often marketing-heavy) description, and MINE THE EXAMPLE PROMPTS for " +
"the real trigger — the trigger is frequently ONLY in the sample prompts even when trainedWords is EMPTY " +
"(e.g. every prompt starting with 'photo in the style of X' means X is the trigger). Adult models (civitai.red) " +
'resolve through this same API. Then clean it up and call action:"propose". ' +
"DEPENDENCY: automatic by-hash lookup uses the OPTIONAL 'comfyui-model-explorer' custom node. If that node " +
"isn't installed, pass 'version_id' (the CivitAI modelVersionId) and this action degrades to CivitAI's public " +
"REST API directly — no node, no auth. Without both the node AND a version_id it returns a clear " +
"'optional feature unavailable' message rather than enriching.", {
action: z
.enum(["read", "propose", "fetch_civitai"])
.describe('Which metadata operation to perform. All three actions require `category` + `name`; "propose" also ' +
'requires `fields` (optional `note`); "fetch_civitai" takes an optional `version_id`.'),
category: z
.string()
.optional()
.describe("ComfyUI model folder, e.g. 'loras'. REQUIRED for all three actions."),
name: z
.string()
.optional()
.describe("model filename incl. .safetensors. REQUIRED for all three actions."),
fields: z
.record(z.string(), z.any())
.optional()
.describe('action:"propose" — REQUIRED proposed field map (see description).'),
note: z
.string()
.optional()
.describe('action:"propose" — optional one-line note about this revision.'),
version_id: z
.number()
.int()
.optional()
.describe('action:"fetch_civitai" — force a specific Civitai modelVersionId if hash lookup misses.'),
}, async (args) => {
try {
// category/name/fields 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: `category: ""` passed z.string() before
// this consolidation and reached the upstream route's own error path. A
// `!category` guard would swallow that path and answer with generic text
// instead.
const requireCategoryName = (action) => {
if (args.category === undefined) {
throw new Error(`model_metadata action:"${action}" requires \`category\` — the ComfyUI model folder (e.g. 'loras').`);
}
if (args.name === undefined) {
throw new Error(`model_metadata action:"${action}" requires \`name\` — the model filename incl. .safetensors.`);
}
return { category: args.category, name: args.name };
};
switch (args.action) {
case "read": {
const { category, name } = requireCategoryName("read");
const COMFY = comfyBase();
const q = `category=${encodeURIComponent(category)}&name=${encodeURIComponent(name)}`;
const dr = await comfyuiFetch(`${COMFY}/model_explorer/detail?${q}`);
if (!dr.ok) {
const body = await readBodyText(dr);
// #363: make an absent optional route a structured unavailable
// result; include local evidence only when it is safe to resolve.
if (dr.status === 404) {
const local = await localMetadataFallback(category, name);
return okText({
model_explorer: "unavailable",
reason: "GET /model_explorer/detail returned HTTP 404 — most likely the optional " +
"'comfyui-model-explorer' custom node is not installed on the connected ComfyUI. " +
"If local evidence is unavailable, this does not confirm whether the node or requested file is absent.",
install: "Install the node via ComfyUI-Manager or install_custom_node, restart ComfyUI, " +
'then re-call model_metadata action:"read" for the full curated view (classify, model_card, ' +
"prompt_director, modelspec, tag_frequency).",
source: local ? "local-fallback" : "unavailable",
...(local ?? {}),
});
}
return errorToToolResult(explorerHttpError("detail", dr.status, body));
}
const detail = (await dr.json());
let tags = null;
try {
const tr = await comfyuiFetch(`${COMFY}/model_explorer/suggest_triggers?${q}`);
if (tr.ok)
tags = (await tr.json()).candidates;
}
catch { /* optional */ }
return okText({
classify: detail.classify,
model_card: detail.namespaces?.model_card ?? {},
prompt_director: detail.namespaces?.prompt_director ?? {},
modelspec: detail.namespaces?.modelspec ?? {},
description: detail.namespaces?.model_card?.description ?? null,
example_prompts: detail.namespaces?.prompt_director?.example_prompts ?? [],
tag_frequency_top: tags,
compat_suggestions: detail.compat_suggestions ?? [],
});
}
case "propose": {
const { category, name } = requireCategoryName("propose");
if (args.fields === undefined) {
throw new Error('model_metadata action:"propose" requires `fields` — the proposed field map (see description).');
}
const COMFY = comfyBase();
const r = await comfyuiFetch(`${COMFY}/model_explorer/proposal`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ category, name, fields: args.fields, note: args.note }),
});
const d = (await r.json());
if (!r.ok || !d.ok)
return errorToToolResult(new Error(d.error || `proposal HTTP ${r.status}`));
return okText({
pushed: true,
seq: d.seq,
note: "Proposal is now in the user's review window. Wait for their feedback; revise by calling this again with the full field set.",
});
}
case "fetch_civitai": {
const { category, name } = requireCategoryName("fetch_civitai");
const COMFY = comfyBase();
// A CivitAI modelVersionId is always a positive integer; treat anything
// else (incl. 0) as "not supplied" so the node query and the fallback
// agree on when we actually have an id.
const hasVersionId = typeof args.version_id === "number" && args.version_id > 0;
const q = `category=${encodeURIComponent(category)}&name=${encodeURIComponent(name)}` +
(hasVersionId ? `&version_id=${args.version_id}` : "");
const r = await comfyuiFetch(`${COMFY}/model_explorer/civitai?${q}`);
if (r.ok)
return okText(await r.json());
const body = await readBodyText(r);
// #541: the node route is unavailable. Degrade instead of hard-failing.
// With a version_id we can satisfy the request straight from CivitAI's
// public API (no node, no auth, no hash).
if (r.status === 404 && hasVersionId) {
const direct = await fetchCivitaiVersionDirect(args.version_id);
if (direct)
return okText(direct);
}
if (r.status === 404) {
return errorToToolResult(civitaiFetchUnavailableError(body, hasVersionId));
}
return errorToToolResult(explorerHttpError("civitai", r.status, body));
}
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 model_metadata action "${String(exhaustive)}". Expected one of: read, propose, fetch_civitai.`);
}
}
}
catch (err) {
return errorToToolResult(err);
}
});
}
//# sourceMappingURL=model-explorer.js.map