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),
555 lines • 23.9 kB
JavaScript
import { config } from "../config.js";
import { ModelError, ValidationError } from "../utils/errors.js";
import { logger } from "../utils/logger.js";
import { htmlToMarkdown } from "../utils/html-to-markdown.js";
import { civitaiDisabled, CIVITAI_DISABLED_MESSAGE } from "./model-resolver.js";
const CIVITAI_API_BASE = "https://civitai.com/api/v1";
function authHeaders() {
const headers = {};
// 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 civitaiToken = config.civitaiApiToken;
if (civitaiToken) {
headers["Authorization"] = `Bearer ${civitaiToken}`;
}
return headers;
}
/**
* Ceiling on any single CivitAI request. Without it a hung connection wedges
* the tool forever — the worst outcome of all (neither a surfaced error nor an
* honest empty). Mirrors the 10s guard the provenance hash-lookup already uses;
* a touch longer here because search/model reads can be heavier.
*/
const CIVITAI_TIMEOUT_MS = 15000;
/**
* Translate a raw (non-HTTP) transport failure into a DISTINCT, actionable
* ModelError, distinguishing our own timeout/abort from a connection-level
* network error. Shared by the initial fetch AND the body read, because an
* AbortError/connection reset can fire at EITHER point (after headers arrive
* but before the body is drained) and must be labelled the same way — never
* misread as a bot-gate/non-JSON page.
* Returns null when `err` is not a transport failure (e.g. a JSON SyntaxError),
* so the caller can fall through to its own classification.
*/
function transportError(err, url) {
const name = err instanceof Error ? err.name : "";
if (name === "TimeoutError" || name === "AbortError") {
return new ModelError(`CivitAI request timed out after ${CIVITAI_TIMEOUT_MS / 1000}s ` +
`(civitai.com slow or unreachable) — try again shortly.`, { url, timeout: true });
}
if (err instanceof TypeError) {
// A malformed Authorization header (e.g. CIVITAI_API_TOKEN pasted with
// non-ASCII characters) ALSO rejects as TypeError, but it is a LOCAL
// credential-config fault, not a connectivity fault. Labelling it
// "unreachable / check connectivity" sends the caller down the wrong
// diagnostic path.
if (/ByteString|greater than 255|Invalid header|invalid header/i.test(err.message)) {
return new ModelError(`CivitAI credential is invalid: the API token contains characters that ` +
`cannot be sent in an HTTP header (non-ASCII). Re-enter CIVITAI_API_TOKEN as the raw ASCII ` +
`token — no quotes, no "Bearer " prefix, no label or translated text. (raw: ${err.message})`, { url, credential: true });
}
return new ModelError(`CivitAI is unreachable (network error: ${err.message}) — ` +
`check connectivity and try again.`, { url, network: true });
}
return null;
}
/**
* Perform the fetch and translate every non-HTTP failure mode (DNS/connection
* down, TLS, or our own timeout abort) into a DISTINCT, actionable ModelError.
* A bare `fetch` rejects with an opaque `TypeError: fetch failed` (or a
* `TimeoutError`) that, once caught upstream, is indistinguishable from any
* other crash — exactly the "silently swallowed" failure WS-6 set out to kill.
*/
async function civitaiFetch(url, headers) {
try {
return await fetch(url, {
headers,
signal: AbortSignal.timeout(CIVITAI_TIMEOUT_MS),
});
}
catch (err) {
throw (transportError(err, url) ??
new ModelError(`CivitAI is unreachable (network error: ` +
`${err instanceof Error ? err.message : String(err)}) — ` +
`check connectivity and try again.`, { url, network: true }));
}
}
/**
* Turn a non-OK HTTP status into a DISTINCT, actionable ModelError so an
* auth/rate-limit/upstream failure is never conflated with each other — or,
* worse, with a genuine empty result. Callers handle 404 themselves (a 404 is
* "resource absent", a definitive answer, not a fault).
*
* `tokenSent` reflects whether THIS request actually carried a bearer token —
* NOT whether one is configured globally. The tRPC leaderboard deliberately
* sends none, so a bot-gate 401 there must read as "auth required", never
* "your configured token is invalid".
*/
async function throwForCivitaiStatus(res, url, tokenSent) {
// 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(() => "");
const status = res.status;
let message;
if (status === 401) {
message = tokenSent
? `CivitAI rejected the API token (401 Unauthorized) — it is invalid or ` +
`expired; refresh CIVITAI_API_TOKEN.`
: `CivitAI requires authentication for this request (401 Unauthorized) — ` +
`set CIVITAI_API_TOKEN.`;
}
else if (status === 403) {
message =
`CivitAI refused the request (403 Forbidden) — the token lacks ` +
`permission, or access is region-blocked.`;
}
else if (status === 429) {
message =
`CivitAI rate-limited the request (429 Too Many Requests) — ` +
`wait a moment and retry.`;
}
else if (status >= 500) {
message =
`CivitAI upstream error (${status} ${res.statusText}) — the site is ` +
`having trouble; this is transient, retry shortly.`;
}
else {
message = `CivitAI API ${status}: ${res.statusText}`;
}
throw new ModelError(message, { url, status, body });
}
/**
* Parse a response body as JSON, converting a non-JSON 2xx (e.g. a Cloudflare
* challenge or HTML interstitial served with a 200) into a distinct error
* rather than an opaque SyntaxError that reads as an empty/garbage result.
*/
async function parseCivitaiJson(res, url) {
try {
return (await res.json());
}
catch (err) {
// An abort/timeout or connection reset can surface HERE (headers arrived,
// body drain failed). Classify it as the transport failure it is — only a
// genuine parse failure (SyntaxError) is the bot-gate/non-JSON case.
const transport = transportError(err, url);
if (transport)
throw transport;
throw new ModelError(`CivitAI returned a non-JSON response (HTTP ${res.status}) — likely a ` +
`bot-gate or interstitial page rather than the API; try again shortly.`, { url, status: res.status, nonJson: true });
}
}
async function civitaiGet(path) {
// User-initiated Civitai actions fail FAST with the config explanation when
// the kill-switch is set (issue #127) — never a hang against a blocked host.
if (civitaiDisabled()) {
throw new ModelError(CIVITAI_DISABLED_MESSAGE);
}
const url = `${CIVITAI_API_BASE}${path}`;
logger.debug("CivitAI API request", { url });
const headers = authHeaders();
const res = await civitaiFetch(url, headers);
if (res.status === 404) {
throw new ModelError(`CivitAI resource not found: ${path}`, {
url,
status: 404,
});
}
if (!res.ok) {
await throwForCivitaiStatus(res, url, "Authorization" in headers);
}
return parseCivitaiJson(res, url);
}
/** Pick the best file from a version's file list: primary first, else the first. */
function pickFile(version) {
const files = version.files ?? [];
return files.find((f) => f.primary) ?? files[0];
}
/** Max example generations captured into the sidecar (those carrying params first). */
const MAX_EXAMPLES = 8;
function buildExamples(version) {
const imgs = version.images ?? [];
// Prefer examples that actually carry generation params (a usable recipe).
const withMeta = imgs.filter((i) => i.meta && Object.keys(i.meta).length > 0);
const ordered = [...withMeta, ...imgs.filter((i) => !withMeta.includes(i))];
return ordered.slice(0, MAX_EXAMPLES).map((i) => ({
url: i.url,
type: i.type,
width: i.width,
height: i.height,
nsfwLevel: i.nsfwLevel,
meta: i.meta ?? null,
}));
}
function buildMetadata(version, file, model) {
const modelId = model?.id;
const sourceUrl = modelId
? `https://civitai.com/models/${modelId}?modelVersionId=${version.id}`
: `https://civitai.com/model-versions/${version.id}`;
return {
modelId,
modelName: model?.name ?? version.model?.name,
modelType: model?.type ?? version.model?.type,
creator: model?.creator?.username,
tags: model?.tags,
modelDescriptionHtml: model?.description,
versionId: version.id,
versionName: version.name,
versionDescriptionHtml: version.description,
baseModel: version.baseModel,
trainedWords: version.trainedWords ?? [],
fileName: file?.name,
fileSizeKB: file?.sizeKB,
sourceUrl,
examples: buildExamples(version),
};
}
function resolveFromVersion(version, model) {
const file = pickFile(version);
const downloadUrl = file?.downloadUrl ??
version.downloadUrl ??
`https://civitai.com/api/download/models/${version.id}`;
return {
downloadUrl,
filename: file?.name,
versionId: version.id,
modelName: model?.name ?? version.model?.name,
metadata: buildMetadata(version, file, model),
};
}
/**
* Resolve a CivitAI model-version id directly to a download URL.
* Uses GET /api/v1/model-versions/{id}.
*/
export async function resolveCivitaiModelVersion(versionId) {
const version = await civitaiGet(`/model-versions/${versionId}`);
return resolveFromVersion(version);
}
/**
* Render a CivitAI metadata bundle as agent-readable Markdown for the sidecar.
* Usage docs (trigger words, base model, descriptions) up top; a compact
* "example recipes" section from the gallery's generation params below.
*/
export function buildCivitaiMarkdown(m) {
const lines = [];
lines.push(`# ${m.modelName ?? "CivitAI model"}`);
const facts = [];
if (m.modelType)
facts.push(`**Type:** ${m.modelType}`);
if (m.baseModel)
facts.push(`**Base model:** ${m.baseModel}`);
if (m.versionName)
facts.push(`**Version:** ${m.versionName}`);
if (m.creator)
facts.push(`**Creator:** ${m.creator}`);
if (facts.length)
lines.push("", facts.join(" \n"));
lines.push("", `Source: ${m.sourceUrl}`);
if (m.trainedWords.length) {
lines.push("", "## Trigger words", "");
lines.push(m.trainedWords.map((w) => `\`${w}\``).join(", "));
}
const modelMd = htmlToMarkdown(m.modelDescriptionHtml);
if (modelMd)
lines.push("", "## Description", "", modelMd);
const versionMd = htmlToMarkdown(m.versionDescriptionHtml);
if (versionMd)
lines.push("", "## Version notes", "", versionMd);
const recipes = m.examples.filter((e) => e.meta && Object.keys(e.meta).length > 0);
if (recipes.length) {
lines.push("", "## Example recipes", "");
lines.push("Generation params pulled from the model's gallery — reuse the seed to replicate, or vary it to remix.");
recipes.forEach((e, i) => {
lines.push("", `### Example ${i + 1}`);
if (e.url)
lines.push(`Image: ${e.url}`);
lines.push("", "```", formatMeta(e.meta), "```");
});
}
return lines.join("\n") + "\n";
}
/** Flatten a CivitAI image `meta` object into readable `key: value` lines,
* surfacing the params that matter for reproduction first. */
function formatMeta(meta) {
const order = [
"prompt",
"negativePrompt",
"seed",
"steps",
"sampler",
"cfgScale",
"Size",
"Model",
"Denoising strength",
"Clip skip",
];
const out = [];
const seen = new Set();
const push = (k, v) => {
if (v === undefined || v === null || v === "")
return;
if (typeof v === "object")
return; // skip nested (resources/hashes) here
out.push(`${k}: ${v}`);
seen.add(k);
};
for (const k of order)
if (k in meta)
push(k, meta[k]);
for (const [k, v] of Object.entries(meta))
if (!seen.has(k))
push(k, v);
return out.join("\n");
}
/**
* Resolve a CivitAI model id to a download URL.
* Uses GET /api/v1/models/{id} and picks a model version.
* If `versionId` is supplied, that specific version is used; otherwise the
* latest (first listed) version is chosen.
*/
export async function resolveCivitaiModel(modelId, versionId) {
const model = await civitaiGet(`/models/${modelId}`);
const versions = model.modelVersions ?? [];
if (versions.length === 0) {
throw new ModelError(`CivitAI model ${modelId} has no downloadable versions.`, { modelId });
}
let version;
if (versionId !== undefined) {
version = versions.find((v) => v.id === versionId);
if (!version) {
throw new ValidationError(`Model ${modelId} has no version with id ${versionId}. ` +
`Available versions: ${versions.map((v) => v.id).join(", ")}.`);
}
}
else {
version = versions[0];
}
return resolveFromVersion(version, model);
}
const CIVITAI_SORTS = ["Highest Rated", "Most Downloaded", "Newest"];
/**
* CivitAI `nsfwLevel` ladder: 1 None · 2 Soft (PG-13) · 4 Mature · 8 Explicit ·
* 16 X · 32 Blocked. SFW search surfaces nothing above PG-13.
*/
const MAX_SFW_NSFW_LEVEL = 2;
/**
* SFW classification of one version — "clean" requires POSITIVE evidence:
* a version-level rating at/below PG-13, or at least one preview image all of
* which are at/below PG-13. A version with NO rating and NO previews is
* UNCLASSIFIED, not clean: treating an empty `images` array as safe let SFW
* search vouch an explicit version's trigger words (#664 gate).
*/
function versionSfwClass(v) {
if (typeof v.nsfwLevel === "number") {
return v.nsfwLevel <= MAX_SFW_NSFW_LEVEL ? "clean" : "explicit";
}
const imgs = v.images ?? [];
if (imgs.length === 0)
return "unclassified";
return imgs.every((img) => (img.nsfwLevel ?? 1) <= MAX_SFW_NSFW_LEVEL)
? "clean"
: "explicit";
}
function toSearchHit(m, opts = {}) {
const versions = m.modelVersions ?? [];
// The API gates only the MODEL-level nsfw flag — a model flagged SFW can
// still front a version with mature/explicit previews and adult trigger
// words (#664). In SFW mode prefer the newest version PROVEN clean; an
// unclassified version (no rating, no previews) is never vouched, and when
// nothing is proven clean the latest version still goes out for the
// download handoff but its trigger words are omitted.
const clean = opts.nsfw ? undefined : versions.find((v) => versionSfwClass(v) === "clean");
const v = clean ?? versions[0];
const file = v ? pickFile(v) : undefined;
const sizeKb = file?.sizeKB;
return {
model_id: m.id,
name: m.name ?? `model ${m.id}`,
type: m.type,
creator: m.creator?.username,
downloads: m.stats?.downloadCount,
thumbs_up: m.stats?.thumbsUpCount,
nsfw: m.nsfw,
version_id: v?.id,
version_name: v?.name,
base_model: v?.baseModel,
trained_words: opts.nsfw || clean ? v?.trainedWords?.slice(0, 6) : undefined,
...(sizeKb ? { size_mb: Math.round(sizeKb / 1024) } : {}),
};
}
/** Creator+keyword mode: pages of 100 scanned client-side, and how many pages
* to follow before giving up (4 → up to 400 of the creator's models). */
const CREATOR_SCAN_PAGE_SIZE = 100;
const CREATOR_SCAN_MAX_PAGES = 4;
export async function searchCivitaiModels(query, opts = {}) {
const creator = opts.creator?.trim();
const keyword = query.trim();
if (!creator && !keyword) {
throw new ValidationError("Provide a query, a creator, or both.");
}
const limit = Math.min(Math.max(opts.limit ?? 10, 1), 25);
const params = new URLSearchParams();
params.set("sort", opts.sort ?? "Highest Rated");
// Civitai defaults to including NSFW for authed accounts — pin it explicitly
// so results are SFW unless the caller opted in.
params.set("nsfw", opts.nsfw ? "true" : "false");
for (const t of opts.types ?? [])
params.append("types", t);
for (const b of opts.baseModels ?? [])
params.append("baseModels", b);
if (!creator || !keyword) {
// Single-page modes: a plain keyword search, or ALL of a creator's models.
if (creator) {
params.set("username", creator);
}
else {
params.set("query", keyword);
}
params.set("limit", String(limit));
const data = await civitaiGet(`/models?${params.toString()}`);
return { hits: (data.items ?? []).slice(0, limit).map((m) => toSearchHit(m, opts)) };
}
// Creator + keyword: `query` + `username` together return an EMPTY page
// (live-verified), so send only `username` and apply the keyword client-side
// — following cursor pagination (bounded) so a prolific creator's matching
// model past the first page is still found.
params.set("username", creator);
params.set("limit", String(CREATOR_SCAN_PAGE_SIZE));
const q = keyword.toLowerCase();
const matches = [];
const seenIds = new Set();
// Every cursor already followed — refuses ANY cycle (immediate repeat AND
// longer A→B→A loops), not just the previous cursor.
const seenCursors = new Set();
let scanned = 0;
let cursor;
// True when the scan stopped with pages plausibly unseen: the page cap was
// hit with a next cursor remaining, or the API handed back a degenerate
// (cycling) cursor we refuse to follow.
let stoppedEarly = false;
// Set when a page AFTER the first fails: we keep the partial matches gathered
// so far but flag them as non-definitive. A FIRST-page failure is left to
// throw (no partial data exists yet), so a broken scan never looks empty.
let scanError;
for (let page = 0; page < CREATOR_SCAN_MAX_PAGES; page++) {
if (cursor !== undefined)
params.set("cursor", cursor);
let data;
try {
data = await civitaiGet(`/models?${params.toString()}`);
}
catch (err) {
if (page === 0)
throw err; // nothing gathered yet — surface the failure
scanError = err instanceof Error ? err.message : String(err);
stoppedEarly = true; // pages remained unscanned → the result is partial
break;
}
// Dedupe across pages by model id — a misbehaving cursor that replays a
// page must not double-count `scanned` or fill `limit` with duplicates.
const items = (data.items ?? []).filter((m) => !seenIds.has(m.id));
for (const m of items)
seenIds.add(m.id);
scanned += items.length;
matches.push(...items.filter((m) => (m.name ?? "").toLowerCase().includes(q) ||
(m.tags ?? []).some((t) => t.toLowerCase().includes(q))));
// Falsy (missing OR empty-string) cursor → the catalog is exhausted; a
// cursor we ALREADY followed (immediate repeat or a longer cycle) would
// page in circles forever → treat as stuck.
const next = data.metadata?.nextCursor || undefined;
if (!next)
break;
if (seenCursors.has(next)) {
stoppedEarly = true; // cycling cursor: pages may remain unseen
break;
}
seenCursors.add(next);
cursor = next;
if (matches.length >= limit)
break;
if (page === CREATOR_SCAN_MAX_PAGES - 1)
stoppedEarly = true; // page cap, more remained
}
return {
hits: matches.slice(0, limit).map((m) => toSearchHit(m, opts)),
scanned,
scanCapped: stoppedEarly && matches.length < limit,
...(scanError ? { scanError } : {}),
};
}
const CIVITAI_LEADERBOARDS = [
"overall",
"overall_90",
"overall_nsfw",
"new_creators",
];
function creatorProfileUrl(username) {
return `https://civitai.com/user/${encodeURIComponent(username)}`;
}
/**
* Search CivitAI creators by (partial) username via GET /api/v1/creators.
* Returns matching creators plus the API's total match count.
*/
export async function searchCivitaiCreators(query, opts = {}) {
const params = new URLSearchParams();
params.set("query", query);
params.set("limit", String(Math.min(Math.max(opts.limit ?? 10, 1), 50)));
const data = await civitaiGet(`/creators?${params.toString()}`);
const hits = (data.items ?? [])
.filter((c) => !!c.username)
.map((c) => ({
username: c.username,
profile_url: creatorProfileUrl(c.username),
model_count: c.modelCount ?? 0,
}));
return { hits, total: data.metadata?.totalItems };
}
/** Browser-shaped headers for the tRPC leaderboard request (see section note). */
const TRPC_BROWSER_HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
Referer: "https://civitai.com/",
};
/**
* Fetch a CivitAI creator leaderboard (the site's civitai.com/leaderboard
* ranking). `board` picks which one: "overall" (default), "overall_90",
* "overall_nsfw", or "new_creators".
*/
export async function fetchCivitaiTopCreators(opts = {}) {
if (civitaiDisabled()) {
throw new ModelError(CIVITAI_DISABLED_MESSAGE);
}
const board = opts.board ?? "overall";
const limit = Math.min(Math.max(opts.limit ?? 10, 1), 100);
const input = encodeURIComponent(JSON.stringify({ json: { id: board } }));
const url = `https://civitai.com/api/trpc/leaderboard.getLeaderboard?input=${input}`;
logger.debug("CivitAI leaderboard request", { url });
// No bearer token here: the leaderboard is public and the API token belongs
// to the documented v1 surface, not the site's tRPC endpoints. Shares the
// timeout + distinct network/status/non-JSON error surfacing of civitaiGet.
// tokenSent: false — the leaderboard request intentionally carries no bearer
// token, so a bot-gate 401 must NOT blame the configured v1 API token.
const res = await civitaiFetch(url, TRPC_BROWSER_HEADERS);
if (!res.ok) {
await throwForCivitaiStatus(res, url, false);
}
const data = await parseCivitaiJson(res, url);
const entries = data.result?.data?.json ?? [];
const metric = (e, type) => e.metrics?.find((m) => m.type === type)?.value;
return entries
.filter((e) => e.user?.username && !e.user.deletedAt)
.slice(0, limit)
.map((e) => ({
username: e.user.username,
profile_url: creatorProfileUrl(e.user.username),
position: e.position,
score: e.score,
downloads: metric(e, "downloadCount"),
thumbs_up: metric(e, "thumbsUpCount"),
generations: metric(e, "generationCount"),
entries: metric(e, "entries"),
}));
}
//# sourceMappingURL=civitai-resolver.js.map