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

417 lines • 17.3 kB
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