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),
423 lines • 15.1 kB
JavaScript
/**
* #2542 — `panel_connect` can refuse a LiteGraph wildcard (`*`) output onto a
* wildcard input even though both slots advertise `*` (PrimitiveNode
* "connect to widget input" → LogicIF.when_true / when_false).
*
* The panel's diagnostic tail ("No input on node N accepts type *") is the
* generic connect-failure sentence, not a compatibility verdict: shared
* slot-compat ranks `*` → `*` as a valid wildcard pairing, and the primitive
* is supposed to become typed from the downstream destination.
*
* On that refusal, re-read both endpoints and retry once with explicit slot
* names. A forceInput-only STRING (no widget) is left alone — that is #2536,
* a lying success that `panel_run` would omit, not a wildcard-to-wildcard
* link.
*/
import { normalizeNodeId } from "../orchestrator/node-id.js";
import { canonicalConnectNodeId, } from "./connect-live-graph.js";
import { isLiteGraphWildcardType, isTypeCompatible } from "./slot-compat.js";
const ACCEPTS_TYPE_STAR_RE = /No input on node \S+ accepts type \*/i;
const SLOT_LISTING_RE = /\[(\d+)\] "([^"]*)" \(([^)]*)\)/g;
const OUTPUTS_LINE_RE = /Node \S+ outputs:\s*(.*)$/im;
const INPUTS_LINE_RE = /Node \S+ inputs:\s*(.*)$/im;
const DETAIL_MAX_CHARS = 60000;
const DETAIL_TIMEOUT_MS = 8000;
const FRONTEND_PRIMITIVE_NODE_TYPE = "PrimitiveNode";
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 {
// unknown-ok: unreadable JSON is not evidence the slots are wildcards.
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 slotTypeString(raw) {
const trimmed = raw.trim();
const slash = trimmed.indexOf("/");
return slash === -1 ? trimmed : trimmed.slice(0, slash).trim();
}
function parseSlotListing(line) {
if (!line)
return [];
const slots = [];
SLOT_LISTING_RE.lastIndex = 0;
for (const match of line.matchAll(SLOT_LISTING_RE)) {
const index = Number.parseInt(match[1] ?? "", 10);
const name = match[2] ?? "";
const type = slotTypeString(match[3] ?? "");
if (!Number.isSafeInteger(index) || !type)
continue;
slots.push({ index, name, type });
}
return slots;
}
export function isLiteGraphWildcardSlotType(type) {
return isLiteGraphWildcardType(type);
}
export function wildcardToWildcardRefusal(text) {
if (!ACCEPTS_TYPE_STAR_RE.test(text))
return null;
const outputs = parseSlotListing(OUTPUTS_LINE_RE.exec(text)?.[1]);
const inputs = parseSlotListing(INPUTS_LINE_RE.exec(text)?.[1]);
if (!outputs.some((slot) => isLiteGraphWildcardSlotType(slot.type)))
return null;
if (!inputs.some((slot) => isLiteGraphWildcardSlotType(slot.type)))
return null;
return { outputs, inputs };
}
function slotNameKey(value) {
if (typeof value !== "string")
return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed.toLowerCase() : null;
}
function findNamedSlot(slots, ref) {
const key = slotNameKey(ref);
if (key) {
const byName = slots.find((slot) => slot.name.toLowerCase() === key);
if (byName)
return byName;
}
if (typeof ref === "number" && Number.isSafeInteger(ref)) {
return slots.find((slot) => slot.index === ref) ?? slots[ref] ?? null;
}
if (typeof ref === "string" && /^-?\d+$/.test(ref.trim())) {
const index = Number.parseInt(ref.trim(), 10);
return slots.find((slot) => slot.index === index) ?? slots[index] ?? null;
}
return null;
}
function uniqueWildcard(slots) {
const wild = slots.filter((slot) => isLiteGraphWildcardSlotType(slot.type));
return wild.length === 1 ? (wild[0] ?? null) : null;
}
export function resolveWildcardConnectRetry(refusal, args) {
const out = findNamedSlot(refusal.outputs, args.from_output) ?? uniqueWildcard(refusal.outputs);
if (!out || !isLiteGraphWildcardSlotType(out.type))
return null;
const inp = findNamedSlot(refusal.inputs, args.to_input) ?? uniqueWildcard(refusal.inputs);
if (!inp || !isLiteGraphWildcardSlotType(inp.type))
return null;
return {
from_output: out.name || out.index,
to_input: inp.name || inp.index,
via: "explicit",
resolvedType: null,
};
}
function isForceInputFlag(value) {
return value === true;
}
function parseLiveSlot(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),
};
}
function parseLiveSlots(raw) {
if (!Array.isArray(raw))
return [];
const slots = [];
for (let i = 0; i < raw.length; i++) {
const slot = parseLiveSlot(raw[i], i);
if (slot)
slots.push(slot);
}
return slots;
}
function parseLiveNode(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;
const widgetsKnown = Object.prototype.hasOwnProperty.call(rec, "widgets");
const widgetsRaw = rec.widgets;
const widgets = widgetsRaw && typeof widgetsRaw === "object" && !Array.isArray(widgetsRaw)
? widgetsRaw
: null;
return {
id,
type,
widgets: widgetsKnown ? widgets : null,
widgetsKnown,
inputs: parseLiveSlots(rec.inputs),
outputs: parseLiveSlots(rec.outputs),
};
}
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 {
// unknown-ok: compact/ids lines are not node rows.
}
}
return rows;
}
export function parseLiveWildcardNodes(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 = parseLiveNode(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;
}
function hasSerializableWidgetBinding(slot) {
const widget = slot.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 a PrimitiveNode landing here would be the #2536 forceInput-only
* STRING case (no widget to bake onto). Wildcard `*` is not STRING — leave
* that pairing eligible for retry.
*/
export function isForceInputOnlyStringSlot(slot, widgets, widgetsKnown) {
if (slot.type !== "STRING")
return false;
if (hasSerializableWidgetBinding(slot))
return false;
if (slot.forceInput)
return true;
if (!widgetsKnown)
return false;
return !Object.prototype.hasOwnProperty.call(widgets ?? {}, slot.name);
}
function findLiveSlot(slots, ref) {
const key = slotNameKey(ref);
if (key) {
const byName = slots.find((slot) => slot.name.toLowerCase() === key);
if (byName)
return byName;
}
if (typeof ref === "number" && Number.isSafeInteger(ref)) {
const bySlot = slots.find((slot) => slot.slot === ref);
if (bySlot)
return bySlot;
return slots[ref] ?? null;
}
if (typeof ref === "string" && /^-?\d+$/.test(ref.trim())) {
const index = Number.parseInt(ref.trim(), 10);
const bySlot = slots.find((slot) => slot.slot === index);
if (bySlot)
return bySlot;
return slots[index] ?? null;
}
return null;
}
function uniqueLiveWildcard(slots) {
const wild = slots.filter((slot) => isLiteGraphWildcardSlotType(slot.type));
return wild.length === 1 ? (wild[0] ?? null) : null;
}
/** First concrete (non-wildcard) type on the node — the destination typing route. */
export function inferConcreteTypeFromNode(node) {
for (const slot of [...node.outputs, ...node.inputs]) {
if (!slot.type || isLiteGraphWildcardSlotType(slot.type))
continue;
if (slot.type.toUpperCase() === "COMBO")
continue;
return slot.type;
}
return null;
}
export function resolveLiveWildcardRetry(source, target, args, diagnostic) {
const out = findLiveSlot(source.outputs, diagnostic.from_output) ??
findLiveSlot(source.outputs, args.from_output) ??
uniqueLiveWildcard(source.outputs);
const inp = findLiveSlot(target.inputs, diagnostic.to_input) ??
findLiveSlot(target.inputs, args.to_input) ??
uniqueLiveWildcard(target.inputs);
if (!out || !inp)
return null;
if (source.type === FRONTEND_PRIMITIVE_NODE_TYPE &&
isForceInputOnlyStringSlot(inp, target.widgets, target.widgetsKnown)) {
return null;
}
const outType = out.type ?? "*";
const inType = inp.type ?? "*";
if (!isTypeCompatible(outType, inType))
return null;
const outIsWild = isLiteGraphWildcardSlotType(out.type);
const inIsWild = isLiteGraphWildcardSlotType(inp.type);
if (!outIsWild && !inIsWild)
return null;
const typedIn = inIsWild ? null : inp.type;
return {
from_output: out.name || diagnostic.from_output,
to_input: inp.name || diagnostic.to_input,
via: typedIn ? "typed" : "explicit",
resolvedType: typedIn ?? inferConcreteTypeFromNode(target),
};
}
function connectCommand(args) {
return {
cmd: "graph_connect",
from_node_id: args.from_node_id,
from_output: args.from_output,
to_node_id: args.to_node_id,
to_input: args.to_input,
auto_match: args.auto_match,
};
}
function sameSlotRef(left, right) {
if (left == null && right == null)
return true;
if (typeof left === "number" && typeof right === "number")
return left === right;
const a = slotNameKey(left);
const b = slotNameKey(right);
if (a && b)
return a === b;
return left === right;
}
function retriedNote(retry) {
const via = retry.via === "typed" && retry.resolvedType
? `typed-resolution (${retry.resolvedType})`
: "explicit wildcard slots";
return (`Retried panel_connect over compatible LiteGraph wildcard-to-wildcard slots ` +
`(${via}) so a PrimitiveNode \`*\` output can land on a \`*\` input and become ` +
`typed from the destination (artokun/comfyui-mcp#2542).`);
}
function withNote(res, note) {
if (!note)
return res;
return { ...res, content: [...res.content, { type: "text", text: note }] };
}
/**
* After a failed graph_connect, retry once when the refusal is a compatible
* LiteGraph wildcard-to-wildcard pairing (`*` → `*`).
*/
export async function retryWildcardSlotConnect(args, first, call, timeoutMs) {
if (!first.isError)
return first;
const text = toolText(first);
const refusal = wildcardToWildcardRefusal(text);
if (!refusal)
return first;
const diagnosticRetry = resolveWildcardConnectRetry(refusal, args);
if (!diagnosticRetry)
return first;
const endpointIds = uniqueEndpointIds(args.from_node_id, args.to_node_id);
if (endpointIds.length === 0)
return first;
const live = await call({
cmd: "graph_query",
ids: endpointIds,
fields: "detail",
limit: endpointIds.length,
max_chars: DETAIL_MAX_CHARS,
}, DETAIL_TIMEOUT_MS);
const nodes = parseLiveWildcardNodes(parseJsonPayload(live));
let retryPlan = diagnosticRetry;
if (nodes) {
const source = nodeByConnectId(nodes, args.from_node_id);
const target = nodeByConnectId(nodes, args.to_node_id);
retryPlan =
source && target
? resolveLiveWildcardRetry(source, target, args, diagnosticRetry)
: diagnosticRetry;
}
if (!retryPlan)
return first;
if (sameSlotRef(args.from_output, retryPlan.from_output) &&
sameSlotRef(args.to_input, retryPlan.to_input) &&
args.auto_match === false) {
return first;
}
const retryArgs = {
from_node_id: args.from_node_id,
from_output: retryPlan.from_output,
to_node_id: args.to_node_id,
to_input: retryPlan.to_input,
auto_match: false,
};
const retry = await call(connectCommand(retryArgs), timeoutMs);
if (!retry.isError)
return withNote(retry, retriedNote(retryPlan));
return retry;
}
//# sourceMappingURL=wildcard-slot-connect.js.map