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),
178 lines • 9.65 kB
JavaScript
// The frontend's VIRTUAL-node registry, ORCHESTRATOR → spawned MCP child (#1400).
//
// `check_runtime` (checkWorkflowRuntime, services/api-nodes.ts) classifies a
// workflow's node types against the connected server's /object_info. A type
// absent from /object_info is reported in `unknownNodes` and collapses the
// verdict to "unknown" — cautious by design, because such a type COULD be a paid
// node the server does not expose. But the caution is wrong for a type that can
// never execute at all: a frontend-only VIRTUAL node (KJNodes' Get/Set bus,
// rgthree's Label / Fast Groups toggles, …). #1372/#1564 exempted the names the
// converter already knew; the owner declined a list of third-party names — a
// list goes stale silently and then reads as authoritative.
//
// The authority is the FRONTEND's LiteGraph registry: a type whose registered
// class sets `isVirtualNode === true` is skipped by ComfyUI's own serializer and
// never reaches the backend. The panel (comfyui-mcp-panel, the one process that
// CAN see that registry) proves it per type by probe-constructing the class, and
// serves it as the `graph_get_virtual_types` bridge command. The orchestrator
// pulls that answer on each panel hello and republishes it here, into the
// progress dir every spawned comfyui tool server already shares
// (COMFYUI_MCP_PROGRESS_DIR — the same plumbing as panel-origin-channel.ts,
// whose read/liveness/write helpers this module reuses so the two channels
// cannot drift). A child has no bridge; a file it can read is the whole channel.
//
// Entries are keyed by the tab's SERVER-OBSERVED handshake origin (UiBridge's
// tabServerOrigin), never the client-supplied hello claim — page JS can write
// the claim, it cannot forge the browser's Origin header (the #756 trust model).
// The reader matches its own target with sameOrigin (utils/origin.ts), so
// 127.0.0.1 and localhost spellings of one server are one entry (#1175).
//
// LEVEL-TRIGGERED like the sibling channel: the orchestrator republishes the
// CURRENT map on its 700ms poll tick, keyed to origins a connected tab actually
// fronts. A tab that goes away drops its entry within one tick, so a stale set
// stops exempting almost immediately. The record also carries the publisher's
// pid + a heartbeat, and the reader requires BOTH (see panel-origin-channel.ts
// for why each alone leaves a hole): a crashed orchestrator's record expires
// instead of exempting forever.
//
// ## What a wrong or stale answer could do, stated rather than implied
//
// The classifier checks /object_info FIRST: a type the server registers is
// classified from its def, and this set is never consulted for it — a stale or
// even hostile entry cannot move a REGISTERED (possibly paid) node. What an
// entry moves is only a type ABSENT from /object_info, from "unknown" to
// skipped. Such a type cannot execute on that server at all (an unregistered
// class_type is rejected at /prompt), so it cannot bill: the worst a stale entry
// produces is "local" for a workflow that is actually BROKEN (its pack removed),
// which fails loudly at queue time and is a runnability answer, not the
// cost answer this tool exists to give. The fail-closed direction is preserved.
//
// No-ops entirely when COMFYUI_MCP_PROGRESS_DIR is unset — a plain (non-panel)
// MCP server keeps the pre-#1400 "unknown" exactly as before.
import { join } from "node:path";
import { publisherAlive, readChannelFile, writeChannelPayload, } from "./panel-origin-channel.js";
import { sameOrigin } from "../utils/origin.js";
/** File name inside the progress dir. The `control-` prefix is load-bearing:
* pollDownloads and listTargetChangeRequests both filter on it, so this file is
* never mistaken for a download row or a target-change request (same contract
* as PANEL_ORIGINS_FILE). */
export const FRONTEND_VIRTUAL_TYPES_FILE = "control-frontend-virtual-types.json";
/** Refresh cadence for an UNCHANGED map, so a reader can tell "still true" from
* "nobody has touched this since the orchestrator died". Mirrors the sibling
* channel's 30s heartbeat against the same 700ms tick. */
export const FRONTEND_VIRTUAL_TYPES_HEARTBEAT_MS = 30_000;
/** How stale a record may be before the reader stops trusting it (4x the
* heartbeat, mirroring the sibling channel: the cost of expiring a good record
* is a cautious "unknown", but flapping would be worse). */
export const FRONTEND_VIRTUAL_TYPES_MAX_AGE_MS = 120_000;
/** Defensive bounds on a payload that ultimately comes from page JS. The honest
* answer is a few dozen short type names; anything past these is not something
* a healthy panel sent. */
const MAX_TYPES_PER_ORIGIN = 512;
const MAX_TYPE_CHARS = 300;
/** Read at CALL time, not at module load: a test can point the channel at a temp
* dir, and nothing in production changes it after spawn anyway. */
function channelFile(dir) {
const base = dir ?? process.env.COMFYUI_MCP_PROGRESS_DIR ?? "";
return base ? join(base, FRONTEND_VIRTUAL_TYPES_FILE) : null;
}
/** Last payload written by THIS process (change-only writes), and when. */
let lastPublished = null;
let lastWriteAt = 0;
/**
* Orchestrator side: publish the CURRENT virtual-registry map — one entry per
* origin a connected tab fronts and has answered `graph_get_virtual_types` for.
* Level-triggered: callers pass the whole live map every tick, and an origin
* whose tab disconnected drops out of the file within that tick.
*
* Best-effort: a failed write leaves the child on the previous record, which the
* freshness check expires — the classifier falls back to "unknown", never to a
* fabricated "virtual".
*/
export function publishFrontendVirtualTypes(dir, entries, now = Date.now()) {
const file = channelFile(dir);
if (!file)
return;
// What actually gets published: validated, bounded, deduped, ordered — the
// change-key and the payload are both built from THIS, so they cannot
// disagree, and a hostile-sized answer is cut to the defensive bounds before
// it can reach the classifier.
const published = entries
.filter((e) => e && typeof e.origin === "string" && e.origin !== "")
.map((e) => ({
origin: e.origin,
types: [
...new Set((Array.isArray(e.types) ? e.types : []).filter((t) => typeof t === "string" && t !== "" && t.length <= MAX_TYPE_CHARS)),
]
.sort()
.slice(0, MAX_TYPES_PER_ORIGIN),
}))
.sort((a, b) => (a.origin < b.origin ? -1 : a.origin > b.origin ? 1 : 0));
const key = JSON.stringify(published);
const changed = key !== lastPublished;
const heartbeatDue = now - lastWriteAt >= FRONTEND_VIRTUAL_TYPES_HEARTBEAT_MS;
if (!changed && !heartbeatDue)
return;
const payload = JSON.stringify({ entries: published, updated: now, pid: process.pid });
// A failed write leaves the PRIOR complete record in place; do NOT record this
// as published, so the next tick retries (the sibling channel's rule).
if (!writeChannelPayload(dir, file, payload))
return;
lastPublished = key;
lastWriteAt = now;
}
/** Test/shutdown hook: forget what this process last published, so the next
* publish writes unconditionally. */
export function resetPublishedFrontendVirtualTypes() {
lastPublished = null;
lastWriteAt = 0;
}
/**
* Child side: the virtual types a connected panel proved for the ComfyUI at
* `targetOrigin` (this process's own ComfyUI base URL — matching is alias-aware
* via sameOrigin). An EMPTY SET — never an error — when there is no channel (a
* plain MCP server), no live/fresh record, no entry for this origin, or the
* payload is unreadable: "no proof" is the cautious answer, and the classifier's
* pre-#1400 behaviour is the fallback every one of those shares.
*/
export function frontendVirtualTypesFor(targetOrigin, now = Date.now()) {
const out = new Set();
const file = channelFile();
if (!file || !targetOrigin)
return out;
try {
const raw = JSON.parse(readChannelFile(file));
if (!Array.isArray(raw?.entries))
return out;
// A record whose publisher is gone describes a registry nobody is still
// observing. Both checks, per the sibling channel: liveness catches the
// death, freshness catches a reused pid.
if (!publisherAlive(raw.pid))
return out;
const updated = typeof raw.updated === "number" ? raw.updated : 0;
// `updated > now` (a clock step, or a future-dated write) is NOT freshness
// evidence — without this it would read as maximally fresh, forever.
if (updated > now || now - updated > FRONTEND_VIRTUAL_TYPES_MAX_AGE_MS)
return out;
for (const entry of raw.entries) {
if (!entry || typeof entry.origin !== "string" || !entry.origin)
continue;
if (!sameOrigin(entry.origin, targetOrigin))
continue;
for (const t of Array.isArray(entry.types) ? entry.types : []) {
if (typeof t === "string" && t !== "" && t.length <= MAX_TYPE_CHARS)
out.add(t);
if (out.size >= MAX_TYPES_PER_ORIGIN)
break;
}
// One origin match is the answer; a second entry for the same server would
// be a publisher bug, and first-wins is at least DETERMINISTIC about it.
break;
}
return out;
}
catch {
return out;
}
}
//# sourceMappingURL=frontend-virtual-types.js.map