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),
439 lines • 16.6 kB
JavaScript
/**
* #2529 — terminal-output classification from live `/object_info` `output_node`.
*
* ComfyUI's backend executes any class with `OUTPUT_NODE = True`. The panel's
* run-to-node guard keys `node.constructor.nodeData.output_node` instead, which
* is missing on some custom frontend classes (VHS_VideoCombine) even when the
* backend metadata is true. Classify from object_info — never a name list.
*/
import { enqueuePrompt, peekObjectInfoCache } from "../comfyui/client.js";
import { convertUiToApi, isUiFormat } from "./workflow-converter.js";
export const NOT_OUTPUT_NODE_RE = /node\s+(\d+)\s+\(([^)]+)\)\s+is not an output node/i;
let objectInfoOverride;
let enqueueScopedOutputNodeOverride = null;
/** Test seam: overlay / recovery object_info without a live ComfyUI. */
export function setOutputNodeObjectInfoForTests(info) {
objectInfoOverride = info;
}
/** Test seam: HTTP `/prompt` fallback without posting to a real ComfyUI. */
export function setEnqueueScopedOutputNodeForTests(fn) {
enqueueScopedOutputNodeOverride = fn;
}
export function outputNodeObjectInfoNow() {
if (objectInfoOverride !== undefined)
return objectInfoOverride;
return peekObjectInfoCache();
}
export function isOutputNodeType(classType, objectInfo) {
if (!classType || !objectInfo)
return false;
return objectInfo[classType]?.output_node === true;
}
export function toolResultText(res) {
return (res.content ?? [])
.filter((c) => c.type === "text")
.map((c) => c.text)
.join("\n");
}
function asRecord(value) {
if (value === null || typeof value !== "object" || Array.isArray(value))
return null;
return value;
}
export function parseToolResultJson(res) {
const text = res.content.find((c) => c.type === "text")?.text;
if (typeof text !== "string")
return null;
try {
return asRecord(JSON.parse(text));
}
catch {
return null;
}
}
export function parseNotOutputNodeRefusal(source) {
const text = typeof source === "string" ? source : toolResultText(source);
const m = text.match(NOT_OUTPUT_NODE_RE);
if (!m)
return null;
const nodeId = Number(m[1]);
const classType = m[2]?.trim() ?? "";
if (!Number.isInteger(nodeId) || !classType)
return null;
return { nodeId, classType };
}
function nodeClassType(node) {
const record = asRecord(node);
if (!record)
return null;
if (typeof record.type === "string" && record.type)
return record.type;
if (typeof record.class_type === "string" && record.class_type)
return record.class_type;
return null;
}
function stampNode(node, objectInfo) {
const classType = nodeClassType(node);
if (!classType)
return;
if (isOutputNodeType(classType, objectInfo))
node.is_output = true;
}
function stampValue(value, objectInfo, depth) {
if (depth > 8 || value == null)
return;
if (Array.isArray(value)) {
for (const item of value)
stampValue(item, objectInfo, depth + 1);
return;
}
const rec = asRecord(value);
if (!rec)
return;
stampNode(rec, objectInfo);
for (const nested of Object.values(rec))
stampValue(nested, objectInfo, depth + 1);
}
function stampJsonLines(text, objectInfo) {
const lines = text.split("\n");
let changed = false;
const out = lines.map((line) => {
const trimmed = line.trim();
if (!trimmed.startsWith("{"))
return line;
try {
const parsed = JSON.parse(trimmed);
const rec = asRecord(parsed);
if (!rec)
return line;
const before = rec.is_output;
stampNode(rec, objectInfo);
if (rec.is_output === before)
return line;
changed = true;
return JSON.stringify(rec);
}
catch {
return line;
}
});
return changed ? out.join("\n") : text;
}
/**
* Add `is_output:true` on live-graph rows whose class has `output_node:true`
* in object_info. Does not invent `is_output:false`.
*/
export function stampOutputNodeFlags(payload, objectInfo) {
if (!objectInfo)
return payload;
stampValue(payload, objectInfo, 0);
if (typeof payload.text === "string") {
payload.text = stampJsonLines(payload.text, objectInfo);
}
return payload;
}
export function stampOutputNodeFlagsOnToolResult(res, objectInfo) {
if (!objectInfo || res.isError)
return res;
const payload = parseToolResultJson(res);
if (!payload)
return res;
const stamped = stampOutputNodeFlags(payload, objectInfo);
const idx = res.content.findIndex((c) => c.type === "text");
if (idx < 0)
return res;
return {
...res,
content: res.content.map((c, i) => i === idx && c.type === "text"
? { ...c, text: JSON.stringify(stamped, null, 2) }
: c),
};
}
function objectInfoFromReply(reply) {
const rec = asRecord(reply);
if (!rec)
return null;
const info = rec.object_info ?? rec;
const map = asRecord(info);
if (!map)
return null;
for (const def of Object.values(map)) {
const node = asRecord(def);
if (node && ("input" in node || "output" in node || "output_node" in node)) {
return map;
}
}
return null;
}
async function fetchPanelObjectInfo(call) {
if (objectInfoOverride !== undefined)
return objectInfoOverride;
try {
const reply = await call({ cmd: "graph_get_object_info" }, 90_000);
if (reply.isError)
return null;
return objectInfoFromReply(parseToolResultJson(reply));
}
catch {
return null;
}
}
function containsUnstampedNode(value, depth = 0) {
if (depth > 8 || value == null)
return false;
if (Array.isArray(value))
return value.some((item) => containsUnstampedNode(item, depth + 1));
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed.startsWith("{"))
return false;
try {
return containsUnstampedNode(JSON.parse(trimmed), depth + 1);
}
catch {
return false;
}
}
const record = asRecord(value);
if (!record)
return false;
if (nodeClassType(record) && !("is_output" in record))
return true;
return Object.values(record).some((nested) => containsUnstampedNode(nested, depth + 1));
}
/** Use the fresh headless cache, or refresh the panel only when its reply has
* node rows whose output classification is absent. */
export async function outputNodeObjectInfoForPanel(call, source) {
if (!source || source.isError || !containsUnstampedNode(parseToolResultJson(source)))
return null;
return outputNodeObjectInfoNow() ?? fetchPanelObjectInfo(call);
}
function uiWorkflowFromSerialize(payload) {
if (!payload)
return null;
const inner = payload.workflow ?? payload;
return isUiFormat(inner) ? inner : null;
}
function workflowUuidFromUi(ui) {
const extra = asRecord(ui.extra);
const metadata = asRecord(extra?.comfyui_mcp);
const uuid = metadata?.workflow_uuid;
return typeof uuid === "string" && uuid.length > 0 ? uuid : null;
}
function scopedFallbackRefusal(reason) {
return { refusal: reason };
}
function rootScopedFallbackWitness(payload, ui, toNodeId, classType) {
const viewing = asRecord(payload?.viewing);
if (viewing?.scope === "subgraph") {
return scopedFallbackRefusal(`the panel reports the target is in a subgraph, but its exact colon-qualified ` +
`NodeExecutionId is not exposed to MCP; the panel retry must perform this nested run`);
}
if (viewing?.scope !== "root") {
return scopedFallbackRefusal(`the panel did not provide a root viewing-scope witness, so the workflow target is ` +
`unproven`);
}
const panelUuid = viewing.workflow_uuid;
const serializedUuid = workflowUuidFromUi(ui);
if (typeof panelUuid !== "string" ||
panelUuid.length === 0 ||
serializedUuid === null ||
panelUuid !== serializedUuid) {
return scopedFallbackRefusal(`the panel graph and serialized workflow did not carry the same workflow identity; ` +
`no fallback prompt was sent`);
}
const targetId = String(toNodeId);
const rootNode = ui.nodes.find((node) => String(node.id) === targetId && nodeClassType(node) === classType);
if (!rootNode) {
return scopedFallbackRefusal(`node ${targetId} is not present as the requested class on the serialized root ` +
`graph; an exact scoped target could not be proven`);
}
const queriedNodes = payload?.nodes;
if (!Array.isArray(queriedNodes) ||
!queriedNodes.some((raw) => {
const node = asRecord(raw);
return node !== null && String(node.id) === targetId && nodeClassType(node) === classType;
})) {
return scopedFallbackRefusal(`the panel's current root query did not return node ${targetId} as ` +
`${classType}; the target may have moved or changed`);
}
return { targetId };
}
function normalizedBatchCount(batchCount) {
if (typeof batchCount !== "number" || !Number.isFinite(batchCount))
return 1;
return Math.max(1, Math.min(100, Math.floor(batchCount)));
}
async function enqueueConvertedScopedRun(toNodeId, classType, objectInfo, call, batchCount, directFallbackAllowed) {
if (!directFallbackAllowed) {
return scopedFallbackRefusal(`the panel's server-observed origin was not proven to be the configured ComfyUI ` +
`target; direct /prompt fallback is disabled to prevent cross-instance queueing`);
}
const serialized = await call({ cmd: "graph_serialize" }, 8000);
if (serialized.isError)
return null;
const ui = uiWorkflowFromSerialize(parseToolResultJson(serialized));
if (!ui)
return null;
const queried = await call({ cmd: "graph_query", ids: [toNodeId], fields: "detail", limit: 1 }, 8000);
if (queried.isError)
return null;
const witness = rootScopedFallbackWitness(parseToolResultJson(queried), ui, toNodeId, classType);
if ("refusal" in witness)
return witness;
const targets = [witness.targetId];
const converted = convertUiToApi(ui, objectInfo);
if (!converted.workflow[witness.targetId]) {
return scopedFallbackRefusal(`the converted workflow did not contain root target ${witness.targetId}; ` +
`no unscoped prompt was sent`);
}
const count = normalizedBatchCount(batchCount);
if (enqueueScopedOutputNodeOverride) {
return {
result: await enqueueScopedOutputNodeOverride(converted.workflow, targets, count),
};
}
const results = [];
try {
for (let i = 0; i < count; i += 1) {
results.push(await enqueuePrompt(converted.workflow, undefined, {
partialExecutionTargets: targets,
}));
}
}
catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`${message} Direct scoped fallback had already accepted ${results.length} of ` +
`${count} requested batch run(s); do not blindly re-submit before checking ` +
`queue/history.`);
}
return {
result: {
prompt_id: results[0].prompt_id,
...(results.length > 1
? { prompt_ids: results.map((entry) => entry.prompt_id) }
: {}),
queue_remaining: results.at(-1)?.queue_remaining,
...(results.some((entry) => entry.rejectedOutputs)
? {
rejectedOutputs: results
.map((entry) => entry.rejectedOutputs)
.filter((entry) => typeof entry === "string" && entry.length > 0)
.join("\n"),
}
: {}),
},
};
}
function queuedToolResult(enqueued, toNodeId, classType) {
return {
content: [
{
type: "text",
text: JSON.stringify({
queued: true,
prompt_id: enqueued.prompt_id,
...(enqueued.prompt_ids && enqueued.prompt_ids.length > 1
? { prompt_ids: enqueued.prompt_ids }
: {}),
to_node_id: toNodeId,
ran_to_node: toNodeId,
output_node_source: "object_info",
output_node_class: classType,
...(enqueued.queue_remaining === undefined
? {}
: { queue_remaining: enqueued.queue_remaining }),
...(enqueued.rejectedOutputs
? { rejected_outputs: enqueued.rejectedOutputs }
: {}),
}, null, 2),
},
],
};
}
/**
* When the panel refuses a scoped run as "not an output node", consult live
* object_info. If that class is `output_node:true`, retry graph_run (newer
* panels can honor `output_node:true`) and fall back to `/prompt` with
* `partial_execution_targets` — the field ComfyUI itself uses for run-to-node.
*/
export async function recoverOutputNodeScopedRun(opts) {
const parsed = parseNotOutputNodeRefusal(opts.rejection) ??
parseNotOutputNodeRefusal(opts.res);
if (!parsed || parsed.nodeId !== opts.toNodeId)
return null;
const objectInfo = await fetchPanelObjectInfo(opts.call);
if (!isOutputNodeType(parsed.classType, objectInfo) || !objectInfo)
return null;
let retry;
try {
retry = await opts.call({
cmd: "graph_run",
batch_count: opts.batchCount,
to_node_id: opts.toNodeId,
output_node: true,
}, 20_000);
}
catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
content: [
{
type: "text",
text: `Error: panel_run's OUTPUT_NODE recovery retry for ${parsed.classType} ` +
`failed before a reply could be read; its outcome is UNKNOWN and it may ` +
`have queued a render. Check queue (action:"list") or get_history before ` +
`re-submitting. (${message})`,
},
],
isError: true,
};
}
const retryParsed = parseToolResultJson(retry);
if (!retry.isError &&
retryParsed?.queued !== false &&
(retryParsed?.queued === true || typeof retryParsed?.prompt_id === "string")) {
return retry;
}
if (!parseNotOutputNodeRefusal(retry)) {
// A different failure (validation, transport) — surface it rather than
// enqueueing a second prompt.
return retry;
}
try {
const enqueued = await enqueueConvertedScopedRun(opts.toNodeId, parsed.classType, objectInfo, opts.call, opts.batchCount, opts.directFallbackAllowed === true);
if (!enqueued)
return null;
if ("refusal" in enqueued) {
return {
content: [
{
type: "text",
text: `Error: panel_run recognized ${parsed.classType} as an OUTPUT_NODE from ` +
`the connected panel's object_info, but refused the direct /prompt fallback: ` +
`${enqueued.refusal}. No fallback prompt was sent.`,
},
],
isError: true,
};
}
if (!enqueued.result?.prompt_id)
return null;
return queuedToolResult(enqueued.result, opts.toNodeId, parsed.classType);
}
catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
content: [
{
type: "text",
text: `Error: panel_run recovered ${parsed.classType} as an OUTPUT_NODE from ` +
`the connected panel's object_info, but the direct /prompt fallback failed: ` +
`${message}`,
},
],
isError: true,
};
}
}
//# sourceMappingURL=output-node.js.map