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),
230 lines • 9.35 kB
JavaScript
import { lstat, mkdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
import { requireSharp } from "./sharp-loader.js";
import { AssetRegistry } from "./asset-registry.js";
import { getOutputImage } from "./image-management.js";
import { resolveOutputDir } from "./output-dir.js";
import { ValidationError } from "../utils/errors.js";
const MIME_BY_FORMAT = {
png: "image/png",
jpeg: "image/jpeg",
webp: "image/webp",
};
const SUPPORTED_SOURCE_MIME = /^image\/(png|jpe?g|webp)$/i;
const DEFAULT_MAX_SOURCE_BYTES = 64 * 1024 * 1024;
const DEFAULT_LIMIT_INPUT_PIXELS = 100_000_000;
// Resolve ComfyUI's REAL output directory (honors --output-directory /
// --base-directory redirects via /system_stats), falling back to
// <COMFYUI_PATH>/output. Async because it may query the running ComfyUI.
async function getOutputDir() {
return resolveOutputDir();
}
function parsePositiveIntEnv(name, fallback) {
const raw = process.env[name];
if (raw === undefined || raw.trim() === "")
return fallback;
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
}
function maxSourceBytes() {
return parsePositiveIntEnv("COMFYUI_CONVERT_IMAGE_MAX_SOURCE_BYTES", DEFAULT_MAX_SOURCE_BYTES);
}
function limitInputPixels() {
return parsePositiveIntEnv("COMFYUI_CONVERT_IMAGE_LIMIT_INPUT_PIXELS", DEFAULT_LIMIT_INPUT_PIXELS);
}
async function resolveOutputPath(path, label) {
if (path.trim().length === 0) {
throw new ValidationError(`${label} must be a non-empty path.`);
}
const outputDir = await getOutputDir();
const resolved = isAbsolute(path) ? resolve(path) : resolve(outputDir, path);
if (resolved === outputDir || !resolved.startsWith(outputDir + sep)) {
throw new ValidationError(`${label} must stay within the ComfyUI output directory.`);
}
return resolved;
}
async function realOutputRoot() {
return realpath(await getOutputDir());
}
function isInsideOrEqual(root, candidate) {
const rel = relative(root, candidate);
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
}
function assertInsideRealOutputDir(root, candidate, label, opts = {}) {
if (!isInsideOrEqual(root, candidate) || (!opts.allowRoot && candidate === root)) {
throw new ValidationError(`${label} must stay within the ComfyUI output directory.`);
}
}
async function validateExistingOutputAncestors(root, parent, label) {
const outputDir = await getOutputDir();
let cursor = parent;
while (cursor !== outputDir && cursor.startsWith(outputDir + sep)) {
try {
const info = await lstat(cursor);
if (info.isSymbolicLink()) {
const realCursor = await realpath(cursor);
assertInsideRealOutputDir(root, realCursor, label, { allowRoot: true });
}
else if (!info.isDirectory()) {
throw new ValidationError(`${label} parent is not a directory: ${cursor}`);
}
return;
}
catch (err) {
if (err instanceof ValidationError)
throw err;
cursor = dirname(cursor);
}
}
}
async function resolveSourcePath(path) {
const lexicalPath = await resolveOutputPath(path, "path");
const root = await realOutputRoot();
// unknown-ok: the caller REFUSES on undefined (the throw immediately below), so a
// failed observation and a genuine absence both fail closed. The only cost is that
// the message says "not found" for e.g. a permission error — noted, not dangerous,
// because nothing proceeds on the unknown.
const sourcePath = await realpath(lexicalPath).catch(() => undefined);
if (!sourcePath) {
throw new ValidationError(`Source image not found: ${lexicalPath}`);
}
assertInsideRealOutputDir(root, sourcePath, "path");
// unknown-ok: undefined fails the isFile() test below and refuses, same as a
// genuine non-file. A path realpath just resolved that cannot then be stat-ed is
// not usable either way.
const info = await stat(sourcePath).catch(() => undefined);
if (!info?.isFile()) {
throw new ValidationError(`Source image not found: ${sourcePath}`);
}
const maxBytes = maxSourceBytes();
if (info.size > maxBytes) {
throw new ValidationError(`Source image is too large (${info.size} bytes). Maximum is ${maxBytes} bytes.`);
}
return { path: sourcePath, size: info.size };
}
async function resolveWritableOutputPath(path) {
const targetPath = await resolveOutputPath(path, "out_path");
const root = await realOutputRoot();
const parent = dirname(targetPath);
await validateExistingOutputAncestors(root, parent, "out_path");
await mkdir(parent, { recursive: true });
const realParent = await realpath(parent);
assertInsideRealOutputDir(root, realParent, "out_path", { allowRoot: true });
try {
const info = await lstat(targetPath);
if (info.isSymbolicLink()) {
const realTarget = await realpath(targetPath);
assertInsideRealOutputDir(root, realTarget, "out_path");
}
}
catch (err) {
if (err instanceof ValidationError)
throw err;
// Missing target is fine; writeFile will create it.
}
return targetPath;
}
async function resolveSource(opts) {
if (opts.asset_id) {
const record = AssetRegistry.get(opts.asset_id);
if (!record) {
throw new ValidationError(`No asset found for id "${opts.asset_id}". It may have expired or never been registered.`);
}
const validType = record.type === "output" || record.type === "input" || record.type === "temp";
const fetchType = validType
? record.type
: "output";
const image = await getOutputImage(record.filename, fetchType, record.subfolder);
if (!SUPPORTED_SOURCE_MIME.test(image.mimeType)) {
throw new ValidationError(`Asset "${opts.asset_id}" is not a supported image (mime: ${image.mimeType}).`);
}
return {
label: `asset ${opts.asset_id} (${record.filename})`,
bytes: Buffer.from(image.base64, "base64"),
};
}
if (!opts.path) {
throw new ValidationError("get_image (action:\"convert\") requires either asset_id or path.");
}
const source = await resolveSourcePath(opts.path);
return {
label: source.path,
bytes: await readFile(source.path),
};
}
// #2411 — the sharp factory arrives as an ARGUMENT rather than a module binding,
// so this file no longer loads a native library while it evaluates. `convertImage`
// is the one caller and does the loading, which keeps the refusal message next to
// the action the user actually asked for.
function buildEncoder(sharp, input, opts) {
const image = sharp(input, { limitInputPixels: limitInputPixels() });
if (opts.format === "png") {
return image.png({ quality: opts.quality });
}
if (opts.format === "jpeg") {
return image.jpeg({
quality: opts.quality,
progressive: opts.progressive,
});
}
return image.webp({
quality: opts.quality,
lossless: opts.lossless,
effort: opts.effort,
});
}
function validateEncodeOptions(opts) {
if (opts.quality !== undefined &&
(!Number.isInteger(opts.quality) || opts.quality < 1 || opts.quality > 100)) {
throw new ValidationError("quality must be an integer between 1 and 100.");
}
if (opts.effort !== undefined &&
(!Number.isInteger(opts.effort) || opts.effort < 0 || opts.effort > 6)) {
throw new ValidationError("effort must be an integer between 0 and 6.");
}
}
export async function convertImage(opts) {
if (Boolean(opts.asset_id) === Boolean(opts.path)) {
throw new ValidationError("Provide exactly one image source: asset_id or path.");
}
validateEncodeOptions(opts);
const sharp = await requireSharp("Image conversion");
const source = await resolveSource(opts);
const converted = await buildEncoder(sharp, source.bytes, opts).toBuffer();
const mimeType = MIME_BY_FORMAT[opts.format];
const outPath = opts.out_path
? await resolveWritableOutputPath(opts.out_path)
: undefined;
if (outPath) {
await writeFile(outPath, converted);
}
const sourceBytes = source.bytes.length;
const outputBytes = converted.length;
const bytesSaved = sourceBytes - outputBytes;
const summary = {
source: source.label,
format: opts.format,
mime_type: mimeType,
source_bytes: sourceBytes,
output_bytes: outputBytes,
bytes_saved: bytesSaved,
out_path: outPath,
};
return {
content: [
{ type: "text", text: JSON.stringify(summary, null, 2) },
{
type: "image",
data: converted.toString("base64"),
mimeType,
},
],
format: opts.format,
mimeType,
sourceBytes,
outputBytes,
bytesSaved,
outPath,
};
}
//# sourceMappingURL=image-convert.js.map