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),
453 lines • 20.9 kB
JavaScript
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { getComfyUIBaseUrl } from "../config.js";
import { comfyuiFetch } from "../comfyui/fetch.js";
import { resolveEffectiveComfyUICodeBase, resolveLocalMutationTarget, } from "./workspace-env.js";
import { recordPanelPendingOp, SNAPSHOT_RESTORE_PENDING_MS, withPanelPinGuard, } from "./panel-pin-guard.js";
import { NodeSnapshotError } from "../utils/errors.js";
import { logger } from "../utils/logger.js";
// Shown when this ComfyUI-Manager build doesn't expose the /snapshot/* HTTP
// API at all (common on the bundled ComfyUI Desktop Manager): every snapshot
// endpoint 404s. Report it as an unsupported-capability result, not a crash.
const SNAPSHOTS_UNSUPPORTED_MESSAGE = "Node snapshots aren't supported on this ComfyUI-Manager build — its HTTP API " +
"doesn't expose the /snapshot/* endpoints (common on the bundled ComfyUI Desktop " +
"Manager). Update ComfyUI-Manager to a build that supports snapshots, or manage " +
"snapshots from the ComfyUI-Manager UI / comfy-cli instead. When neither is " +
"available there is still a rollback path: before changing anything, copy the " +
"pack directories under custom_nodes somewhere safe (or move them into a " +
"custom_nodes/.disabled folder) and record what moved — restore is moving them " +
"back.";
/**
* True when an error indicates the Manager build simply lacks the snapshot
* endpoint (404), as opposed to a transient/server-side failure (e.g. 500) or
* a network error, which should still surface.
*/
function isSnapshotEndpointUnsupported(err) {
if (!(err instanceof NodeSnapshotError))
return false;
const status = err.details?.status;
return status === 404 || status === 405 || status === 501;
}
function managerBaseUrl() {
return getComfyUIBaseUrl();
}
/**
* Fetch a ComfyUI-Manager endpoint. Throws NodeSnapshotError on non-2xx or
* network failure so callers can route through errorToToolResult.
*
* `base` pins the target for this call (the ManagerFetchOptions.base
* convention): callers that record a pending-op marker must pass the SAME
* base they recorded, so marker and request can never name different servers
* (#689 round 3).
*/
async function managerFetch(path, init, base = managerBaseUrl()) {
const url = `${base}${path}`;
logger.debug("Manager API request", { url, method: init?.method ?? "GET" });
let res;
try {
res = await comfyuiFetch(url, init);
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new NodeSnapshotError(`Failed to reach ComfyUI-Manager at ${url}: ${msg}. ` +
`Is ComfyUI running with ComfyUI-Manager installed?`, { url });
}
if (!res.ok) {
// 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(() => "");
throw new NodeSnapshotError(`ComfyUI-Manager API ${res.status} ${res.statusText} for ${path}`, { url, status: res.status, body });
}
return res;
}
/**
* Candidate ComfyUI-Manager FILES directories under a local install, newest
* layout first. Mirrors manager_migration.get_manager_path() (user/__manager
* when ComfyUI has the system-user API, user/default/ComfyUI-Manager
* otherwise) plus the legacy git-clone layout, which keeps everything inside
* the extension dir.
*/
function managerFilesDirCandidates(comfyuiPath) {
const candidates = [
join(comfyuiPath, "user", "__manager"),
join(comfyuiPath, "user", "default", "ComfyUI-Manager"),
];
// Modern Manager files are data/user state and stay anchored to the local
// mutation target above. The legacy git-clone layout lives under
// custom_nodes/, which --base-directory runtimes scan from the data/base
// root (#1770). Search the data root first, then the checkout as a last
// resort for a non-base-directory split that still keeps Manager next to
// main.py.
const legacyRoots = new Set([
comfyuiPath,
resolveEffectiveComfyUICodeBase(),
]);
for (const root of legacyRoots) {
if (root)
candidates.push(join(root, "custom_nodes", "ComfyUI-Manager"));
}
return candidates;
}
/**
* Candidate ComfyUI-Manager snapshot directories under a local install,
* newest layout first.
*/
function snapshotDirCandidates(comfyuiPath) {
return managerFilesDirCandidates(comfyuiPath).map((dir) => join(dir, "snapshots"));
}
/**
* Resolve the directory to write a named snapshot into. Prefers an existing
* Manager snapshots dir; otherwise falls back to the newest-layout path
* (created on write).
*/
function resolveSnapshotWriteDir(comfyuiPath) {
const candidates = snapshotDirCandidates(comfyuiPath);
for (const dir of candidates) {
if (existsSync(dir))
return dir;
}
return candidates[0];
}
/**
* Serialize a JSON-ish value to YAML. Scalars are always double-quoted (valid,
* if verbose, YAML), which sidesteps any need to decide when keys/values like
* git URLs require quoting.
*/
function toYaml(value, indent) {
const pad = " ".repeat(indent);
if (value === null || value === undefined)
return "null";
if (typeof value === "number" || typeof value === "boolean")
return String(value);
if (typeof value === "string")
return JSON.stringify(value);
if (Array.isArray(value)) {
if (value.length === 0)
return "[]";
return ("\n" +
value
.map((item) => {
const rendered = toYaml(item, indent + 1);
// A nested block (object/array) renders on following indented lines.
return rendered.startsWith("\n")
? `${pad}-${rendered}`
: `${pad}- ${rendered}`;
})
.join("\n"));
}
const entries = Object.entries(value);
if (entries.length === 0)
return "{}";
return ("\n" +
entries
.map(([k, v]) => {
const rendered = toYaml(v, indent + 1);
return rendered.startsWith("\n")
? `${pad}${JSON.stringify(k)}:${rendered}`
: `${pad}${JSON.stringify(k)}: ${rendered}`;
})
.join("\n"));
}
/**
* Render a snapshot as a comfy-cli/cm-cli-compatible YAML document. The CLI
* wraps the snapshot body under a top-level `custom_nodes:` key (restore reads
* `info['custom_nodes']` for .yaml files), so we must match that contract.
*/
function snapshotToYaml(snapshot) {
return `custom_nodes:${toYaml(snapshot, 1)}\n`;
}
/** GET /snapshot/getlist — raw fetch + parse; throws on any non-2xx. */
async function fetchSnapshotList() {
const res = await managerFetch("/snapshot/getlist");
const data = (await res.json());
return Array.isArray(data.items)
? data.items.filter((x) => typeof x === "string")
: [];
}
/** GET /snapshot/getlist */
export async function listNodeSnapshots() {
try {
const items = await fetchSnapshotList();
logger.info(`Listed ${items.length} node snapshot(s)`);
return { snapshots: items };
}
catch (err) {
if (isSnapshotEndpointUnsupported(err)) {
logger.info("Snapshot listing unsupported on this ComfyUI-Manager build");
return { snapshots: [], unsupported: true, message: SNAPSHOTS_UNSUPPORTED_MESSAGE };
}
throw err;
}
}
/**
* Save a snapshot of the current custom-node + version state.
*
* - No name: POST /snapshot/save (Manager names it {date}_snapshot). Works
* remotely. We diff getlist before/after to report the created name.
* - Named: fetch GET /snapshot/get_current and write <name>.json into the
* local Manager snapshots dir. Requires a resolvable local install root
* (COMFYUI_PATH or a saved default workspace); errors clearly when there is none.
*/
export async function saveNodeSnapshot(name) {
const trimmed = name?.trim();
if (!trimmed) {
// HTTP-only path — Manager assigns a timestamped name.
try {
const before = new Set(await fetchSnapshotList());
await managerFetch("/snapshot/save", { method: "POST" });
const after = await fetchSnapshotList();
const created = after.find((n) => !before.has(n));
const resolved = created ?? after[0] ?? "(unknown)";
return {
name: resolved,
method: "http",
message: `Saved snapshot "${resolved}" via ComfyUI-Manager.`,
};
}
catch (err) {
if (isSnapshotEndpointUnsupported(err)) {
logger.info("Snapshot save unsupported on this ComfyUI-Manager build");
return {
name: "",
method: "http",
message: SNAPSHOTS_UNSUPPORTED_MESSAGE,
unsupported: true,
};
}
throw err;
}
}
// Named snapshot — requires a local install to write the file. Resolve the
// data/base root through the same local-mutation resolver used by other
// filesystem writes (#775): COMFYUI_PATH, then the saved default workspace.
// A split COMFYUI_CODE_PATH is consulted later only for the legacy Manager
// layout under custom_nodes.
validateSnapshotName(trimmed);
// A named save WRITES a file into the install tree, so it must know WHICH
// install — and in remote mode a stale local COMFYUI_PATH would send that write
// to an unrelated tree while the snapshot content came from the remote server
// (codex gate P0). Refuse instead of guessing.
const target = resolveLocalMutationTarget();
if (target.refusal) {
throw new NodeSnapshotError(`Saving a named snapshot requires a local ComfyUI install, and ${target.refusal} ` +
"Omit the name to let ComfyUI-Manager assign a timestamped one on the server itself.");
}
const comfyuiBase = target.base;
if (!comfyuiBase) {
throw new NodeSnapshotError("Saving a named snapshot requires a local ComfyUI install path, and none is " +
"available: COMFYUI_PATH is unset, no default workspace is saved (set one with " +
"the workspace tool, action 'set_default'), or the session targets a genuinely " +
"remote ComfyUI. Omit the name to let ComfyUI-Manager assign a timestamped " +
"snapshot instead — that works remotely.");
}
// Capture current state from the running instance via the Manager API.
let res;
try {
res = await managerFetch("/snapshot/get_current");
}
catch (err) {
if (isSnapshotEndpointUnsupported(err)) {
logger.info("Snapshot capture unsupported on this ComfyUI-Manager build");
return {
name: trimmed,
method: "file",
message: SNAPSHOTS_UNSUPPORTED_MESSAGE,
unsupported: true,
};
}
throw err;
}
const snapshot = await res.json();
// Honor the comfy-cli contract: the file FORMAT is chosen by extension.
// `.json` → bare object (4-space indent, as save_snapshot_with_postfix does);
// `.yaml`/`.yml` → the snapshot wrapped under a `custom_nodes:` key. A name
// without a recognized extension defaults to `.json` (never double-appended).
const lower = trimmed.toLowerCase();
const isYaml = lower.endsWith(".yaml") || lower.endsWith(".yml");
const isJson = lower.endsWith(".json");
const fileName = isYaml || isJson ? trimmed : `${trimmed}.json`;
const dir = resolveSnapshotWriteDir(comfyuiBase);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
const filePath = join(dir, fileName);
const contents = isYaml
? snapshotToYaml(snapshot)
: JSON.stringify(snapshot, null, 4);
writeFileSync(filePath, contents, "utf-8");
logger.info(`Wrote named node snapshot to ${filePath}`);
return {
name: fileName,
method: "file",
message: `Saved snapshot "${fileName}" to ${filePath}.`,
};
}
/** POST /snapshot/restore with { target } */
export async function restoreNodeSnapshot(name) {
const trimmed = name?.trim();
if (!trimmed) {
throw new NodeSnapshotError("Snapshot name is required to restore.");
}
// PIN GUARD. A snapshot records every pack's commit and restoring reverts them
// ALL, so this moves the sidebar panel exactly like an "update all" would —
// another door that never passes through install_comfyui(action:'panel'). It is inherently bulk
// (there is no restore-everything-except-one-pack), so a pin refuses it
// outright and the message explains the two real options. The check and the
// restore request are atomic under the panel mutation lock (withPanelPinGuard);
// the Manager then applies the restore on its own schedule (the message below
// says "requested", never "restored").
return withPanelPinGuard("restore a node snapshot over", "all", async () => {
// The Manager stores names without the .json suffix; tolerate either.
const target = trimmed.endsWith(".json") ? trimmed.slice(0, -5) : trimmed;
// Pin the target ONCE and use it for BOTH the marker and the request: a
// retarget between the record and the POST must never leave the marker
// naming server A while the restore is scheduled on server B — a later
// pin-write cancellation trusts the marker's base (#689 round 3).
const base = managerBaseUrl();
// Persist and VERIFY the warning marker BEFORE requesting the deferred
// restore. A pin written after Manager receives the request cannot stop the
// next-restart apply; refusing when the marker cannot be made durable is the
// only way to avoid reporting such a pin as protective. Keep the marker on a
// request failure because a transport error cannot prove Manager did not
// receive it. The base is recorded so the pin-write cancellation path aims
// at the host the restore is actually scheduled on (#689).
recordPanelPendingOp("snapshot-restore", `a snapshot restore ("${target}") may have been requested and reverts EVERY pack to ` +
`its snapshot commit — the sidebar panel included — at the next ComfyUI ` +
`restart`, SNAPSHOT_RESTORE_PENDING_MS, { base });
try {
await managerFetch("/snapshot/restore", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ target }),
}, base);
}
catch (err) {
if (isSnapshotEndpointUnsupported(err)) {
logger.info("Snapshot restore unsupported on this ComfyUI-Manager build");
return { name: target, message: SNAPSHOTS_UNSUPPORTED_MESSAGE, unsupported: true };
}
throw err;
}
logger.info(`Requested restore of node snapshot "${target}"`);
return {
name: target,
message: `Restore of snapshot "${target}" requested. ComfyUI-Manager applies ` +
`custom-node changes on the next ComfyUI restart.`,
};
});
}
/**
* The deferred-restore file Manager's POST /snapshot/restore leaves behind:
* nothing happens at request time beyond copying the chosen snapshot to
* `<manager files>/startup-scripts/restore-snapshot.json`, and
* prestartup_script.py applies it at the next ComfyUI start only
* `if os.path.exists(...)`, then deletes it. Deleting the file before that
* restart is therefore a PROVABLE cancel, and its absence proves nothing is
* scheduled. (Verified against Comfy-Org/ComfyUI-Manager main AND the
* manager-v4 pip package — both use the same path.)
*/
const RESTORE_SNAPSHOT_FILE = join("startup-scripts", "restore-snapshot.json");
/**
* Cancel a DEFERRED snapshot restore by deleting Manager's deferred-restore
* file before the next ComfyUI restart consumes it (#689).
*
* Local installs only: in remote mode the file lives on the ComfyUI host,
* which this process cannot reach — reported truthfully as "cannot cancel",
* never as a cancel. Every candidate Manager files dir is checked (a stale
* copy in a legacy layout is deleted too: Manager's prestartup reads exactly
* one of these, and leaving any of them in place risks the restore running).
*/
export function cancelPendingSnapshotRestore() {
// Same resolver as a named save (#775): the saved default workspace counts as
// a local install path — a bare comfyuiPath check misclassified exactly that
// session as "remote".
// Cancelling DELETES the pending-restore file. Same hazard as the save: in
// remote mode a stale local path would delete an unrelated install's pending
// restore (codex gate P0).
const cancelTarget = resolveLocalMutationTarget();
if (cancelTarget.refusal) {
return {
outcome: "remote",
deletedPaths: [],
checkedPaths: [],
detail: `cannot cancel — ${cancelTarget.refusal} Nothing was deleted.`,
};
}
const comfyuiBase = cancelTarget.base;
if (!comfyuiBase) {
return {
outcome: "remote",
deletedPaths: [],
checkedPaths: [],
detail: "cannot cancel — no local install path (COMFYUI_PATH unset, no saved " +
"default workspace, or a genuinely remote target): the deferred restore file " +
"(<ComfyUI>/user/**/startup-scripts/restore-snapshot.json) lives on the " +
"ComfyUI host and this orchestrator has no local install path to delete " +
"it through. Delete it on the host BEFORE the next ComfyUI restart, or " +
"the restore will run.",
};
}
const checkedPaths = managerFilesDirCandidates(comfyuiBase).map((dir) => join(dir, RESTORE_SNAPSHOT_FILE));
const found = checkedPaths.filter((p) => existsSync(p));
if (found.length === 0) {
return {
outcome: "not-scheduled",
deletedPaths: [],
checkedPaths,
detail: "no deferred restore file exists in any ComfyUI-Manager files dir, so " +
"there was NOTHING left to cancel — the restore already ran at a ComfyUI " +
"restart (or was never scheduled). The panel may ALREADY have been " +
"reverted to its snapshot commit — check install_comfyui(action:'panel', panel_action:'status').",
};
}
const deletedPaths = [];
for (const p of found) {
try {
rmSync(p, { force: true });
}
catch (err) {
return {
outcome: "failed",
deletedPaths,
checkedPaths,
detail: `could not delete the deferred restore file at ${p}: ${err instanceof Error ? err.message : String(err)}. The restore may still run at the next ComfyUI restart — delete that ` +
`file on the ComfyUI host manually.`,
};
}
// Verify gone rather than trusting the syscall — the whole point is proof.
if (!existsSync(p))
deletedPaths.push(p);
}
if (deletedPaths.length !== found.length) {
const surviving = found.filter((p) => !deletedPaths.includes(p));
return {
outcome: "failed",
deletedPaths,
checkedPaths,
detail: `the deferred restore file still exists after deletion was attempted ` +
`(${surviving.join(", ")}). The restore may still run at the next ` +
`ComfyUI restart — delete it on the ComfyUI host manually.`,
};
}
return {
outcome: "cancelled",
deletedPaths,
checkedPaths,
detail: `deleted the deferred restore file (${deletedPaths.join(", ")}) — the ` +
`snapshot restore will NOT run at the next ComfyUI restart.`,
};
}
/**
* Reject names that would escape the snapshots directory or produce an
* invalid filename.
*/
function validateSnapshotName(name) {
if (name.includes("/") ||
name.includes("\\") ||
name.includes("..") ||
name.includes("\0")) {
throw new NodeSnapshotError(`Invalid snapshot name "${name}": must not contain path separators or "..".`);
}
}
//# sourceMappingURL=node-snapshots.js.map