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),
364 lines • 19 kB
JavaScript
import { unlink, writeFile } from "node:fs/promises";
import { isAbsolute } from "node:path";
import { isLocalMode, config } from "../config.js";
import { logger } from "../utils/logger.js";
import { resolveExistingModelFile, currentLiveModelsRoot, } from "../services/model-resolver.js";
import { startDownloadJob, describePlacement } from "../services/download-jobs.js";
/** Mirrors download_model's grace window — see download-jobs.ts. CivitAI
* checkpoints are routinely multi-GB, so this is the path that actually
* triggered the reported hang. */
function civitaiGraceMs() {
const raw = Number(process.env.COMFYUI_MCP_DOWNLOAD_GRACE_MS);
return Number.isFinite(raw) && raw >= 0 ? raw : 20_000;
}
import { resolveCivitaiModel, resolveCivitaiModelVersion, buildCivitaiMarkdown, searchCivitaiModels, searchCivitaiCreators, fetchCivitaiTopCreators, } from "../services/civitai-resolver.js";
import { ValidationError, errorToToolResult } from "../utils/errors.js";
/**
* The four model "extras" handlers, no longer registered as tools of their own.
*
* 0.50.0 slice 11 folded them into the two action-parameterized survivors in
* ./model-management.ts — the model-file deleter became list_local_models
* (action:"remove") and the three CivitAI tools became download_model
* (action:"search_civitai"|"search_creators"|"download_civitai"). The BODIES are
* unchanged and stay here, next to the CivitAI resolver imports and the sidecar
* writer they use; only the wrapper changed, from `server.tool(...)` to an
* exported function the dispatcher calls. Per-action requiredness is enforced by
* the dispatcher before it calls these, so each one still receives exactly the
* arguments its old schema guaranteed.
*/
/**
* Write the CivitAI metadata sidecars next to a freshly downloaded model:
* `<file>.civitai.json` (structured, incl. example generation params) and
* `<file>.civitai.md` (agent-readable usage docs + example recipes). Best-effort
* — a sidecar failure never fails the download. Returns the sidecar paths written.
*/
async function writeCivitaiSidecar(savedPath, metadata) {
try {
const jsonPath = `${savedPath}.civitai.json`;
const mdPath = `${savedPath}.civitai.md`;
await writeFile(jsonPath, JSON.stringify(metadata, null, 2), "utf8");
await writeFile(mdPath, buildCivitaiMarkdown(metadata), "utf8");
return { json: jsonPath, md: mdPath };
}
catch (err) {
logger.warn("Failed to write CivitAI metadata sidecar", {
savedPath,
error: err instanceof Error ? err.message : String(err),
});
return null;
}
}
/** Graceful "not supported remotely" tool result (no isError), matching the
* degrade-don't-throw pattern list_local_models uses. */
function remoteUnsupported(message) {
return { content: [{ type: "text", text: message }] };
}
export async function removeModelAction(args) {
if (!isLocalMode()) {
return remoteUnsupported('list_local_models action:"remove" is not supported against a remote ComfyUI. It deletes a ' +
"file on the ComfyUI host's local filesystem, which the MCP cannot " +
"reach in remote (--comfyui-url / COMFYUI_URL) mode. Delete the file " +
"directly on the ComfyUI host instead.");
}
try {
const { path: target, info } = await resolveExistingModelFile(args.path, {
mode: "remove",
});
if (!info.isFile()) {
throw new ValidationError(`Not a file (refusing to remove): ${args.path}`);
}
const sizeMB = (info.size / 1024 / 1024).toFixed(1);
await unlink(target);
return {
content: [
{
type: "text",
text: `Removed model:\n ${target}\n (${sizeMB} MB freed)`,
},
],
};
}
catch (err) {
return errorToToolResult(err);
}
}
export async function searchCivitaiModelsAction(args) {
try {
if (!args.query?.trim() && !args.creator?.trim()) {
throw new ValidationError("Provide a query, a creator (exact username), or both.");
}
const { hits, scanned, scanCapped } = await searchCivitaiModels(args.query ?? "", {
types: args.types,
baseModels: args.base_models,
sort: args.sort,
nsfw: args.nsfw,
limit: args.limit,
creator: args.creator,
});
// Creator+keyword scans are bounded (client-side keyword filter over
// paged results) — never present a capped miss as definitive.
const capNote = scanCapped
? `\nNOTE: the keyword was matched client-side over only this creator's first ${scanned} models (scan cap) — matching models past that may exist. Narrow with types/base_models, or drop the query to list everything.`
: "";
const what = [
args.query?.trim() && `"${args.query}"`,
args.creator?.trim() && `creator ${args.creator}`,
]
.filter(Boolean)
.join(" by ");
if (hits.length === 0) {
return {
content: [
{
type: "text",
text: `No CivitAI models matched ${what}` +
(args.base_models?.length ? ` for base ${args.base_models.join("/")}` : "") +
`. Try a broader query, drop the filters` +
(args.creator
? `, check the exact username with download_model action:"search_creators" (creators with only NSFW models need nsfw:true)`
: "") +
`, or search HuggingFace with download_model action:"search".` +
capNote,
},
],
};
}
const lines = hits.map((h, i) => {
const stats = [
h.base_model && `base ${h.base_model}`,
h.downloads != null && `${h.downloads.toLocaleString()} downloads`,
h.thumbs_up != null && `${h.thumbs_up} 👍`,
h.size_mb && `~${h.size_mb} MB`,
h.nsfw && "NSFW",
]
.filter(Boolean)
.join(" · ");
const words = h.trained_words?.length ? `\n trigger words: ${h.trained_words.join(", ")}` : "";
return (`${i + 1}. **${h.name}** (${h.type ?? "?"}) by ${h.creator ?? "unknown"} — ${stats}\n` +
` model_id: ${h.model_id} · model_version_id: ${h.version_id ?? "?"} (${h.version_name ?? "latest"})${words}`);
});
// Civitai downloads are token-gated even though search is open — warn
// BEFORE the model burns rounds on 401s (live E2E failure shape).
const tokenNote = config.civitaiApiToken
? ""
: `\nNOTE: no CIVITAI_API_TOKEN is configured — downloads WILL fail with 401 until the user sets one (panel Settings › “Set CivitAI token…”, or the CIVITAI_API_TOKEN env var; created at civitai.com/user/account). Ask them to set it before attempting a download.`;
return {
content: [
{
type: "text",
text: `${hits.length} CivitAI result(s) for ${what}:\n\n${lines.join("\n\n")}\n\n` +
`Next: download_model {"action": "download_civitai", "model_version_id": <id>, "target_subfolder": "<loras|checkpoints|...>"} — then use the trigger words in the prompt.` +
capNote +
tokenNote,
},
],
};
}
catch (err) {
return errorToToolResult(err);
}
}
export async function searchCivitaiCreatorsAction(args) {
try {
if (args.query?.trim()) {
const { hits, total } = await searchCivitaiCreators(args.query, {
limit: args.limit,
});
if (hits.length === 0) {
return {
content: [
{
type: "text",
text: `No CivitAI creators matched "${args.query}". Usernames match on substrings — ` +
`try a shorter fragment, or omit the query for the top-creators leaderboard. ` +
`Note: this index only covers creators who have published MODELS — a creator who ` +
`posts only images/videos won't appear here even if the exact username is correct. ` +
`For a media-only creator, browse their posts via the panel CivitAI browser instead.`,
},
],
};
}
const lines = hits.map((h, i) => `${i + 1}. **${h.username}** — ${h.model_count ?? 0} model(s) · ${h.profile_url}`);
return {
content: [
{
type: "text",
text: `${hits.length} CivitAI creator(s) for "${args.query}"` +
(total != null ? ` (${total.toLocaleString()} total match${total === 1 ? "" : "es"})` : "") +
`:\n\n${lines.join("\n")}\n\n` +
`Next: download_model {"action": "search_civitai", "creator": "<username>"} to list a creator's models.`,
},
],
};
}
const board = args.board ?? "overall";
const hits = await fetchCivitaiTopCreators({ board, limit: args.limit });
if (hits.length === 0) {
return {
content: [
{
type: "text",
text: `CivitAI returned an empty "${board}" leaderboard. Try again later or search by name with a query.`,
},
],
};
}
const lines = hits.map((h) => {
const stats = [
h.score != null && `score ${h.score.toLocaleString()}`,
h.downloads != null && `${h.downloads.toLocaleString()} downloads`,
h.thumbs_up != null && `${h.thumbs_up.toLocaleString()} 👍`,
h.entries != null && `${h.entries} model(s) counted`,
]
.filter(Boolean)
.join(" · ");
return `${h.position ?? "?"}. **${h.username}** — ${stats}\n ${h.profile_url}`;
});
return {
content: [
{
type: "text",
text: `Top ${hits.length} CivitAI creators ("${board}" leaderboard):\n\n${lines.join("\n\n")}\n\n` +
`Next: download_model {"action": "search_civitai", "creator": "<username>"} to list a creator's models, then action:"download_civitai".`,
},
],
};
}
catch (err) {
return errorToToolResult(err);
}
}
export async function downloadCivitaiModelAction(args) {
try {
if (args.model_id === undefined && args.model_version_id === undefined) {
throw new ValidationError("Provide either model_id or model_version_id.");
}
const resolved = args.model_id !== undefined
? await resolveCivitaiModel(args.model_id, args.model_version_id)
: await resolveCivitaiModelVersion(args.model_version_id);
const filename = args.filename ?? resolved.filename;
// Everything that used to run inline AFTER the await now runs as the
// job's completion hook, so it still happens when a big CivitAI
// checkpoint outlives the tool call. Its output lands on job.notes.
const postDownload = async (savedPath) => {
const lines = [];
// NOT-A-MODEL guard (live panel finding: the agent downloaded a
// 'Workflows'-type zip into loras/ and told the user their LoRA was
// installed). Loud warning when the entry type / file extension can't
// load as a model so the agent corrects course instead of celebrating.
const civitaiType = resolved.metadata?.modelType;
const NON_MODEL_TYPES = new Set(["Workflows", "Poses", "Wildcards", "Other"]);
const fileExt = (filename ?? savedPath).toLowerCase().match(/\.([a-z0-9]+)$/)?.[1];
const NON_MODEL_EXTS = new Set(["zip", "rar", "7z", "json", "txt", "png", "jpg"]);
if ((civitaiType && NON_MODEL_TYPES.has(civitaiType)) || (fileExt && NON_MODEL_EXTS.has(fileExt))) {
lines.push(` WARNING: this CivitAI entry is type "${civitaiType ?? "unknown"}" (file: .${fileExt ?? "?"}) — it is NOT a loadable model file and will not appear in a ${args.target_subfolder} loader. ` +
`If the user wanted a LoRA/checkpoint, re-run download_model action:"search_civitai" with types:["LORA"] (or ["Checkpoint"]) and download a hit whose type matches. Do not tell the user a model was installed.`);
}
// Write usage-docs sidecars beside the file so the panel agent has the
// description, trigger words, and example generation params on hand.
// Only when the file actually landed on the LOCAL filesystem: a real
// streamed download returns an ABSOLUTE path, whereas a remote OR
// reconnect-fallback Manager dispatch (#420) returns a human-readable
// status descriptor — writing a sidecar beside that string would create a
// stray file. isAbsolute(savedPath) distinguishes the two precisely
// (a Manager dispatch is loopback-"local" mode too, so isLocalMode() alone
// would misfire on the #420 fallback).
if (isAbsolute(savedPath) && resolved.metadata) {
const sidecar = await writeCivitaiSidecar(savedPath, resolved.metadata);
if (sidecar) {
const tw = resolved.metadata.trainedWords;
if (tw.length)
lines.push(` Trigger words: ${tw.join(", ")}`);
const recipes = resolved.metadata.examples.filter((e) => e.meta && Object.keys(e.meta).length > 0).length;
lines.push(` Metadata: ${sidecar.md}` +
(recipes ? ` (${recipes} example recipe${recipes === 1 ? "" : "s"})` : ""));
}
}
return lines;
};
const { job, settled } = await startDownloadJob(resolved.downloadUrl, args.target_subfolder, filename, args.auth, postDownload, undefined, args.model_root);
let timer;
await Promise.race([
settled,
new Promise((r) => {
timer = setTimeout(r, civitaiGraceMs());
}),
]);
if (timer)
clearTimeout(timer);
if (job.status === "error") {
return errorToToolResult(new Error(job.error ?? "CivitAI download failed"));
}
if (job.status === "downloading") {
return {
content: [
{
type: "text",
text: `CivitAI download STARTED and is still running — id \`${job.id}\`.\n` +
(resolved.modelName ? ` Model: ${resolved.modelName}\n` : "") +
` Version id: ${resolved.versionId}\n\n` +
`This is NOT a failure. The file is streaming to disk and will land on its own; ` +
`trigger words and metadata are written when it completes. Tell the user it is ` +
`downloading, then read download_model \`action:"status"\` with this id — do not re-issue the ` +
`download and do not report it as incomplete.`,
},
],
};
}
if (job.status === "cancelled") {
// A concurrent download_model action:"cancel" landed during this grace window — do NOT fall
// through to the success renderer (which would dereference an unset job.path).
return {
content: [
{
type: "text",
text: job.viaManager
? `CivitAI download \`${job.id}\` was cancelled. It was a remote ComfyUI-Manager dispatch, so there is no local partial to resume; the host MAY still be fetching server-side. Check list_local_models to see if it landed; re-issuing starts a NEW dispatch, not a resume.`
: `CivitAI download \`${job.id}\` was cancelled. A resumable partial may remain on disk — re-issue the same download to resume it.`,
},
],
};
}
const savedPath = job.path;
// ONE placement policy shared with download_model action:"download"/"status" (#369):
// "downloaded successfully" is licensed ONLY by a placement the connected
// ComfyUI actually confirmed. Anything else is reported with its caveat.
const placement = describePlacement(job, {
liveModelsDir: await currentLiveModelsRoot(),
});
const lines = job.viaManager
? [
"CivitAI model DISPATCHED to the remote ComfyUI via ComfyUI-Manager (server-side fetch):",
` ${savedPath}`,
` NOTE: ${placement.warning}`,
]
: placement.confirmed
? [
`CivitAI model downloaded successfully${placement.pathQualifier}:`,
` ${savedPath}`,
]
: placement.wrongPlace
? [
"CivitAI download finished, but the model is NOT usable by the connected ComfyUI.",
` ${placement.pathLabel}${placement.pathQualifier}: ${savedPath}`,
` ${placement.warning}`,
" Do NOT tell the user the model is ready — it is not visible to the server that would load it.",
]
: [
`CivitAI model ${placement.pathLabel}${placement.pathQualifier}:`,
` ${savedPath}`,
` NOTE: ${placement.warning}`,
];
if (resolved.modelName)
lines.push(` Model: ${resolved.modelName}`);
lines.push(` Version id: ${resolved.versionId}`);
lines.push(...(job.notes ?? []));
return {
content: [{ type: "text", text: lines.join("\n") }],
};
}
catch (err) {
return errorToToolResult(err);
}
}
//# sourceMappingURL=model-extras.js.map