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),
417 lines • 17.3 kB
JavaScript
import { existsSync, readdirSync, renameSync } from "node:fs";
import { join } from "node:path";
import { config, getComfyUIBaseUrl } from "../config.js";
import { comfyuiFetch } from "../comfyui/fetch.js";
import { NodeBisectError } from "../utils/errors.js";
import { logger } from "../utils/logger.js";
import { detectManagerApi, enqueueManagerTaskForExternal, managerApiPrefixFor, startManagerQueueForExternal, } from "./node-management.js";
// ---------------------------------------------------------------------------
// Module-level session state — persists between MCP tool calls in a session.
// (Same pattern as `lastProcessInfo` in services/process-control.ts.)
// ---------------------------------------------------------------------------
let session = null;
const IDLE_STATE = {
status: "idle",
all: [],
range: [],
active: [],
culprit: null,
};
function snapshot(state) {
return {
status: state.status,
all: [...state.all],
range: [...state.range],
active: [...state.active],
culprit: state.culprit,
};
}
// ---------------------------------------------------------------------------
// Pure bisection math — no side effects, fully unit-testable.
// ---------------------------------------------------------------------------
/**
* Pick the active subset for a fresh `range`: the upper half.
* Matches comfy-cli: `active = new_range[len(new_range) // 2 :]`.
*/
export function pickActive(range) {
return range.slice(Math.floor(range.length / 2));
}
/** The complement of `active` within `all`. */
function inactiveOf(state) {
const activeSet = new Set(state.active);
return state.all.filter((n) => !activeSet.has(n));
}
/**
* Compute the next state after marking the current `active` set GOOD
* (problem absent from the enabled subset → culprit is among the disabled
* candidates). `new_range = range - active`.
*/
export function reduceGood(state) {
const activeSet = new Set(state.active);
const newRange = state.range.filter((n) => !activeSet.has(n));
return advance(state, newRange);
}
/**
* Compute the next state after marking the current `active` set BAD
* (problem present in the enabled subset → culprit is within active).
* `new_range = active`.
*/
export function reduceBad(state) {
const newRange = [...state.active];
return advance(state, newRange);
}
/**
* Shared tail of good/bad: if the candidate range has collapsed to a single
* node (or fewer) the search is resolved; otherwise pick the next active half.
*/
function advance(state, newRange) {
if (newRange.length <= 1) {
return {
status: "resolved",
all: state.all,
range: newRange,
active: [],
culprit: newRange[0] ?? null,
};
}
return {
status: "running",
all: state.all,
range: newRange,
active: pickActive(newRange),
culprit: null,
};
}
// ---------------------------------------------------------------------------
// Manager HTTP API controller (preferred — works against remote ComfyUI too)
// ---------------------------------------------------------------------------
function managerBase() {
return getComfyUIBaseUrl();
}
/** Small local fetch helper for the ComfyUI-Manager API. */
async function managerFetch(path, init, timeoutMs = 15000, base = managerBase()) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await comfyuiFetch(`${base}${path}`, {
...init,
signal: controller.signal,
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
});
return res;
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new NodeBisectError(`ComfyUI-Manager request to ${path} failed: ${msg}`);
}
finally {
clearTimeout(timer);
}
}
/**
* Whether the ComfyUI-Manager API is reachable. Used to decide between the
* HTTP controller and the filesystem fallback.
*/
export async function isManagerAvailable() {
try {
const base = managerBase();
const api = await detectManagerApi(base);
const res = await managerFetch(`${managerApiPrefixFor(api)}/customnode/installed?mode=default`, { method: "GET" }, 4000, base);
return res.ok;
}
catch {
return false;
}
}
const managerDescriptors = new Map();
/**
* Controller backed by the ComfyUI-Manager HTTP API (used for remote installs).
*
* Endpoints (verified against Comfy-Org/ComfyUI-Manager glob/manager_server.py):
* - GET /customnode/installed → { "<module>": { ver, cnr_id, aux_id, enabled }, ... }
* - POST /manager/queue/disable → { id, version, ui_id } (version != "unknown"
* → Manager uses `id` as the node name)
* - POST /manager/queue/install → re-enable a disabled pack via the synchronous
* unified_enable path: { id, version,
* selected_version, skip_post_install:true, ui_id }
* - POST /manager/queue/start → execute queued tasks
*
* Payloads carry each pack's REAL installed version (and cnr_id when registry-
* backed). Sending version:"unknown" wrongly forces Manager's git/"unknown"
* branch (which then needs a `files` array) and fails for registry packs.
*/
export const managerController = {
async listNodes() {
const base = managerBase();
const api = await detectManagerApi(base);
const res = await managerFetch(`${managerApiPrefixFor(api)}/customnode/installed?mode=default`, {
method: "GET",
}, 15000, base);
if (!res.ok) {
throw new NodeBisectError(`ComfyUI-Manager /customnode/installed returned HTTP ${res.status}`);
}
const data = (await res.json());
managerDescriptors.clear();
const nodes = [];
for (const [id, v] of Object.entries(data)) {
managerDescriptors.set(id, {
cnrId: v.cnr_id && v.cnr_id.length > 0 ? v.cnr_id : undefined,
version: typeof v.ver === "string" ? v.ver : undefined,
});
// Manager marks disabled packs with enabled:false; treat missing as enabled.
nodes.push({ id, enabled: v.enabled !== false });
}
// Stable, deterministic order so successive rounds are reproducible.
return nodes.sort((a, b) => a.id.localeCompare(b.id));
},
async setEnabledStates(enabled, disabled) {
// Build the payload from the cached descriptor: prefer the registry id + the
// pack's real installed version; fall back to the module id + its commit
// hash. Never send "unknown" for an installed pack.
const payloadFor = (id) => {
const d = managerDescriptors.get(id);
return {
id: d?.cnrId ?? id,
version: d?.version ?? "unknown",
ui_id: id,
};
};
const base = managerBase();
let queued = 0;
let used;
for (const id of disabled) {
const p = payloadFor(id);
used = await enqueueManagerTaskForExternal("disable", (api) => api === "v2"
? { node_name: id, is_unknown: false }
: { node_name: id, node_ver: p.version }, base);
queued++;
}
for (const id of enabled) {
const p = payloadFor(id);
used = await enqueueManagerTaskForExternal("enable", (api) => api === "v2"
? { cnr_id: managerDescriptors.get(id)?.cnrId ?? id }
: {
node_name: id,
node_ver: p.version,
}, base);
queued++;
}
if (queued > 0 && used)
await startManagerQueueForExternal(used, base);
},
};
// ---------------------------------------------------------------------------
// Filesystem fallback controller — toggles `.disabled` directory suffixes.
// Requires a local install (config.comfyuiPath); errors clearly in remote mode.
// ---------------------------------------------------------------------------
function customNodesDir() {
if (!config.comfyuiPath) {
throw new NodeBisectError("ComfyUI-Manager API is unavailable and no local ComfyUI path is configured " +
"(remote mode). Cannot toggle custom nodes via the filesystem. Set COMFYUI_PATH " +
"or ensure ComfyUI-Manager is installed and reachable.");
}
return join(config.comfyuiPath, "custom_nodes");
}
/** Strip a trailing ".disabled" to get the canonical node id. */
function canonicalName(entry) {
return entry.endsWith(".disabled")
? entry.slice(0, -".disabled".length)
: entry;
}
export const filesystemController = {
async listNodes() {
const dir = customNodesDir();
if (!existsSync(dir)) {
throw new NodeBisectError(`custom_nodes directory not found: ${dir}`);
}
// A pack is disabled when its directory carries the ".disabled" suffix
// (ComfyUI-Manager's own convention). If both "foo" and "foo.disabled"
// exist, treat the pack as enabled.
const enabledById = new Map();
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory())
continue;
const name = entry.name;
// Skip bookkeeping dirs (note: "foo.disabled" does not start with ".").
if (name === "__pycache__" || name.startsWith("."))
continue;
const id = canonicalName(name);
const isEnabled = !name.endsWith(".disabled");
enabledById.set(id, (enabledById.get(id) ?? false) || isEnabled);
}
return [...enabledById.entries()]
.map(([id, enabled]) => ({ id, enabled }))
.sort((a, b) => a.id.localeCompare(b.id));
},
async setEnabledStates(enabled, disabled) {
const dir = customNodesDir();
const enableSet = new Set(enabled);
const disableSet = new Set(disabled);
for (const id of disableSet) {
const active = join(dir, id);
const off = join(dir, `${id}.disabled`);
if (existsSync(active) && !existsSync(off)) {
renameSync(active, off);
}
}
for (const id of enableSet) {
const active = join(dir, id);
const off = join(dir, `${id}.disabled`);
if (existsSync(off) && !existsSync(active)) {
renameSync(off, active);
}
}
},
};
// ---------------------------------------------------------------------------
// Controller selection
// ---------------------------------------------------------------------------
/**
* Choose the node controller: prefer the Manager HTTP API (works remotely),
* fall back to filesystem `.disabled` toggling for local installs.
*/
export async function resolveController() {
// Bisect toggles enable/disable for arbitrary packs. The filesystem
// ".disabled" rename is exactly Manager's own mechanism and works uniformly
// for every pack type, so prefer it whenever a local install path is known.
// Use the Manager HTTP API only for remote (--comfyui-url) targets.
if (config.comfyuiPath) {
logger.info("node-bisect: using filesystem controller (local install)");
return filesystemController;
}
if (await isManagerAvailable()) {
logger.info("node-bisect: remote target — using ComfyUI-Manager HTTP API");
return managerController;
}
// No local path and Manager unreachable — filesystemController throws a clear
// remote-mode error when invoked.
return filesystemController;
}
async function controllerFor(deps) {
return deps?.controller ?? (await resolveController());
}
function describe(state) {
if (state.status === "resolved") {
return state.culprit
? `Resolved: the problematic custom node is "${state.culprit}".`
: "Resolved: no candidate nodes remain (problem not attributable to a single node).";
}
if (state.status === "idle") {
return 'No bisect session is active. Run bisect (action:"start") to begin.';
}
return (`Round in progress: ${state.range.length} candidate node(s) remain. ` +
`${state.active.length} enabled this round, ${state.all.length - state.active.length} disabled. ` +
`Reproduce your problem now, then call bisect (action:"good") if the problem is gone or bisect (action:"bad") if it persists.`);
}
/**
* Begin a bisect session over all installed custom nodes. Enables the upper
* half and disables the rest for the first test round.
*/
export async function bisectStart(deps) {
const controller = await controllerFor(deps);
const installed = await controller.listNodes();
// Range only over nodes that are ENABLED right now. Packs the user disabled
// before starting are left untouched and are never re-enabled by bisect.
const all = installed.filter((n) => n.enabled).map((n) => n.id);
const skipped = installed.length - all.length;
const skippedNote = skipped > 0 ? ` (${skipped} already-disabled pack(s) left as-is)` : "";
if (all.length === 0) {
throw new NodeBisectError("No enabled custom nodes were found to bisect" +
(installed.length > 0
? " (every installed pack is already disabled)."
: "."));
}
if (all.length === 1) {
// Nothing to bisect — the lone node is trivially the only candidate.
session = {
status: "resolved",
all,
range: [...all],
active: [],
culprit: all[0],
};
return {
state: snapshot(session),
message: `Only one enabled custom node ("${all[0]}")${skippedNote}. ` +
"It is the sole candidate; nothing to bisect.",
};
}
const range = [...all];
const active = pickActive(range);
session = { status: "running", all, range, active, culprit: null };
await controller.setEnabledStates(active, inactiveOf(session));
return {
state: snapshot(session),
message: `Started bisect over ${all.length} enabled custom node(s)${skippedNote}. ` +
`${describe(session)} ` +
"A ComfyUI restart may be required for node changes to take effect.",
};
}
function requireRunning() {
if (!session || session.status !== "running") {
throw new NodeBisectError('No bisect session is in progress. Run bisect (action:"start") first.');
}
return session;
}
async function transition(reducer, deps) {
const current = requireRunning();
const controller = await controllerFor(deps);
const next = reducer(current);
session = next;
if (next.status === "resolved") {
// Search done — re-enable everything so the install is left usable.
await controller.setEnabledStates(next.all, []);
return {
state: snapshot(next),
message: `${describe(next)} All custom nodes have been re-enabled.`,
};
}
await controller.setEnabledStates(next.active, inactiveOf(next));
return {
state: snapshot(next),
message: `${describe(next)} A ComfyUI restart may be required for node changes to take effect.`,
};
}
/** Mark the current active set GOOD (problem absent) and narrow the search. */
export async function bisectGood(deps) {
return transition(reduceGood, deps);
}
/** Mark the current active set BAD (problem present) and narrow the search. */
export async function bisectBad(deps) {
return transition(reduceBad, deps);
}
/** Re-enable all custom nodes and clear the session. */
export async function bisectReset(deps) {
if (!session) {
return {
state: snapshot(IDLE_STATE),
message: "No bisect session was active; nothing to reset.",
};
}
const controller = await controllerFor(deps);
// Restore ONLY the nodes this session ranged over (all were enabled at start).
// Packs the user had already disabled are deliberately left disabled — they
// were never recorded in `all`.
const toRestore = session.all;
session = null;
if (toRestore.length > 0) {
await controller.setEnabledStates(toRestore, []);
}
return {
state: snapshot(IDLE_STATE),
message: toRestore.length > 0
? `Bisect session cleared. Re-enabled ${toRestore.length} custom node(s) that bisect had toggled. ` +
"A ComfyUI restart may be required for node changes to take effect."
: "Bisect session cleared.",
};
}
/** Report the current session state and remaining candidate set. */
export function bisectStatus() {
const state = session ? snapshot(session) : snapshot(IDLE_STATE);
return { state, message: describe(state) };
}
/** Test-only: reset module-level session state. */
export function __resetSessionForTests() {
session = null;
}
//# sourceMappingURL=node-bisect.js.map