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),
1,023 lines • 74.5 kB
JavaScript
import { z } from "zod";
import { writeFile, mkdir } from "node:fs/promises";
import { join, basename, isAbsolute, resolve } from "node:path";
import { tmpdir, homedir } from "node:os";
import { listOutputMedia, getOutputImage, uploadImageAuto, uploadVideoAuto, uploadAudioAuto, stageOutputAsInput, } from "../services/image-management.js";
import { AssetRegistry } from "../services/asset-registry.js";
import { MAX_RECONCILIATION_PROBE_ATTEMPTS, reconcileAssetsFromHistory, } from "../services/asset-reconcile.js";
import { viewAssetImage } from "../services/view-image.js";
import { convertImage } from "../services/image-convert.js";
import { boundInlineImage } from "../services/inline-preview.js";
import { analyzeColor } from "../services/color-analysis.js";
import { uploadOutput } from "../services/storage-upload.js";
import { errorToToolResult } from "../utils/errors.js";
import { logger } from "../utils/logger.js";
/**
* Where get_image action:"get" writes when the caller names no directory (#768).
*
* It used to be `process.cwd()`, which is not a location this process chose — it is
* whatever directory the MCP client happened to launch us from. On Windows that is
* routinely `C:\Windows\System32`, and the write died with EPERM *after* the image had
* already been fetched. The schema had always DOCUMENTED `/tmp/comfyui-images/`; the
* string existed nowhere in the code, and `/tmp` is not a real directory on Windows
* anyway. `os.tmpdir()` is the platform-correct, process-writable spelling of the same
* promise (`%LOCALAPPDATA%\Temp` on Windows, `/tmp` on Linux, `$TMPDIR` on macOS).
*
* Computed per call rather than at module load so a test (or a caller) that repoints
* TMPDIR/TEMP is honoured instead of being silently pinned to the value at import time.
*
* `os.tmpdir()` returns %TEMP%/%TMP%/$TMPDIR verbatim, and nothing guarantees those are
* usable as a launch-independent base. Merely `resolve()`-ing a bad one would anchor it
* to the MCP process's cwd and land us straight back in System32, so an unqualified
* tmpdir is not used at all: `os.homedir()` is always fully qualified and always writable
* by us, and the whole point of this function is to name a directory that does not depend
* on where we were launched.
*/
export function defaultImageSaveDir() {
const tmp = tmpdir();
return resolve(isFullyQualified(tmp) ? tmp : homedir(), "comfyui-images");
}
/**
* Is this path independent of the process's current directory — INCLUDING its drive?
*
* `path.isAbsolute` is not that test on Windows. It answers true for `\Temp`, which is
* DRIVE-RELATIVE: `resolve("\\Temp", …)` picks up whatever drive the cwd happens to be
* on, so `TEMP=\Windows\System32` reproduces the very failure #768 is about on a
* C:-launched process. Only a drive-qualified path (`C:\…`) or a UNC path
* (`\\server\share\…`) is genuinely launch-independent.
*/
function isFullyQualified(p) {
if (!p)
return false;
if (process.platform !== "win32")
return isAbsolute(p);
return /^[a-zA-Z]:[\\/]/.test(p) || /^[\\/]{2}[^\\/]/.test(p);
}
/**
* Turn the caller's `save_dir` into an ABSOLUTE destination directory.
*
* Omitted → the documented default. A relative value keeps its historical meaning
* (resolved against this process's cwd — the issue asked for explicit `save_dir`
* behaviour to be left alone) but is resolved EAGERLY, so every path this tool reports
* back is absolute. A bare relative `savePath` echoed to an agent is unreadable
* evidence: it names a different file depending on who reads it.
*/
/**
* A Windows path whose final location depends on state this process did not state.
* Two spellings, and they are unresolved differently:
*
* `\out`, `/out` — rooted but drive-less. `isAbsolute` says true; `resolve()` supplies
* the CURRENT drive, so it lands on C: or D: by where we launched.
* `C:out`, `D:out` — drive-qualified but rooted-less. `isAbsolute` says FALSE, so it
* used to be described as "resolved against this process's working
* directory" — which is not what happens: Windows keeps a working
* directory PER DRIVE, and `resolve()` uses the named drive's, which
* need not be ours at all. The old message named the wrong base.
*
* Neither is refused. Both are legal Windows spellings the caller typed, and refusing a
* real request is its own bug — the hazard is silence, not the path. They are DISCLOSED:
* the returned `Saved to:` line is always fully qualified, and a failure message says the
* missing piece came from process state rather than from the argument.
*/
function isDriveRelative(p) {
if (process.platform !== "win32")
return false;
return /^[\\/](?![\\/])/.test(p) || /^[a-zA-Z]:(?![\\/])/.test(p);
}
export function resolveImageSaveDir(saveDir) {
const raw = saveDir?.trim();
if (!raw)
return defaultImageSaveDir();
return isAbsolute(raw) ? resolve(raw) : resolve(process.cwd(), raw);
}
// The four cloud destinations action:"output" accepts, unchanged from the
// retired cloud-upload tool — same object keys, same min-length/url/enum
// constraints, so a caller's `destination` payload is byte-identical.
const s3DestinationSchema = z.object({
bucket: z.string().min(1).describe("Destination S3 bucket"),
prefix: z.string().optional().describe("Optional object key prefix"),
async: z
.boolean()
.optional()
.describe("Accepted for API compatibility; uploads complete before the tool returns."),
});
const azureDestinationSchema = z.object({
container: z.string().min(1).describe("Destination Azure Blob container"),
blob_prefix: z.string().optional().describe("Optional blob name prefix"),
});
const httpDestinationSchema = z.object({
url: z.string().url().describe("HTTP(S) URL to PUT the output file to"),
});
const hfDestinationSchema = z.object({
repo: z.string().min(1).describe("HuggingFace repo in owner/name format"),
repo_type: z.enum(["model", "dataset", "space"]).optional().describe("Repo type; defaults to model"),
path: z.string().optional().describe("Optional path prefix inside the repo"),
});
/** The asset-registry record fields every asset-shaped response has always echoed. */
function summarizeRecord(record) {
if (!record)
return null;
return {
asset_id: record.assetId,
prompt_id: record.promptId,
node_id: record.nodeId,
filename: record.filename,
subfolder: record.subfolder,
type: record.type,
url: record.url,
source: record.source,
created_at: new Date(record.createdAt).toISOString(),
created_at_source: record.createdAtSource,
};
}
/**
* The twelve image/asset tools collapsed into TWO action-parameterized tools
* (0.50.0 surface consolidation, slice 15):
*
* `get_image` — the READ/INSPECT surface: fetch, view, browse, re-encode,
* measure colour, and read asset provenance (7 actions)
* `upload_image` — the WRITE surface: put bytes somewhere ComfyUI (or cloud
* storage) can read them (5 actions)
*
* TWO tools rather than one twelve-action grab-bag because the halves have
* opposite directions of travel and different blast radii: everything on
* `get_image` reads (its one write is the local save of bytes it just fetched),
* while every action on `upload_image` puts a file somewhere — ComfyUI's input/
* directory or a cloud bucket. The orchestrator's call_tool whitelist depends on
* that split: `get_image` is whitelisted for canvas-less clients (action-SCOPED,
* see CALL_TOOL_ACTION_WHITELIST in src/orchestrator/call-tool-admission.ts) and
* `upload_image` is not whitelisted at all — exactly as before the fold.
*
* Both survivors keep their registration slots (get_image, then upload_image),
* so tools/list order is unchanged for every name that still exists.
*
* SHAPE: a FLAT object with an `action` enum — deliberately NOT a
* z.discriminatedUnion, which the MCP SDK renders as a schema with ZERO visible
* parameters, hiding every input from the model.
*
* REQUIREDNESS: only `action` can be schema-required. `filename` is required for
* action:"get" but optional for action:"analyze_color" and meaningless for
* action:"list_assets"; `asset_id` is required for "view"/"asset_metadata" and
* optional for "convert"/action:"analyze_color"; `format` is required for
* "convert" and optional
* for "list_outputs". The handler enforces per-action PRESENCE and names the
* missing field — the one deliberate behavioural difference a flat enum permits.
* Guards test ABSENCE, never falsiness: `filename: ""` passed z.string() before
* this consolidation and reached the service, which answers with its own
* not-found/validation error, and it still does.
*
* WHERE THE BYTES GO: `stage` copies a generated OUTPUT back into ComfyUI's
* INPUT directory, image/video/audio upload a LOCAL file into that same input
* directory, and `output` ships a generated output OFF the machine to S3 / Azure
* / an HTTP PUT / HuggingFace. A cross-wired action would put a file somewhere
* the caller did not ask for without failing, so upload-image.test.ts pins each
* action to exactly one service in BOTH directions.
*/
/**
* The action vocabularies, declared once each.
*
* The zod enum and the unreachable-default error both read from these, so the
* message a caller gets for a bad action can never drift from the list the
* schema actually accepts — and neither list has to spell the actions twice.
*/
// prettier-ignore — one line so each member follows `[` or `,`, which is the
// shape the dead-name gate licenses for a declared action literal.
const GET_IMAGE_ACTIONS = ["get", "view", "list_outputs", "convert", "analyze_color", "list_assets", "asset_metadata"];
const UPLOAD_IMAGE_ACTIONS = ["image", "video", "audio", "output", "stage"];
export function registerImageManagementTools(server) {
// ── get_image (7 actions) ────────────────────────────────────────────────
server.tool("get_image", "Fetch, browse and inspect ComfyUI images and registered assets. Driven by the `action` parameter:\n" +
'- action:"get" — Fetch a generated image from ComfyUI by FILENAME and return it as an inline image. Video/audio outputs (e.g. a VHS_VideoCombine .mp4) and allowlisted mesh/material attachments (.obj, .glb, .gltf, .fbx, .ply, .stl, .mtl) are saved to save_dir with their original extension instead of being rendered inline. Works with remote ComfyUI instances — does not require COMFYUI_PATH. Use get_history (action:"list") first to obtain the filename; if it returns `subfolder/filename`, pass that relative path as-is and get_image will split it automatically.\n' +
'- action:"view" — Fetch a registered asset\'s bytes by ASSET ID and return them as an inline image so the agent can see the result. Use this after a render completes (asset_id is included in the completion notification) to inspect, critique, or compare generated images. Only supports image mime types (PNG/JPEG/WebP); audio/video assets must be saved to disk via action:"get".\n' +
'- action:"list_outputs" — List recently generated image AND video files from ComfyUI\'s output/ directory, newest-first, with each file\'s kind (\'image\' | \'video\'), subfolder, size, and modification time. Covers stills (.png/.jpg/.jpeg/.bmp) and video/animation outputs (.mp4/.webm/.mov/.mkv/.m4v/.avi/.gif/.webp). LOCAL ComfyUI (COMFYUI_PATH set): a RECURSIVE filesystem scan of output/ (stills + video, including subfolders like video/ that VHS/SaveVideo write to) AND of temp/ for video files — VHS_VideoCombine with `save_output` unchecked writes the completed .mp4 (including the "-audio.mp4" a run completion names) there; those entries are tagged type:"temp" so action:"get" / upload_image (action:"stage") can fetch them. Reports size + modification time. Preview stills in temp/ (PreviewImage) are omitted. REMOTE ComfyUI: derives the list from /history over HTTP instead (size/modified are unavailable and omitted) and includes type:"temp" videos from history the same way. It does NOT return the media bytes themselves — fetch those with action:"get". USE THIS TO CONFIRM A VIDEO RENDER (e.g. VHS_VideoCombine / LTX / WAN output) when get_history (action:"list") shows the prompt done but lists no output: VHS-style video nodes write the file but often do NOT register in ComfyUI\'s /history, so the local filesystem scan is the reliable way to verify the .mp4 exists — then chain it with upload_image (action:"stage"). THAT GUARANTEE IS LOCAL-ONLY AND INVERTS ON A REMOTE TARGET: with no disk to scan, this falls back to /history, so a REMOTE listing can neither confirm nor deny a VHS video that never registered, and absence from it is NOT evidence the file is missing. Check a specific filename with action:"get" or upload_image (action:"stage") instead — both read /view. Every remote result says so in its own text. Read-only.\n' +
'- action:"convert" — Re-encode a generated image to PNG, JPEG, or WebP and return it inline as an image content block. Source can be a registered asset_id or a path under the local ComfyUI output directory. Optionally writes the converted image back under the output directory and reports source/output size plus bytes saved.\n' +
'- action:"analyze_color" — Measure the color of a rendered image (not by eye): returns black/white points, contrast (luma std), saturation, per-channel means + cast, and clipping — plus heuristic flags (washedOut, lowContrast, liftedBlacks, dimHighlights, lowSaturation, colorCast) and a one-line verdict. Source = asset_id, a ComfyUI output ref (filename/subfolder/type), or an image path. Pass reference_path to shot-match against a known-good frame (target−reference deltas). Set histogram:true to also get an overlaid R/G/B/luma histogram PNG. Use this to diagnose \'washed out\' objectively and decide a color fix; for a video, extract a frame to PNG first.\n' +
'- action:"list_assets" — List recently generated assets, newest-first. Each call first reconciles ComfyUI\'s /history, so outputs are listed even when this session did not watch the render complete (e.g. queued via panel_run, by an earlier session, or before a server restart) — those are tagged source:\'history-reconcile\', versus source:\'watched\' for renders this server saw finish. Newly reconciled image refs are checked through /view before registration; stale or unavailable refs are omitted and disclosed in the response note. Returns count + assets (asset_id, prompt_id, filename, url, source, created_at). The registry is ephemeral and clears on server restart; records expire after COMFYUI_ASSET_TTL_HOURS (default 24h), and only the most recent completed runs are reconciled — use get_history (action:\"list\") / action:"get" by filename for anything older.\n' +
'- action:"asset_metadata" — Get full provenance for a registered asset including the workflow snapshot that produced it. Use this to inspect the parameters that generated an image before calling generate_image (action:"regenerate") with overrides.', {
action: z
.enum(GET_IMAGE_ACTIONS)
.describe('Which image/asset operation to perform. "get" requires `filename`; "view" and "asset_metadata" require `asset_id`; "convert" requires `format` plus exactly one of `asset_id`/`path`; action:"analyze_color" takes one source (`asset_id`, `filename`, or `path`); "list_outputs" and action:"list_assets" take no required parameters.'),
filename: z
.string()
.optional()
.describe('Output image filename or a relative `subfolder/filename` reference from get_history, e.g. PulID_Klein_00001_.png or out_F/PulID_Klein_00001_.png. REQUIRED for action:"get". Relative prefixes are split automatically; absolute paths, drive prefixes, and `..` segments are refused. OPTIONAL for action:"analyze_color", where it is one of the three ways to name a source (pair it with subfolder/type).'),
asset_id: z
.string()
.optional()
.describe('Asset id returned by action:"list_assets" or job completion. REQUIRED for actions "view" and "asset_metadata". OPTIONAL for "convert" (provide exactly one of asset_id or path) and action:"analyze_color" (one of asset_id, filename, or path).'),
type: z
.enum(["output", "input", "temp"])
.optional()
.describe('ComfyUI directory the file lives in: output (default), input, or temp. Used by action:"get" and by action:"analyze_color" when the source is a `filename`.'),
subfolder: z
.string()
.optional()
.describe('Subfolder within the directory, if any (default empty). Used by action:"get" and by action:"analyze_color" when the source is a `filename`. If filename already includes a relative prefix, it is combined with this subfolder.'),
save_dir: z
.string()
.optional()
.describe('action:"get" — absolute local directory to save the file in. Defaults to a ' +
"'comfyui-images' folder inside the platform temp directory " +
"(os.tmpdir()), which is created if missing. A RELATIVE value is " +
"resolved against this MCP process's working directory, which is the " +
"client's choice and may not be writable. On Windows a drive-less path " +
"like \\out is resolved against this process's CURRENT DRIVE, not a drive " +
"you chose. Prefer a fully-qualified path (C:\\... or \\\\server\\share); " +
"the returned 'Saved to:' line always names the resolved absolute path."),
max_preview_bytes: z
.number()
.int()
.positive()
.optional()
.describe('action:"get" — ceiling on the base64 payload returned INLINE (default ~16MB). ' +
"The file saved to disk is never affected. Lower it when your client rejects " +
"or truncates large tool results; the reply says when it downscaled and by how much."),
max_preview_dimension: z
.number()
.int()
.positive()
.optional()
.describe('action:"get" — ceiling on the inline preview\'s longest side in pixels ' +
"(default 4096). Applies even when the byte budget is satisfied, since some " +
"consumers reject by dimension — but only for an image this server can decode; " +
"an undecodable one under the byte budget is passed through as-is. Does not " +
"affect the saved file."),
path: z
.string()
.optional()
.describe('A source image path. action:"convert" — a path under COMFYUI_PATH/output (provide exactly one of asset_id or path). action:"analyze_color" — an absolute image path, or a path under the ComfyUI output dir (videos: extract a frame to PNG first).'),
limit: z
.number()
.int()
.positive()
.optional()
.describe('action:"list_outputs" — max media files to return, 1..100 (default 20). action:"list_assets" — max records to return (default: all, no upper bound).'),
pattern: z
.string()
.optional()
.describe('action:"list_outputs" — filter by filename pattern (case-insensitive substring match).'),
format: z
.enum(["markdown", "json", "png", "jpeg", "webp"])
.optional()
.describe('Two unrelated meanings, one per action — the enum is the union of both and each action accepts only its own half. action:"list_outputs" — RESPONSE SHAPE: "markdown" (default, human/agent-readable) or "json" ({images:[{filename,subfolder,kind,size,modified,type?}]} — type is "temp" for VHS videos written with save_output unchecked, omitted for output/). action:"convert" — REQUIRED target encoded image format: "png", "jpeg" or "webp".'),
quality: z
.number()
.int()
.min(1)
.max(100)
.optional()
.describe('action:"convert" — encoder quality, 1-100. Applies where supported by the selected format.'),
progressive: z.boolean().optional().describe('action:"convert" — JPEG only: write a progressive JPEG.'),
lossless: z.boolean().optional().describe('action:"convert" — WebP only: write lossless WebP.'),
effort: z
.number()
.int()
.min(0)
.max(6)
.optional()
.describe('action:"convert" — WebP only: encoder effort, 0-6.'),
out_path: z
.string()
.optional()
.describe('action:"convert" — optional output path under COMFYUI_PATH/output where the converted image should be written.'),
reference_path: z
.string()
.optional()
.describe('action:"analyze_color" — optional reference image to shot-match against; returns target−reference deltas for contrast, black/white points, saturation, and per-channel means.'),
histogram: z
.boolean()
.optional()
.describe('action:"analyze_color" — also return an overlaid R/G/B/luma histogram PNG for visual confirmation (default false).'),
since: z
.string()
.datetime()
.optional()
.describe('action:"list_assets" — ISO timestamp; only return assets created at or after this time.'),
}, async (args) => {
try {
const json = (value) => ({
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
});
// `filename`/`asset_id`/`format` cannot be schema-required in a flat
// shape, so the handler enforces per-action presence and names the
// missing field — the same information the old per-tool schemas gave.
//
// ABSENCE only, never falsiness: `filename: ""` and `asset_id: ""`
// passed z.string() before this consolidation and reached the service,
// which answers with its own not-found/validation error. A `!filename`
// guard would swallow that path and substitute generic text instead.
const requireField = (value, action, field, what) => {
if (value === undefined) {
throw new Error(`get_image action:"${action}" requires \`${field}\` — ${what}.`);
}
return value;
};
switch (args.action) {
// ── GET (fetch by filename over /view, save to disk) ───────────────
case "get": {
const filename = requireField(args.filename, "get", "filename", 'the output filename to fetch (from get_history or action:"list_outputs")');
const { base64, mimeType } = await getOutputImage(filename, args.type ?? "output", args.subfolder ?? "",
// #1373 — the input dir legitimately holds workflow .json files.
// #2785 — still images use the 64 MB preview-source cap so
// max_preview_dimension can run instead of VIEW_TOO_LARGE at 32 MB.
{ allowMedia: true, allowAttachment: true, allowJson: true, forInlinePreview: true });
// The bytes are already in hand at this point. Saving them is a SEPARATE
// operation that can fail on its own (EPERM/EACCES/ENOSPC/read-only volume),
// and its failure says nothing about the fetch. Reporting the whole call as an
// error there threw away a successfully retrieved image and invited a retry of
// the fetch that could never fix the disk problem (#768) — so the save is
// isolated and its outcome is DISCLOSED rather than allowed to fail the tool.
//
// RESOLUTION is inside the guarded region, not before it: `resolveImageSaveDir`
// calls `process.cwd()` for a relative save_dir, and cwd itself throws ENOENT
// when the launch directory has been deleted underneath us. Left outside, that
// throw would reach the outer catch and discard the fetched image — the same
// loss this block exists to prevent, one line earlier.
//
// `explicitDir` is the TRIMMED value actually used for resolution, so the
// message can never describe a whitespace-only save_dir as a directory the
// caller named (resolveImageSaveDir treats it as omitted).
const explicitDir = args.save_dir?.trim() || undefined;
const localFilename = basename(filename);
let savePath;
let saveError;
try {
const saveDir = resolveImageSaveDir(args.save_dir);
savePath = join(saveDir, localFilename);
await mkdir(saveDir, { recursive: true });
await writeFile(savePath, Buffer.from(base64, "base64"));
}
catch (err) {
const detail = err instanceof Error ? err.message : String(err);
// A default destination we can still NAME, even if resolution itself failed —
// never a remedy the caller cannot act on.
let fallbackDefault;
try {
fallbackDefault = defaultImageSaveDir();
}
catch {
fallbackDefault = undefined;
}
saveError =
`NOT SAVED. The image was fetched from ComfyUI successfully, but ` +
(savePath
? `writing it to ${savePath} failed: ${detail}. `
: `this process could not even work out where to put it: ${detail}. `) +
(explicitDir
? `The destination came from the save_dir argument you passed ` +
`("${explicitDir}")` +
// Drive-relative is checked FIRST: `C:out` is not `isAbsolute`, so the
// generic relative branch would have claimed it resolved against this
// process's working directory, when Windows actually uses the named
// DRIVE's own working directory. Naming the wrong base is the same
// defect as naming the wrong cause.
(isDriveRelative(explicitDir)
? ` — a DRIVE-RELATIVE save_dir, so the part you did not give (the ` +
`drive, or that drive's current directory) came from this process's ` +
`state, not from your argument`
: !isAbsolute(explicitDir)
? ` — a RELATIVE save_dir, resolved against this process's working directory`
: "") +
`. Retry with an absolute save_dir you can write to` +
(fallbackDefault ? `, or omit save_dir to use the default ${fallbackDefault}.` : ".")
: `That is the default destination${fallbackDefault ? ` (${fallbackDefault})` : ""}. ` +
`Retry with an explicit absolute save_dir you can write to.`) +
` Do NOT re-run the render — the output already exists on the server.`;
}
// Only images render inline; video/audio are save-to-disk only (#663).
if (!mimeType.toLowerCase().startsWith("image/")) {
// Nothing can be handed back inline for media, so an unsaved fetch really did
// deliver nothing — but the message still names the exact destination and a
// remedy that works from here, instead of a bare EPERM.
if (saveError) {
return {
content: [{ type: "text", text: `${saveError} (${mimeType})` }],
isError: true,
};
}
return {
content: [
{
type: "text",
text: `Saved to: ${savePath} (${mimeType}; not rendered inline)`,
},
],
};
}
// #1495 — BOUND THE INLINE PAYLOAD. An 8504×17008 render encoded to ~267 MB and
// blew the caller's 64 MB IPC frame, so a perfectly good output could not be
// looked at. The saved file above is untouched; only what goes on the wire is
// capped.
const bounded = await boundInlineImage(base64, mimeType, {
budgetBytes: args.max_preview_bytes,
maxDimension: args.max_preview_dimension,
});
// Could not be reduced: say so and keep the saved path, rather than emitting a
// payload that will fail in transport — where the error names a byte count and
// not the image, which is how the reporter ended up debugging their own
// workflow instead of this tool.
// The disk claim is conditional on the save actually having happened (codex).
// Telling someone their full-resolution file is "on disk — open it directly"
// after an EACCES save is the same class of defect as the inline failure this
// whole change is about: a confident sentence about something nobody checked.
const savedOnDisk = !saveError && savePath;
if (bounded.refused) {
return {
content: [
{
type: "text",
text: (saveError ? `${saveError} ` : `Saved to: ${savePath}. `) +
`NOT rendered inline: ${bounded.refused.reason}. ` +
(savedOnDisk
? `The full-resolution file is on disk and unaffected — open it ` +
`directly, or re-run get_image with a smaller max_preview_dimension.`
: `NOTHING was written locally either, so this image is not available ` +
`here at all — fix the save destination above and retry, or re-run ` +
`get_image with a smaller max_preview_dimension. The output is still ` +
`intact on the ComfyUI server; do NOT re-run the render.`),
},
],
};
}
// A silently downscaled image is a worse failure than the one being fixed: an
// agent reads fine detail off it and reports confidently. So the preview
// ANNOUNCES itself, with the true dimensions and where the real file is.
const previewNote = bounded.preview
? ` PREVIEW ONLY: the inline image was downscaled to ` +
`${bounded.preview.width}×${bounded.preview.height} because the original ` +
`(${bounded.preview.originalWidth}×${bounded.preview.originalHeight}, ` +
`~${Math.round(bounded.preview.originalEncodedBytes / 1_048_576)} MB encoded) ` +
`exceeds what can be sent inline.` +
// Every way the preview differs from the source gets said, not just the
// resize (codex). An agent that is told "downscaled" and hands back a
// verdict on a video's motion, or on 16-bit banding, was misled by an
// accurate-but-incomplete sentence.
(bounded.preview.sourceMayBeAnimated
? ` The source format can hold ANIMATION and this preview is a single still ` +
`PNG — if it was animated you are seeing ONE frame, so do not judge motion, ` +
`timing, or any later frame from it.`
: "") +
(bounded.preview.recoded
? ` It was also re-encoded to 8-bit RGB PNG, so colour depth and colour space ` +
`differ from the source — do not judge banding or colour accuracy from it.`
: "") +
` Do NOT judge fine detail, small text, or pixel-level artefacts from it — ` +
(savedOnDisk
? `read the full-resolution file at the path above for that.`
: `and note the full-resolution file was NOT saved locally (see above), so ` +
`re-fetch it once the save destination is fixed.`)
: "";
return {
content: [
{
type: "text",
text: (saveError
? `${saveError} The image itself is returned inline below.`
: `Saved to: ${savePath}`) + previewNote,
},
{
type: "image",
data: bounded.base64,
mimeType: bounded.mimeType,
},
],
};
}
// ── VIEW (inline a registered asset's pixels) ──────────────────────
case "view": {
const assetId = requireField(args.asset_id, "view", "asset_id", 'the registered asset to inline (from action:"list_assets" or a job completion)');
const result = await viewAssetImage(assetId);
return {
content: result.content.map((block) => block.type === "image"
? { type: "image", data: block.data, mimeType: block.mimeType }
: { type: "text", text: block.text }),
};
}
// ── LIST_OUTPUTS (browse output/ — the video-render check) ─────────
case "list_outputs": {
// The retired output-listing tool's schema carried `.int().min(1).max(100)`
// while action:"list_assets" carried `.int().positive()` with no ceiling.
// A single zod field cannot express both: the intersection would
// REFUSE an action:"list_assets" limit of 500 that was legal before (a false
// refusal), and the union alone would let a list_outputs caller ask
// for 5000 entries and blow the context. So the shared field keeps
// the loosest zod constraint both agreed on (positive integer) and
// the 100 ceiling is re-applied HERE, for the one action that had it.
// The refusal is preserved; only its source moved from the schema
// layer to the handler, which is the same trade rule 3 already makes
// for presence.
if (args.limit !== undefined && args.limit > 100) {
throw new Error(`get_image action:"list_outputs" accepts \`limit\` 1..100 (got ${args.limit}). ` +
'Use action:"list_assets" for an unbounded listing of registered assets.');
}
// `format` is the union of two unrelated enums (see the schema note),
// so each action refuses the other's values by name rather than
// silently treating an encoder format as a response shape.
if (args.format !== undefined && args.format !== "markdown" && args.format !== "json") {
throw new Error(`get_image action:"list_outputs" accepts \`format\` "markdown" or "json" (got "${args.format}"). ` +
'"png"/"jpeg"/"webp" are encoder formats for action:"convert".');
}
const { images, source } = await listOutputMedia({
limit: args.limit,
pattern: args.pattern,
});
// Say WHERE these came from (#899). The tool resolved it a moment
// ago; withholding it forces callers to reconstruct the path, and the
// natural reconstruction — the workspace path from install_comfyui (action:"environment") —
// is wrong on any install launched with --output-directory. Naming the
// basis matters as much as the path: "scanned this directory" and
// "read the server's history" are different claims, and only one is
// about disk.
// #953 — naming the source was not enough. "What the server remembers
// this session" reads as a STALENESS caveat, and the reporter's files
// were never in /history at all: VHS-style video nodes write the file
// without registering an output entry, so those videos are absent from
// this listing by construction, not by age. They had 30+ ProRes masters
// under output/slot/, saw a confident 12-file listing that omitted every
// one, concluded the directory was empty and wrote that into a handover
// document. The files were fetchable by name the whole time.
//
// So the history branch states the OMISSION as a property of the source,
// and names the check that does work on a remote target.
const scannedTemp = Boolean(source.tempDirectory);
const historyWhereFrom = "Read from ComfyUI's generation history over HTTP — NOT from disk. This listing is " +
"INCOMPLETE BY CONSTRUCTION: VHS_VideoCombine and similar video nodes write their " +
"file without registering an output entry, so those videos never appear here even " +
"though they are on disk, and a server restart clears the history besides. Absence " +
"from this list is NOT evidence the file is missing. To check a specific file, fetch " +
'it by name with action:"get" or upload_image (action:"stage") — both read the server\'s ' +
"/view endpoint, which serves straight from the output directory.";
const whereFrom = source.basis === "local-scan"
? scannedTemp
? `Read from \`${source.directory}\` and \`${source.tempDirectory}\` (scanned on disk).`
: `Read from \`${source.directory}\` (scanned on disk).`
: source.basis === "server-history-fallback"
? `Local scan of \`${source.directory}\` found nothing; listing comes from ComfyUI's /history instead (the same source action:"get" /view uses). ` +
historyWhereFrom
: historyWhereFrom;
// Only when temp/ could not be scanned: VHS with save_output unchecked
// writes the completed .mp4 there, and an empty output/ listing without
// that scan is not evidence the file is missing (#2370). Production
// now scans temp/ for videos; this caveat is the fallback if that
// scan did not run.
const localEmptyCaveat = "This scan covers ComfyUI's OUTPUT directory only — it does NOT look in ComfyUI's " +
"temp/ folder. A VHS_VideoCombine with `save_output` unchecked writes its .mp4 there " +
'instead (including the "-audio.mp4" variant a run completion names), so a render that ' +
"finished can be absent here and still exist. If a completion just named the file, fetch " +
'it directly with get_image (action:"get", type:"temp") or re-register it with ' +
'upload_image (action:"stage", type:"temp") — both read /view, which serves temp/ too. ' +
"ComfyUI clears temp/ on restart, so do it before restarting.";
const emptyLocalNote = source.basis === "local-scan" && !scannedTemp ? ` ${localEmptyCaveat}` : "";
if (args.format === "json") {
// Machine-readable form for app clients (the mobile dataset picker):
// same entries, no prose. Thumbs render client-side via /view URLs.
return {
content: [
{
type: "text",
text: JSON.stringify({
// Machine-readable counterpart of the prose: the entries are
// meaningless to a client that cannot say which directory
// they are relative to (#899).
source: source.basis,
...(source.directory ? { directory: source.directory } : {}),
...(source.tempDirectory ? { tempDirectory: source.tempDirectory } : {}),
// Empty-only note. Populated payloads stay byte-identical
// for app clients except for type:"temp" on VHS temp videos.
...(images.length === 0
? {
note: source.basis === "local-scan"
? `${whereFrom}${emptyLocalNote}`
: whereFrom,
}
: {}),
images: images.map((img) => ({
filename: img.filename,
subfolder: img.subfolder,
kind: img.kind,
...(img.size > 0 ? { size: img.size } : {}),
...(img.modified ? { modified: img.modified } : {}),
...(img.type === "temp" ? { type: "temp" } : {}),
})),
}),
},
],
};
}
if (images.length === 0) {
return {
content: [
{
type: "text",
// NAMING THE SOURCE MATTERS MOST HERE. "Nothing found"
// invites the reader to conclude their file does not exist,
// and whether that holds depends entirely on where we looked.
text: (args.pattern
? `No output media (images or videos) found matching "${args.pattern}".`
: "No output media (images or videos) found.") +
` ${whereFrom}` +
emptyLocalNote,
},
],
};
}
const lines = images.map((img, i) => {
const loc = img.subfolder ? `${img.subfolder}/${img.filename}` : img.filename;
const sub = img.subfolder ? ` _(subfolder: ${img.subfolder})_` : "";
// Size/modified are only available on the local filesystem scan; the
// remote (history-derived) path leaves them as 0 / "" — omit them then.
const sizePart = img.size > 0 ? ` (${(img.size / 1024 / 1024).toFixed(1)} MB)` : "";
const datePart = img.modified
? ` — ${new Date(img.modified).toLocaleString()}`
: "";
const typePart = img.type === "temp" ? ' type:"temp"' : "";
return `${i + 1}. **${loc}** [${img.kind}]${typePart}${sizePart}${datePart}${sub}`;
});
const videoCount = images.filter((img) => img.kind === "video").length;
const summary = videoCount > 0
? `Found ${images.length} media file(s) (${videoCount} video):`
: `Found ${images.length} media file(s):`;
return {
content: [
{
type: "text",
text: `${summary} ${whereFrom}\n\n${lines.join("\n")}`,
},
],
};
}
// ── CONVERT (re-encode; may WRITE under the output dir) ────────────
case "convert": {
const format = requireField(args.format, "convert", "format", 'the target encoding — "png", "jpeg" or "webp"');
if (format !== "png" && format !== "jpeg" && format !== "webp") {
throw new Error(`get_image action:"convert" accepts \`format\` "png", "jpeg" or "webp" (got "${format}"). ` +
'"markdown"/"json" are response shapes for action:"list_outputs".');
}
const result = await convertImage({
asset_id: args.asset_id,
path: args.path,
format,
quality: args.quality,
progressive: args.progressive,
lossless: args.lossless,
effort: args.effort,
out_path: args.out_path,
});
return {
content: result.content.map((block) => block.type === "image"
? { type: "image", data: block.data, mimeType: block.mimeType }
: { type: "text", text: block.text }),
};
}
// ── ANALYZE_COLOR (objective scopes/stats, optional histogram) ─────
case "analyze_color": {
const result = await analyzeColor({
asset_id: args.asset_id,
path: args.path,
filename: args.filename,
subfolder: args.subfolder,
type: args.type,
reference_path: args.reference_path,
histogram: args.histogram,
});
return {
content: result.content.map((block) => block.type === "image"
? { type: "image", data: block.data, mimeType: block.mimeType }
: { type: "text", text: block.text }),
};
}
// ── LIST_ASSETS (registry + on-demand history reconcile) ───────────
case "list_assets": {
const since = args.since ? Date.parse(args.since) : undefined;
// Close the #751 gap: outputs whose completion this process didn't
// watch (panel_run dispatches, earlier sessions, pre-restart runs)
// register from history on demand. Best-effort — the registry still
// answers when ComfyUI is unreachable.
let note;
let reconciliation;
try {
// Keep a small failure allowance so unavailable newest refs do not
// hide a later valid asset, while scaling the total work envelope
// to the requested page. The response limit is still applied to
// the registry below; this only prevents an unbounded sequence of
// availability probes when the caller asks for one record.
const requestedLimit = typeof args.limit === "number" && Number.isFinite(args.limit)
? Math.floor(args.limit)
: undefined;
const maxProbeAttempts = requestedLimit === undefined
? MAX_RECONCILIATION_PROBE_ATTEMPTS
: Math.min(MAX_RECONCILIATION_PROBE_ATTEMPTS, Math.max(8, requestedLimit * 4));
reconciliation = await reconcileAssetsFromHistory({ maxProbeAttempts });
}
catch (reconcileErr) {
const message = reconcileErr instanceof Error
? reconcileErr.message
: String(reconcileErr);
logger.warn('get_image action:"list_assets" history reconcile failed', { error: message });
// Truthful degradation: previously reconciled records stay listed
// (they name real outputs) — the note must not claim watched-only.
note =
`Could not refresh from ComfyUI history (${message}); results may be stale — ` +
"they still include assets reconciled from history earlier, and very recent completions may be missing.";
}
const records = AssetRegistry.list({ limit: args.limit, since });
const skippedUnavailable = reconciliation?.skippedUnavailable ?? 0;
if (skippedUnavailable > 0 || reconciliation?.probeLimitReached) {
const reasons = [];
if (skippedUnavailable > 0) {
reasons.push(skippedUnavailable + " history image output(s) were not listed because the guarded ComfyUI /view consumer " +
"could not fetch or validate them. Check the filename/subfolder in get_history or try " +
'get_image action:"get" directly.');
}
if (reconciliation?.probeLimitReached) {
reasons.push("History reconciliation stopped at its bounded work/time budget, so additional refs may not be listed; " +
'use get_history or get_image action:"get" for a specific older output.');
}
note = reasons.join(" ");
}
if (records.length === 0 && note === undefined) {
note =
"No assets found — nothing completed under this server's watch and no recent successfully completed outputs in ComfyUI history. " +
'Use get_history to inspect past runs and get_image action:"get" to fetch an output by filename.';
}
return json({
count: records.length,
assets: records.map(summarizeRecord),
...(note !== undefined ? { note } : {}),
});
}
// ── ASSET_METADATA (provenance + the workflow that produced it) ────
case "asset_metadata": {
const assetId = requireField(args.asset_id, "asset_metadata", "asset_id", 'the registered asset whose provenance you want (from action:"list_assets" or a job completion)');
const record = AssetRegistry.get(assetId);
if (!record) {
return errorToToolResult(new Error(`No asset found for id "${assetId}". It may have expired or never been registered.`));
}
return json({
...summarizeRecord(record),
workflow: record.workflow,
});
}
default: {
// Unreachable given the zod enum, but a clear runtime guard beats a
// silent undefined if the schema and switch ever drift apart.
const exhaustive = args.action;
throw new Error(`Unknown get_image action "${String(exhaustive)}". Expected one of: ${GET_IMAGE_ACTIONS.join(", ")}.`);
}
}
}
catch (err) {
return errorToToolResult(err);
}
});
// ── upload_image (5 actions) ─────────────────────────────────────────────
// HTTP-only for the media uploads (works for both local and remote ComfyUI
// via /upload/image). The previous filesystem fallback was deceptive when
// COMFYUI_PATH auto-detected an unrelated local install — files would land in
// the wrong tree and the tool reported success while the remote ComfyUI never
// received them. Originally diagnosed by João Lucas
// (github.com/joaolvivas) in joaolvivas/comfyui-mcp-byjlucas@089180ad
// (2026-05-12).
//
// action:"stage" is the correct, dir-agnostic way to pipe one pipeline
// stage's output into the next stage's loader. It fetches the output via
// /view and re-registers it as an input via /upload/image — never touches the
// filesystem, so it works with custom --input-directory / --output-directory.
const MEDIA_UPLOADS = {
image: {
fn: uploadImageAuto,
nodeHint: "as the `image` input in LoadImage nodes",
},
video: {
fn: uploadVideoAuto,
nodeHint: "as the video file input in VHS_LoadVideo (or similar) nodes",
},
audio: {
fn: uploadAudioAuto,
nodeHint: "as the audio input in LoadAudio (or similar) nodes",
},
};
server.tool("upload_image", "Put a file where ComfyUI (or cloud storage) can read it. Driven by the `action` parameter:\n" +
'- action:"image" — Upload a local image file to the connected ComfyUI\'s input/ directory via the HTTP /upload/image endpoint so it can be referenced in LoadImage nodes. Works for both local and remote ComfyUI. Nested filenames that LoadImage does not enumerate are re-registered at the input root; the returned filename is the one a LoadImage combo can select.\n' +
'- action:"video" — Upload a local video file (.mp4, .mov, .webm, .avi, .mkv, .m4v) to the connected ComfyUI\'s input/ directory via the HTTP /upload/image endpoint for use in video-loading nodes such as VHS_LoadVideo (ComfyUI-VideoHelperSuite). Works for both local and remote ComfyUI. Returns the stored filename.\n' +
'- action:"audio" — Upload a local audio file (.wav, .mp3, .flac, .ogg, .m4a, .aac) to the connected ComfyUI\'s input/ directory via the HTTP /upload/image endpoint for use in audio-conditioned workflows (e.g. LoadAudio). Works for both local and remote ComfyUI. Returns the stored filename.\n' +
'- action:"stage" — Stage an EXISTING ComfyUI output (or temp/preview) as an INPUT so the next stage\'s loader (LoadImage / VHS_LoadVideo / LoadAudio) can read it. This is the CORRECT way to chain a multi-stage pipeline (e.g. Krea2 image → LTX video → WAN extend): it fetches the output\'s bytes from the server via /view and re-registers them as an input via /upload/image — the same endpoints get_image and the uploads above use. Because it goes entirely through the server API, it works even when ComfyUI was launched with a CUSTOM input/output directory. Do NOT instead copy the output file or guess a filesystem `input/` path — the server\'s input dir may be custom and it will reject the file ("Invalid image file"), wasting the render. Pass an existing output reference ({ filename, subfolder?, type? }); the media kind (image/video/audio) is inferred from the extension unless you set `kind`. Nested video as_filename values are staged at the input root because VHS_LoadVideo lists only top-level files. Returns { filename, subfolder, type: "input", kind } — drop `filename` into LoadImage / VHS_LoadVideo / LoadAudio combo widgets. VHS_LoadVideoPath needs the returned filesystem path, not that combo filename ("Invalid file path" otherwise).\n' +
'- action:"output" — Upload a generated ComfyUI output to CLOUD storage (this is the only action that sends bytes off the machine). Source can be asset_id or a local path under COMFYUI_PATH/output. Destination can be S3, Azure Blob, HTTP PUT, or HuggingFace via the hf CLI.', {
action: z
.enum(UPLOAD_IMAGE_ACTIONS)
.describe('What to upload and where. "image"/"video"/"audio" send a LOCAL file (`source_path`) to ComfyUI\'s input/ directory; "stage" re-registers an EXISTING server-side output (`filename`) as an input; "output" ships a generated output to cloud storage (`destination`).'),
source_path: z
.string()
.optional()
.describe('Absolute path to the local file to upload. REQUIRED for actions "image", "video" and "audio".'),
// NOTE the deliberate meaning split, kept because a fold must not rename
// the arguments a service is called with: for "image"/"video"/"audio"
// `filename` is the DESTINATION name in ComfyUI's input/ directory
// (uploadImageAuto's second argument), while for "stage" it names the
// SOURCE output being re-registered (stageOutputAsInput's `filename`),
// whose destination override is the separate `as_filename`. Both spellings
// predate this consolidation.
filename: z
.string()
.optional()
.describe('Two meanings, one per action. actions "image"/"video"/"audio" — OPTIONAL override for the filename in ComfyUI\'s input/ directory (auto-detected from source_path if omitted). A path prefix (e.g. assets/clip.mp4) places the upload in that SUBFOLDER of input/ — ".." is refused — and the returned filename reference includes the subfolder when the loader enumerates it. action:"image" verifies the name against LoadImage /object_info and, if a nested path is stored but not listed, returns a verified root filename instead. action:"stage" — REQUIRED filename of the EXISTING output/temp asset to re-register (from get_history or get_image action:"list_outputs"), e.g. LTX_video_00001.mp4; its destination name override is `as_filename`, not this field.'),
subfolder: z
.string()
.optional()
.describe('action:"stage" — subfolder the source asset currently lives in, if any.'),
type: z
.enum(["output", "temp"])
.optional()
.describe('action:"stage" — source directory the asset lives in: output (default) or temp (previews).'),
kind: z
.enum(["image", "video", "audio"])
.optional()
.describe('action:"stage" — force the media kind instead of inferring it from the file extension.'),
as_filename: z
.string()
.optional()
.describe('action:"stage" — override the filename it is registered under in the input/ directory (defaults to the source filename).'),
asset_id: z
.string()
.optional()
.describe('action:"output" — registered asset id from a completed job. Provide exactly one of asset_id or path.'),
path: z
.string()
.optional()
.describe('action:"output" — path to a generated output under COMFYUI_PATH/output. Provide exactly one of asset_id or path.'),
destination: z
.object({
s3: s3DestinationSchema.optional(),
azure: azureDestinationSchema.optional(),
http: httpDestinationSchema.optional(),
hf: hfDestinationSchema.optional(),
})
.optional()
.describe('action:"output" — REQUIRED. Exactly one upload destination.'),
}, async (args) => {
try {
// Same absence-not-falsiness contract as get_image above: `source_path: ""`
// and `filename: ""` passed z.string() before this consolidation and
// reached the service, which answers with its own validation error.
const requireField = (value, action, field, what) => {
if (value === undefined) {
throw new Error(`upload_image action:"${action}" requires \`${field}\` — ${what}.`);
}
return value;
};
switch (args.action) {
// ── LOCAL FILE → ComfyUI's input/ directory ────────────────────────
case "image":
case "video":
case "audio": {
const { fn, nodeHint } = MEDIA_UPLOADS[args.action];
const sourcePath = requireField(args.source_path, args.action, "source_path", "the absolute path of the local file to upload");
const result = await fn(sourcePath, args.filename);
// #946 recurrence: when the filename override carried a path, the
// file landed in that SUBFOLDER of input/ — and the bare name the
// old text returned did not resolve in a loader (FileNotFoundError
// on input/<name>). The reference a loader accepts is the
// subfolder-qualified path, so that is what we hand back — unless
// LoadImage's combo does not enumerate nested paths, in which case
// action:"image" returns the verified root filename instead (#2498).
const reference = result.subfolder
? `${result.subfolder}/${result.filename}`
: result.filename;
const selectable = "loaderSelectable" in result ? result.loaderSelectable : undefined;
const requested = "requestedFilename" in result ? result.requestedFilename : undefined;
const selectabilityNote = args.action !== "image"
? ""
: selectable === "verified"
? `\n\nThe fresh /object_info loader list verifies that "${reference}" is selectable.`
: selectable === "root-fallback"
? `\n\nThis ComfyUI stored the requested nested path "${requested}" ` +
`but LoadImage enumerates only top-level input files, so the same bytes were ` +
`registered at the root as "${reference}". The fresh /object_info loader ` +
`list verifies the root combo reference; use that one on LoadImage.\n\n` +
`NOTE: the open ComfyUI tab's loader dropdown was populated at page-load, ` +
`so this just-registered input is not in it yet — call panel_refresh_nodes ` +
`first (it re-pulls /object_info so the new file becomes selectable), THEN ` +
`panel_set_widget the LoadImage node's image widget to "${reference}".`
: selectable === "unverified" && (result.subfolder || requested)
? `\n\nThe upload succeeded, but a fresh /object_info response did not prove that ` +
`"${reference}" is present in a LoadImage list. Do not assume the widget can ` +
`select it; inspect the loader or retry after panel_refresh_nodes.`
: "";
return {
content: [
{
type: "text",
text: `Uploaded via HTTP.\n\nFilename: ${reference}\n\n` +
`Use "${reference}" ${nodeHint}.` +
selectabilityNote,
},
],
};
}
// ── SERVER-SIDE OUTPUT → ComfyUI's input/ directory ────────────────
case "stage": {
const filename = requireField(args.filename, "stage", "filename", 'the existing output/temp asset to re-register as an input (from get_history or get_image action:"list_outputs")');
const staged = await stageOutputAsInput({
filename,
subfolder: args.subfolder,
type: args.type ?? "output",
kind: args.kind,
asFilename: args.as_filename,
});
const loaderHint = staged.kind === "video"
? "the video file input in VHS_LoadVideo / VHS_LoadVideoFFmpeg"
: staged.kind === "audio"
? "the audio input in LoadAudio (or similar)"
: "the `image` input in LoadImage";
// Same #946 rule as the local uploads above: an as_filename with a
// path lands in a subfolder, and the loader reference is the
// subfolder-qualified path — the bare filename does not resolve.
const stagedRef = staged.subfolder
? `${staged.subfolder}/${staged.filename}`
: staged.filename;
const pathRef = staged.pathReference;
const loaderInstruction = staged.kind === "video" && pathRef
? `Use "${stagedRef}" as the video file input in VHS_LoadVideo / VHS_LoadVideoFFmpeg (combo filename). ` +
`VHS_LoadVideoPath / VHS_LoadVideoFFmpegPath take a filesystem path, not the combo filename — use "${pathRef}".`
: staged.loaderSelectable === "unverified"
? `The staged reference is "${stagedRef}" for ${loaderHint}.`
: `Use "${stagedRef}" as ${loaderHint}.`;
const selectabilityNote = staged.loaderSelectable === "verified"
? `The fresh /object_info loader list verifies that "${stagedRef}" is selectable.`
: staged.loaderSelectable === "root-fallback"
? `This ComfyUI stored the requested nested path "${staged.requestedFilename}" ` +
`but VHS_LoadVideo enumerates only top-level input files, so the same bytes were ` +
`registered at the root as "${stagedRef}". The fresh /object_info loader ` +
`list verifies the root combo reference; use that one on VHS_LoadVideo. ` +
(pathRef
? `VHS_LoadVideoPath must use the filesystem path "${pathRef}", not the combo filename.`
: "")
: `The upload succeeded, but a fresh /object_info response did not prove that ` +
`"${stagedRef}" is present in a loader list. Do not assume the widget can ` +
`select it; inspect the loader or retry after panel_refresh_nodes.`;
const setWidgetNote = staged.kind === "video" && pathRef
? `NOTE: the open ComfyUI tab's loader dropdown was populated at page-load, ` +
`so this just-registered input is not in it yet — call panel_refresh_nodes ` +
`first (it re-pulls /object_info so the new file becomes selectable), THEN ` +
`panel_set_widget VHS_LoadVideo.video to "${stagedRef}", or ` +
`panel_set_widget VHS_LoadVideoPath.video to "${pathRef}". ` +
`(panel_set_widget also self-refreshes on a rejected value, so a single ` +
`retry after panel_refresh_nodes will always accept it.)`
: `NOTE: the open ComfyUI tab's loader dropdown was populated at page-load, ` +
`so this just-registered input is not in it yet — call panel_refresh_nodes ` +
`first (it re-pulls /object_info so the new file becomes selectable), THEN ` +
`panel_set_widget the loader's widget to "${stagedRef}". ` +
`(panel_set_widget also self-refreshes on a rejected value, so a single ` +
`retry after panel_refresh_nodes will always accept it.)`;
return {
content: [
{
type: "text",
text: `Staged ${staged.kind} output as input via the server API.\n\n` +
`Input filename: ${stagedRef}\n` +
`subfolder: ${staged.subfolder || "(none)"}\n` +
`type: ${staged.type}\n` +
(pathRef ? `path: ${pathRef}\n` : "") +
`\n` +
`${loaderInstruction}\n\n` +
`${selectabilityNote}\n\n` +
setWidgetNote,
},
],
};
}
// ── GENERATED OUTPUT → cloud storage (off this machine) ────────────
case "output": {
if (args.destination === undefined) {
throw new Error('upload_image action:"output" requires `destination` — exactly one of s3, azure, http or hf.');
}
const result = await uploadOutput({
asset_id: args.asset_id,
path: args.path,
destination: args.destination,
});
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
}
default: {
const exhaustive = args.action;
throw new Error(`Unknown upload_image action "${String(exhaustive)}". Expected one of: ${UPLOAD_IMAGE_ACTIONS.join(", ")}.`);
}
}
}
catch (err) {
return errorToToolResult(err);
}
});
// The retired PNG-workflow-metadata tool registered here; 0.50.0 slice 14
// folded it into get_workflow (action:"from_image"), where the other workflow
// reads live. The extractor itself is untouched in
// src/services/image-management.ts.
}
//# sourceMappingURL=image-management.js.map