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),
195 lines • 6.8 kB
JavaScript
/**
* #2778 — `panel_connect` can refuse an exposed subgraph INT rail onto another
* INT widget input (`Scene Seed` → LocalWildcardText.seed) while the same rail
* already feeds KSampler.seed / FaceDetailer.seed.
*
* The panel reports "INT is not compatible with INT". The live rail socket still
* carries the object_info widget spec (`["INT", { min, max, step }]`) or its
* `Array.toString()` form `INT,[object Object]`. Shared slot-compat treated that
* array as a COMBO, so a scalar INT looked like a different type.
*
* Normalize both socket types, then retry once with explicit slots when they
* rank as compatible. A real MODEL→CLIP (or COMBO vs INT) refusal is left alone.
*/
import { canonicalConnectNodeId } from "./connect-live-graph.js";
import { isTypeCompatible } from "./slot-compat.js";
const CONNECT_REFUSED_RE = /connect refused/i;
const QUOTED_RE = /subgraph input "([^"]+)" \(([^)]+)\) is not compatible with node (\S+) input "([^"]+)" \(([^)]+)\)/i;
const BARE_RE = /subgraph input ([^(]+?) \(([^)]+)\) is not compatible with node (\S+) input ([^(]+?) \(([^)]+)\)/i;
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;
}
}
export function subgraphInputTypeRefusal(text) {
if (!CONNECT_REFUSED_RE.test(text))
return null;
const quoted = QUOTED_RE.exec(text);
if (quoted) {
return {
railName: quoted[1] ?? "",
railType: quoted[2] ?? "",
nodeId: quoted[3] ?? "",
inputName: quoted[4] ?? "",
inputType: quoted[5] ?? "",
};
}
const bare = BARE_RE.exec(text);
if (!bare)
return null;
return {
railName: (bare[1] ?? "").trim(),
railType: bare[2] ?? "",
nodeId: bare[3] ?? "",
inputName: (bare[4] ?? "").trim(),
inputType: bare[5] ?? "",
};
}
export function railSocketTypesCompatible(output, input) {
return isTypeCompatible(output, input);
}
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 lines are not node rows */
}
}
return rows;
}
function liveSlotType(slots, name) {
if (!Array.isArray(slots))
return undefined;
const want = name.toLowerCase();
for (const entry of slots) {
const rec = asRecord(entry);
if (!rec)
continue;
if (typeof rec.name === "string" && rec.name.toLowerCase() === want)
return rec.type;
}
return undefined;
}
function liveTargetInputType(payload, nodeId, inputName) {
const rec = asRecord(payload);
if (!rec)
return undefined;
const want = canonicalConnectNodeId(nodeId);
if (!want)
return undefined;
for (const row of collectDetailRows(rec)) {
const node = asRecord(row);
if (!node || canonicalConnectNodeId(node.id) !== want)
continue;
return liveSlotType(node.inputs, inputName);
}
return undefined;
}
function liveRailOutputType(payload, railName) {
const rec = asRecord(payload);
const rails = asRecord(rec?.rails);
const input = asRecord(rails?.input);
return liveSlotType(input?.provides_outputs, railName);
}
export function resolveRailSlotRetry(refusal, args, livePayload) {
let railType = refusal.railType;
let inputType = refusal.inputType;
if (livePayload !== undefined) {
const liveRail = liveRailOutputType(livePayload, refusal.railName);
const liveInput = liveTargetInputType(livePayload, args.to_node_id, refusal.inputName);
if (liveRail !== undefined)
railType = liveRail;
if (liveInput !== undefined)
inputType = liveInput;
}
if (!railSocketTypesCompatible(railType, inputType))
return null;
return {
from_node_id: args.from_node_id,
from_output: args.from_output ?? refusal.railName,
to_node_id: args.to_node_id,
to_input: args.to_input ?? refusal.inputName,
auto_match: false,
};
}
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 retriedNote() {
return (`Retried panel_connect after normalizing the exposed subgraph rail socket type ` +
`so INT widget constraints are not a COMBO (artokun/comfyui-mcp#2778).`);
}
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 subgraph input
* rail whose socket type is compatible with the target input once widget-spec
* arrays are normalized to the declared scalar (INT→INT).
*/
export async function retryRailSlotConnect(args, first, call, timeoutMs) {
if (!first.isError)
return first;
const refusal = subgraphInputTypeRefusal(toolText(first));
if (!refusal)
return first;
if (!railSocketTypesCompatible(refusal.railType, refusal.inputType))
return first;
const toId = canonicalConnectNodeId(args.to_node_id);
let livePayload;
if (toId) {
const live = await call({
cmd: "graph_query",
ids: [args.to_node_id ?? toId],
fields: "detail",
limit: 1,
max_chars: DETAIL_MAX_CHARS,
}, DETAIL_TIMEOUT_MS);
livePayload = parseJsonPayload(live) ?? undefined;
}
const retryArgs = resolveRailSlotRetry(refusal, args, livePayload);
if (!retryArgs)
return first;
const retry = await call(connectCommand(retryArgs), timeoutMs);
if (!retry.isError)
return withNote(retry, retriedNote());
return retry;
}
//# sourceMappingURL=rail-slot-connect.js.map