UNPKG

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
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