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),
301 lines • 11.6 kB
JavaScript
/**
* #2536 — `panel_connect` can report success for a frontend PrimitiveNode wired
* to a forceInput-only STRING, then `panel_run` omits the required input.
*
* PrimitiveNode is LiteGraph-native: it is skipped from the queued prompt and
* only serializes by copying its literal onto a target widget. A forceInput
* socket has no widget, so the visible link disappears from `/prompt` and
* ComfyUI rejects with `Required input is missing`.
*
* After a successful connect, prove the source is PrimitiveNode AND the target
* input has no serializable widget binding, then disconnect and refuse with a
* backend STRING producer (PrimitiveStringMultiline). Unreadable / truncated
* probes fall open — they do not prove the force-only shape.
*/
import { normalizeNodeId } from "../orchestrator/node-id.js";
import { canonicalConnectNodeId, sameConnectNodeId, } from "./connect-live-graph.js";
export const FRONTEND_PRIMITIVE_NODE_TYPE = "PrimitiveNode";
/** Backend STRING producer `panel_connect` accepts on a forceInput-only STRING. */
export const BACKEND_STRING_PRODUCER_EXAMPLE = "PrimitiveStringMultiline";
/**
* Shared recovery instruction for a forceInput-only / non-widget STRING.
* `panel_connect` and `panel_set_widget` (MiniMaxH3Director) both use this so
* they cannot recommend a frontend PrimitiveNode that the other tool refuses.
*/
export function backendStringProducerConnectAdvice(inputName) {
return (`Add a backend STRING producer such as ${BACKEND_STRING_PRODUCER_EXAMPLE}, set its ` +
`STRING widget, then panel_connect that STRING output to "${inputName}".`);
}
const DETAIL_MAX_CHARS = 60000;
const DETAIL_TIMEOUT_MS = 8000;
function asRecord(value) {
if (value === null || typeof value !== "object" || Array.isArray(value))
return null;
return value;
}
function toolText(res) {
return res.content.map((c) => (c.type === "text" ? (c.text ?? "") : "")).join("\n");
}
function parseJsonPayload(res) {
if (res.isError)
return null;
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;
}
}
function uniqueEndpointIds(fromNodeId, toNodeId) {
const ids = [];
const seen = new Set();
for (const value of [fromNodeId, toNodeId]) {
const canonical = canonicalConnectNodeId(value);
if (!canonical || seen.has(canonical))
continue;
seen.add(canonical);
const wire = typeof value === "number" || typeof value === "string" ? normalizeNodeId(value) : canonical;
ids.push(wire);
}
return ids;
}
function isForceInputFlag(value) {
return value === true;
}
function parseLiveConnectInput(raw, index) {
const rec = asRecord(raw);
if (!rec || typeof rec.name !== "string" || rec.name.length === 0)
return null;
const slot = typeof rec.slot === "number" && Number.isSafeInteger(rec.slot)
? rec.slot
: typeof rec.slot_index === "number" && Number.isSafeInteger(rec.slot_index)
? rec.slot_index
: index;
const config = asRecord(rec.config) ?? asRecord(rec.options);
return {
name: rec.name,
slot,
type: typeof rec.type === "string" && rec.type.length > 0 ? rec.type : null,
widget: rec.widget,
forceInput: isForceInputFlag(rec.forceInput) ||
isForceInputFlag(rec.force_input) ||
isForceInputFlag(config?.forceInput) ||
isForceInputFlag(config?.force_input),
connectedFrom: rec.connected_from ?? rec.connectedFrom ?? rec.link ?? null,
};
}
function parseLiveConnectNode(raw) {
const rec = asRecord(raw);
if (!rec)
return null;
const id = canonicalConnectNodeId(rec.id);
if (!id)
return null;
const type = typeof rec.type === "string" && rec.type.length > 0
? rec.type
: typeof rec.class_type === "string" && rec.class_type.length > 0
? rec.class_type
: null;
if (!type)
return null;
if (!Array.isArray(rec.inputs))
return null;
const widgetsKnown = Object.prototype.hasOwnProperty.call(rec, "widgets");
const widgetsRaw = rec.widgets;
const widgets = widgetsRaw && typeof widgetsRaw === "object" && !Array.isArray(widgetsRaw)
? widgetsRaw
: null;
const inputs = [];
for (let i = 0; i < rec.inputs.length; i++) {
const input = parseLiveConnectInput(rec.inputs[i], i);
if (input)
inputs.push(input);
}
return {
id,
type,
widgets: widgetsKnown ? widgets : null,
widgetsKnown,
inputs,
};
}
function collectDetailRows(payload) {
if (Array.isArray(payload.nodes))
return payload.nodes;
if (typeof payload.text !== "string")
return [];
const rows = [];
for (const line of payload.text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed.startsWith("{"))
continue;
try {
rows.push(JSON.parse(trimmed));
}
catch {
/* compact/ids line */
}
}
return rows;
}
export function isFrontendPrimitiveNodeType(type) {
return type === FRONTEND_PRIMITIVE_NODE_TYPE;
}
/** True when the live input carries a widget object/name the Primitive can bake onto. */
export function hasSerializableWidgetBinding(input) {
const widget = input.widget;
if (widget === true)
return true;
if (typeof widget === "string")
return widget.length > 0;
const rec = asRecord(widget);
if (!rec)
return false;
if (typeof rec.name === "string")
return rec.name.length > 0;
return Object.keys(rec).length > 0;
}
/**
* True when the live target input is proven forceInput-only / non-widget.
* A missing widgets map is not evidence — that probe falls open.
*/
export function isForceInputOnlyNonWidget(input, widgets, widgetsKnown) {
if (input.forceInput)
return true;
if (hasSerializableWidgetBinding(input))
return false;
if (!widgetsKnown)
return false;
return !Object.prototype.hasOwnProperty.call(widgets ?? {}, input.name);
}
function connectedFromNodeId(connectedFrom) {
if (connectedFrom == null)
return null;
if (typeof connectedFrom === "number")
return canonicalConnectNodeId(connectedFrom);
if (typeof connectedFrom === "string") {
const idPart = connectedFrom.split(".")[0] ?? connectedFrom;
return canonicalConnectNodeId(idPart);
}
const rec = asRecord(connectedFrom);
if (!rec)
return null;
return canonicalConnectNodeId(rec.node_id ?? rec.id);
}
export function findTargetConnectInput(target, fromNodeId, toInput) {
if (typeof toInput === "string" && toInput.length > 0) {
const byName = target.inputs.find((input) => input.name === toInput);
if (byName)
return byName;
const lower = toInput.toLowerCase();
return target.inputs.find((input) => input.name.toLowerCase() === lower) ?? null;
}
if (typeof toInput === "number" && Number.isSafeInteger(toInput)) {
const bySlot = target.inputs.find((input) => input.slot === toInput);
if (bySlot)
return bySlot;
return target.inputs[toInput] ?? null;
}
const fromId = canonicalConnectNodeId(fromNodeId);
if (!fromId)
return null;
return (target.inputs.find((input) => sameConnectNodeId(connectedFromNodeId(input.connectedFrom), fromId)) ??
null);
}
export function parseLiveConnectNodes(payload) {
const rec = asRecord(payload);
if (!rec)
return null;
if (Object.prototype.hasOwnProperty.call(rec, "truncated") && rec.truncated !== false) {
return null;
}
const nodes = [];
const seen = new Set();
for (const row of collectDetailRows(rec)) {
const node = parseLiveConnectNode(row);
if (!node || seen.has(node.id))
continue;
seen.add(node.id);
nodes.push(node);
}
return nodes.length > 0 ? nodes : null;
}
export function nodeByConnectId(nodes, nodeId) {
const want = canonicalConnectNodeId(nodeId);
if (!want)
return null;
return nodes.find((node) => node.id === want) ?? null;
}
export function primitiveForceInputRefusal(opts) {
const from = canonicalConnectNodeId(opts.fromNodeId) ?? String(opts.fromNodeId);
const to = canonicalConnectNodeId(opts.toNodeId) ?? String(opts.toNodeId);
const slot = opts.inputType ? `${opts.toType}.${opts.inputName} (${opts.inputType})` : `${opts.toType}.${opts.inputName}`;
const undo = opts.disconnected
? "The LiteGraph link was disconnected so this is not reported as success."
: "Disconnect that input before retrying with a backend STRING producer.";
return (`Error: panel_connect refused frontend PrimitiveNode #${from} → #${to} ${slot}. ` +
`A PrimitiveNode only serializes through a target widget; this input is forceInput-only ` +
`(no serializable widget binding), so panel_run would omit the required input from the ` +
`queued prompt. ${backendStringProducerConnectAdvice(opts.inputName)} ${undo} ` +
`(artokun/comfyui-mcp#2536)`);
}
function errorResult(text, base) {
return { ...base, isError: true, content: [{ type: "text", text }] };
}
/**
* After a successful graph_connect, refuse a frontend PrimitiveNode that landed
* on a forceInput-only / non-widget input. Fail-open when the live detail cannot
* prove that shape.
*/
export async function verifyPrimitiveForceInputAfterConnect(args, connected, call) {
if (connected.isError)
return connected;
const endpointIds = uniqueEndpointIds(args.from_node_id, args.to_node_id);
if (endpointIds.length === 0)
return connected;
const detail = await call({
cmd: "graph_query",
ids: endpointIds,
fields: "detail",
limit: endpointIds.length,
max_chars: DETAIL_MAX_CHARS,
}, DETAIL_TIMEOUT_MS);
const nodes = parseLiveConnectNodes(parseJsonPayload(detail));
if (!nodes)
return connected;
const source = nodeByConnectId(nodes, args.from_node_id);
const target = nodeByConnectId(nodes, args.to_node_id);
if (!source || !target)
return connected;
if (!isFrontendPrimitiveNodeType(source.type))
return connected;
const input = findTargetConnectInput(target, args.from_node_id, args.to_input);
if (!input)
return connected;
// #2536 is STRING-only. A LiteGraph wildcard (`*`) with no widget is a typed
// destination for PrimitiveNode (#2542), not a forceInput-only STRING omit.
if ((input.type ?? "").toUpperCase() !== "STRING")
return connected;
if (!isForceInputOnlyNonWidget(input, target.widgets, target.widgetsKnown)) {
return connected;
}
let disconnected = false;
const undone = await call({
cmd: "graph_disconnect",
node_id: args.to_node_id,
input: input.name,
});
disconnected = undone.isError !== true;
return errorResult(primitiveForceInputRefusal({
fromNodeId: args.from_node_id,
toNodeId: args.to_node_id,
toType: target.type,
inputName: input.name,
inputType: input.type,
disconnected,
}), connected);
}
//# sourceMappingURL=primitive-force-input-connect.js.map