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),
223 lines • 7.87 kB
JavaScript
/**
* #2502 — `panel_connect` can refuse a node that the immediately preceding
* `panel_graph_outline` listed on the same live root graph.
*
* After a completed canvas render the mutation executor can still be bound to a
* stale graph object while reads (`graph_outline` / `graph_query`) already walk
* the live snapshot. The panel then reports "No node with id N in the current
* graph" even though the identity string and root scope match the outline.
*
* Re-resolve the live graph (the same query surface outline uses) before treating
* that refusal as final, and retry the connect once when both endpoints are
* present. A node that truly is absent, or that lives in another subgraph, is
* left alone.
*/
import { NODE_ID_PATTERN, normalizeNodeId } from "../orchestrator/node-id.js";
const MISSING_NODE_RE = /No node with id (\S+) in the current graph/i;
const INSIDE_SUBGRAPH_RE = /lives INSIDE a subgraph/i;
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 canonicalConnectNodeId(value) {
if (typeof value === "number") {
return Number.isSafeInteger(value) ? String(value) : null;
}
if (typeof value !== "string" || value.length === 0)
return null;
if (!NODE_ID_PATTERN.test(value))
return null;
return String(normalizeNodeId(value));
}
export function sameConnectNodeId(left, right) {
const a = canonicalConnectNodeId(left);
const b = canonicalConnectNodeId(right);
return a !== null && a === b;
}
/** True when the panel refused a node as missing from the current graph, and
* did not locate it inside another subgraph. */
export function isMissingNodeInCurrentGraphRefusal(text) {
return MISSING_NODE_RE.test(text) && !INSIDE_SUBGRAPH_RE.test(text);
}
export function missingNodeIdInCurrentGraph(text) {
if (!isMissingNodeInCurrentGraphRefusal(text))
return null;
const match = MISSING_NODE_RE.exec(text);
const raw = match?.[1];
return raw ? canonicalConnectNodeId(raw) : null;
}
export function isUsableLiveGraphBinding(value) {
const rec = asRecord(value);
if (!rec)
return false;
if (rec.scope !== "root" && rec.scope !== "subgraph")
return false;
if (rec.graphIdentity !== undefined) {
return (typeof rec.graphIdentity === "string" &&
rec.graphIdentity.length > 0 &&
rec.graphIdentity.length <= 256);
}
return true;
}
export function parseLiveGraphBinding(value) {
const rec = asRecord(value);
if (!rec)
return null;
if (rec.scope !== "root" && rec.scope !== "subgraph")
return null;
const graphIdentity = rec.graph_identity;
if (graphIdentity !== undefined &&
(typeof graphIdentity !== "string" || graphIdentity.length === 0 || graphIdentity.length > 256)) {
return null;
}
const binding = { scope: rec.scope };
if (typeof graphIdentity === "string")
binding.graphIdentity = graphIdentity;
return isUsableLiveGraphBinding(binding) ? binding : null;
}
function collectLiveNodeIds(payload) {
const rec = asRecord(payload);
if (!rec)
return null;
if (Object.prototype.hasOwnProperty.call(rec, "truncated") && rec.truncated !== false) {
return null;
}
const ids = new Set();
const add = (value) => {
const id = canonicalConnectNodeId(value);
if (id)
ids.add(id);
};
if (Array.isArray(rec.nodes)) {
for (const entry of rec.nodes) {
const node = asRecord(entry);
if (node)
add(node.id);
else
add(entry);
}
}
if (Array.isArray(rec.ids)) {
for (const entry of rec.ids)
add(entry);
}
if (typeof rec.text === "string") {
for (const line of rec.text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed)
continue;
if (/^\d+ match\(es\) of \d+ in scope/.test(trimmed))
continue;
const compact = trimmed.match(/^#(\S+)\s+\S+/);
if (compact) {
add(compact[1]);
continue;
}
for (const part of trimmed.split(/[,\s]+/))
add(part);
}
}
return ids;
}
/** True when the live graph_query (same snapshot outline uses) lists `nodeId`. */
export function liveGraphHasNodeId(payload, nodeId) {
const want = canonicalConnectNodeId(nodeId);
if (!want)
return false;
const ids = collectLiveNodeIds(payload);
return ids !== null && ids.has(want);
}
export function liveGraphHasConnectEndpoints(payload, fromNodeId, toNodeId) {
return liveGraphHasNodeId(payload, fromNodeId) && liveGraphHasNodeId(payload, toNodeId);
}
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 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 withNote(res, note) {
if (!note)
return res;
return { ...res, content: [...res.content, { type: "text", text: note }] };
}
function retriedNote(missingId) {
return (`Re-resolved node ${missingId} on the live graph (same binding as panel_graph_outline) ` +
`and retried panel_connect (artokun/comfyui-mcp#2502).`);
}
/**
* Dispatch graph_connect. On a current-graph missing-node refusal, look the
* endpoints up on the live graph_query snapshot and retry once when both exist.
*/
export async function retryConnectAgainstLiveGraph(args, call, timeoutMs) {
const cmd = connectCommand(args);
const first = await call(cmd, timeoutMs);
if (!first.isError)
return first;
const text = toolText(first);
const missingId = missingNodeIdInCurrentGraph(text);
if (!missingId)
return first;
if (!sameConnectNodeId(missingId, args.from_node_id) &&
!sameConnectNodeId(missingId, args.to_node_id)) {
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: "ids",
limit: endpointIds.length,
max_chars: 4000,
}, 8000);
const payload = parseJsonPayload(live);
const rec = asRecord(payload);
if (!rec)
return first;
if (rec.viewing !== undefined && !parseLiveGraphBinding(rec.viewing))
return first;
if (!liveGraphHasConnectEndpoints(payload, args.from_node_id, args.to_node_id)) {
return first;
}
const retry = await call(cmd, timeoutMs);
if (!retry.isError)
return withNote(retry, retriedNote(missingId));
return retry;
}
//# sourceMappingURL=connect-live-graph.js.map