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),
273 lines • 12.7 kB
JavaScript
import { isAbsolute, relative, resolve, sep } from "node:path";
import { stat } from "node:fs/promises";
import { listLocalModels, MODEL_SUBDIRS } from "./model-resolver.js";
import { getSystemStats } from "../comfyui/client.js";
import { config } from "../config.js";
// ---------------------------------------------------------------------------
// Local-model discovery fallback for the comfy_cli tool's models_* actions
// (issue #460). The read-only listing actions (list-folders, list-folder,
// search, show) don't
// actually need the separate comfy-cli executable: the connected ComfyUI
// already exposes what's installed via its /models REST endpoint (with a
// COMFYUI_PATH filesystem scan behind it). This reproduces those actions
// directly through the existing listLocalModels() path so model discovery
// keeps working on a plain local install with no comfy-cli on PATH — mirroring
// the live /object_info fallback for comfy_cli action:"search_nodes" (#354).
// ---------------------------------------------------------------------------
/**
* comfy-cli's `models search --type` maps its `--type` value to the real ComfyUI
* model folder before scanning. This mirrors comfy-cli's `_TYPE_TO_FOLDER`
* (comfy_cli/command/models/search.py) EXACTLY so a `--type` that comfy-cli
* would resolve is resolved identically here — otherwise a valid fallback search
* returns a wrong empty result. Values not in the map pass through unchanged.
*/
const TYPE_ALIASES = {
checkpoint: "checkpoints",
checkpoints: "checkpoints",
lora: "loras",
loras: "loras",
vae: "vae",
controlnet: "controlnet",
upscale: "upscale_models",
upscale_models: "upscale_models",
clip: "clip",
clip_vision: "clip_vision",
unet: "diffusion_models",
diffusion: "diffusion_models",
diffusion_models: "diffusion_models",
style: "style_models",
style_models: "style_models",
embeddings: "embeddings",
hypernetworks: "hypernetworks",
gligen: "gligen",
};
function resolveModelFolder(type) {
return TYPE_ALIASES[type.toLowerCase()] ?? type;
}
function posixRel(p) {
return p.replace(/\\/g, "/").replace(/^\/+/, "");
}
/** Drop a leading `models/` so `models/vae/foo.safetensors` matches listing paths. */
function stripModelsPrefix(rel) {
const n = posixRel(rel);
return /^models\//i.test(n) ? n.slice("models/".length) : n;
}
function pathBasename(rel) {
const parts = posixRel(rel).split("/").filter(Boolean);
return parts.at(-1) ?? rel;
}
function pathDirname(rel) {
const parts = posixRel(rel).split("/").filter(Boolean);
return parts.length >= 2 ? parts.slice(0, -1).join("/") : undefined;
}
function modelRelPath(model) {
if (isAbsolute(model.path))
return posixRel(`${model.type}/${model.name}`);
return stripModelsPrefix(model.path);
}
/**
* Folder the caller asked for: explicit `folder`/`type`, else the directory
* prefix of a relative `name` (e.g. `vae/qwen_image_vae.safetensors`).
*/
function showFolderHint(args) {
const fromArg = (args.folder ?? args.type)?.trim();
if (fromArg)
return resolveModelFolder(fromArg);
const dir = pathDirname(stripModelsPrefix(args.name.trim()));
if (!dir)
return undefined;
const head = dir.split("/")[0];
return head ? resolveModelFolder(head) : undefined;
}
function showArgFolderConflictsName(args) {
const fromArg = (args.folder ?? args.type)?.trim();
const nameDir = pathDirname(stripModelsPrefix(args.name.trim()));
const nameHead = nameDir?.split("/")[0];
if (!fromArg || !nameHead)
return false;
return resolveModelFolder(fromArg).toLowerCase() !== resolveModelFolder(nameHead).toLowerCase();
}
/**
* Exact (case-insensitive) basename matches, optionally narrowed by folder/type
* or a relative path. Array order is not a selector — #2504's bug was
* `Array.find` reporting whichever duplicate happened to be listed first.
*/
function showMatches(models, args) {
if (showArgFolderConflictsName(args))
return [];
const needle = stripModelsPrefix(args.name.trim());
const wantBase = pathBasename(needle).toLowerCase();
const nameDir = pathDirname(needle);
const folder = showFolderHint(args);
return models.filter((m) => {
if (pathBasename(m.name).toLowerCase() !== wantBase)
return false;
if (folder && m.type.toLowerCase() !== folder.toLowerCase())
return false;
if (nameDir?.includes("/")) {
const wantRel = needle.toLowerCase();
const gotRel = modelRelPath(m).toLowerCase();
const gotName = posixRel(m.name).toLowerCase();
if (gotRel !== wantRel && !gotRel.endsWith(`/${wantRel}`) && gotName !== wantRel)
return false;
}
return true;
});
}
function pickShowMatch(models, args) {
const hits = showMatches(models, args);
const only = hits[0];
if (hits.length === 1 && only)
return only;
if (hits.length === 0) {
throw new Error(`Model '${args.name}' was not found among the connected ComfyUI's local models.`);
}
const listed = [...hits]
.sort((a, b) => modelRelPath(a).localeCompare(modelRelPath(b)))
.map((m) => ` ${modelRelPath(m)}`)
.join("\n");
throw new Error(`Model '${args.name}' matches ${hits.length} local files:\n${listed}\n` +
"Pass folder, type, or a relative path (e.g. vae/qwen_image_vae.safetensors) to select one.");
}
/** HTTP listings leave size/mtime blank; stat the file when a local path is known. */
async function enrichShowStats(match) {
if (match.size > 0 && match.modified)
return match;
let abs;
if (isAbsolute(match.path)) {
abs = match.path;
}
else if (config.comfyuiPath) {
const modelsRoot = resolve(config.comfyuiPath, "models");
const target = resolve(modelsRoot, match.path);
const rel = relative(modelsRoot, target);
if (rel !== "" && !rel.startsWith("..") && !isAbsolute(rel) && target.startsWith(modelsRoot + sep)) {
abs = target;
}
}
if (!abs)
return match;
try {
const info = await stat(abs);
if (!info.isFile())
return match;
return { ...match, size: info.size, modified: info.mtime.toISOString() };
}
catch {
return match;
}
}
export function isLocalModelsListAction(action) {
return action === "list-folders" || action === "list-folder" || action === "search" || action === "show";
}
/**
* When listLocalModels() yields nothing it may be an empty (but reachable)
* server, OR it may mean we have no usable local source at all. The latter must
* be a clear error rather than a silent empty list, so callers know to install
* comfy-cli or point at a reachable ComfyUI. A local COMFYUI_PATH always counts
* as a source; otherwise we probe the connected server cheaply via system_stats.
*/
async function assertLocalSourceAvailable() {
if (config.comfyuiPath)
return;
try {
await getSystemStats();
return; // server reachable — an empty result is a legitimate answer
}
catch (err) {
// #796 — "UNREACHABLE" IS A SPECIFIC CLAIM, and this made it for every way
// the probe can fail. A 401, a 500, a timeout, and an HTML page from a
// reverse proxy that forwards the UI but not the API all produced "the
// connected ComfyUI server is unreachable" and the remedy "connect to a
// running ComfyUI server" — which the user has already done in three of
// those four.
//
// The irony is that this threw the answer away: `getSystemStats` already
// fails with the diagnosis #828/#952/#954 built for exactly this, naming
// what answered, with which status and content type, and what to check. It
// is scrubbed at the source, so it is safe to carry.
//
// The claim is now the observation — the probe did not yield readable stats
// — and the CAUSE comes from the error rather than from a guess.
const why = err instanceof Error ? err.message : String(err);
throw new Error("comfy-cli was not found and no local model source could be established: COMFYUI_PATH is unset, " +
`and the connected ComfyUI's /system_stats could not be read. ${why} ` +
"Install comfy-cli>=1.11.1 (or set COMFY_CLI_PATH), set COMFYUI_PATH, or point at a ComfyUI whose API answers.");
}
}
/**
* Serve a read-only comfy_cli models_* listing action from the connected
* ComfyUI's local models (via listLocalModels), for use when comfy-cli is
* absent. Throws when a required argument is missing (mirrors the CLI path).
*/
export async function listLocalModelsFallback(args) {
switch (args.action) {
case "list-folders": {
// comfy `models list-folders` reports the model folder names. We list
// the folders that actually contain at least one model — the canonical
// MODEL_SUBDIRS first (in their canonical order), then any additional
// loader-registered categories present (e.g. ComfyUI-GGUF's `unet_gguf`,
// `clip_gguf`), so GGUF-only folders aren't silently dropped (#526).
const models = await listLocalModels();
if (models.length === 0)
await assertLocalSourceAvailable();
const present = new Set(models.map((m) => m.type));
const canonical = MODEL_SUBDIRS.filter((f) => present.has(f));
const extras = [...present].filter((t) => !MODEL_SUBDIRS.includes(t));
const folders = [...canonical, ...extras];
return { command: "models list-folders", data: { folders } };
}
case "list-folder": {
if (!args.folder)
throw new Error("folder is required for list-folder");
const folder = resolveModelFolder(args.folder);
const models = await listLocalModels(folder);
if (models.length === 0)
await assertLocalSourceAvailable();
let files = models.map((m) => m.name);
// comfy-cli forwards `--limit` for list-folder; cap the same way.
if (args.limit && args.limit > 0)
files = files.slice(0, args.limit);
return { command: `models list-folder ${folder}`, data: { folder, count: files.length, files } };
}
case "search": {
// Map the CLI's singular `--type` alias to the real folder before scanning.
const models = await listLocalModels(args.type ? resolveModelFolder(args.type) : undefined);
if (models.length === 0)
await assertLocalSourceAvailable();
const needle = (args.text ?? "").trim().toLowerCase();
let hits = needle ? models.filter((m) => m.name.toLowerCase().includes(needle)) : models;
if (args.limit && args.limit > 0)
hits = hits.slice(0, args.limit);
const results = hits.map((m) => ({ name: m.name, type: m.type, path: m.path }));
return { command: "models search", data: { count: results.length, results } };
}
case "show": {
if (!args.name)
throw new Error("name is required for show");
const showArgs = { name: args.name, folder: args.folder, type: args.type };
// Prefer the hinted folder so a filtered REST listing does not even see
// same-basename files in other categories. The matcher still filters the
// returned set: mocks (and unfiltered listings) can still yield duplicates.
const folderHint = showArgFolderConflictsName(showArgs) ? undefined : showFolderHint(showArgs);
const models = await listLocalModels(folderHint);
if (models.length === 0)
await assertLocalSourceAvailable();
const match = await enrichShowStats(pickShowMatch(models, showArgs));
return {
command: `models show ${args.name}`,
data: {
name: match.name,
type: match.type,
path: match.path,
size: match.size,
modified: match.modified,
...(match.baseModel ? { baseModel: match.baseModel } : {}),
...(match.triggerWords?.length ? { triggerWords: match.triggerWords } : {}),
...(match.civitaiUrl ? { civitaiUrl: match.civitaiUrl } : {}),
},
};
}
}
}
//# sourceMappingURL=local-models-fallback.js.map