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),
163 lines • 6.57 kB
JavaScript
// #2544 — content-only [root-shape-mismatch] must not block a read-only inspect.
//
// After manual MiniMaxH3Director custom-widget / builder edits, the panel refuses
// graph_query even when workflow and live canvas report the SAME node count. The
// difference is CONTENT (widgets, builder UI state), not size or identity. The
// panel's own remedy names panel_open_workflow / reload, which would discard the
// unsaved live canvas. This module is the MCP-side read-only path: classify that
// refusal, recover from a live serialize/get_state when the panel will still
// serve one, and otherwise rewrite the error so the caller is not sent into
// destructive rebind. Size disagreements and [root-workflow-uuid-mismatch] stay
// fail-closed.
import { queryApiGraph } from "../services/graph-query.js";
import { isPlainObject, workflowFromSerializeReply } from "./open-identity-normalization.js";
export const CONTENT_ONLY_QUERY_NOTE = "READ-ONLY: the live canvas drifted in CONTENT from the workflow tracker " +
"(custom-widget / builder edits) while the node count stayed the same. This " +
"reply is the unsaved live canvas. Do NOT panel_open_workflow, panel_reload, " +
"or rebind — those would discard unsaved builder work.";
export const CONTENT_ONLY_QUERY_REFUSAL_NOTE = "READ-ONLY: this is content-only drift from custom-widget / builder edits " +
"(same node count, unsaved live canvas). The live canvas is still this " +
"workflow. Do NOT panel_open_workflow, panel_reload, or rebind — those would " +
"discard unsaved MiniMaxH3Director / custom-widget builder work.";
function errorText(err) {
if (typeof err === "string")
return err;
if (err instanceof Error)
return err.message;
if (isPlainObject(err) && typeof err.message === "string")
return err.message;
return String(err ?? "");
}
function resultText(res) {
return res.content?.find((c) => c.type === "text")?.text ?? "";
}
/** Anchored to the panel's bracketed token. Optional `Error: ` prefix is the
* ToolResult wrapper ctx.call applies; a quoted token later in a message is not
* this verdict. */
export function isRootShapeMismatch(err) {
const text = errorText(err);
return /^\s*(?:Error:\s*)?\[root-shape-mismatch\]/i.test(text);
}
function sizesDisagree(text) {
const m = /the workflow reports (\d+) node\(s\) but the live canvas holds (\d+)/i.exec(text);
if (!m)
return false;
return m[1] !== m[2];
}
/**
* True when the panel named [root-shape-mismatch] AND measured equal size —
* CONTENT drift, not a different graph. The structure-exact wording is the
* same class (widgets a node rewrote itself). A size disagreement is not.
*/
export function isContentOnlyRootShapeMismatch(err) {
if (!isRootShapeMismatch(err))
return false;
const text = errorText(err);
if (sizesDisagree(text))
return false;
return (/CONTENT, not its size/i.test(text) ||
/reproduces this workflow's STRUCTURE exactly/i.test(text) ||
/both the workflow and the live canvas report \d+ node\(s\)/i.test(text));
}
export function contentOnlyRootShapeReadNote(raw) {
const body = raw.replace(/^\s*(?:Error:\s*)?/i, "");
return `${body}\n\n${CONTENT_ONLY_QUERY_REFUSAL_NOTE}`;
}
function jsonPayload(res) {
if (res.isError)
return null;
const text = resultText(res);
if (!text)
return null;
try {
const parsed = JSON.parse(text);
return isPlainObject(parsed) ? parsed : null;
}
catch {
// unknown-ok: live-canvas capture was not JSON
return null;
}
}
function nodeTitle(node) {
if (typeof node.title === "string" && node.title)
return node.title;
const meta = node._meta;
if (isPlainObject(meta) && typeof meta.title === "string" && meta.title)
return meta.title;
return undefined;
}
function nodeWidgets(node) {
if (isPlainObject(node.widgets_values_named))
return node.widgets_values_named;
if (isPlainObject(node.widgets))
return node.widgets;
return {};
}
/** Named widgets from a UI serialize / graph_get_state node list, for queryApiGraph. */
export function uiGraphToApiGraph(nodes) {
const graph = {};
if (!Array.isArray(nodes))
return graph;
for (const raw of nodes) {
if (!isPlainObject(raw))
continue;
if (raw.id == null)
continue;
const id = String(raw.id);
const title = nodeTitle(raw);
graph[id] = {
class_type: typeof raw.type === "string" ? raw.type : "",
inputs: nodeWidgets(raw),
...(title ? { _meta: { title } } : {}),
};
}
return graph;
}
function nodesFromLiveCapture(payload) {
const wf = workflowFromSerializeReply(payload);
if (wf && Array.isArray(wf.nodes) && wf.nodes.length > 0)
return wf.nodes;
if (isPlainObject(payload) && Array.isArray(payload.nodes) && payload.nodes.length > 0) {
return payload.nodes;
}
return null;
}
export async function readLiveUiGraphForContentDrift(call) {
const serialized = await call({ cmd: "graph_serialize" }, 8000);
if (!serialized.isError) {
const nodes = nodesFromLiveCapture(jsonPayload(serialized));
if (nodes)
return { nodes, recovered_from: "graph_serialize" };
}
else if (isRootShapeMismatch(resultText(serialized)) && !isContentOnlyRootShapeMismatch(resultText(serialized))) {
return null;
}
const state = await call({ cmd: "graph_get_state" }, 8000);
if (state.isError)
return null;
const nodes = nodesFromLiveCapture(jsonPayload(state));
if (!nodes)
return null;
return { nodes, recovered_from: "graph_get_state" };
}
/**
* Read-only inspection when graph_query was refused for content-only drift.
* Never opens or rebinds. Returns null when the live canvas could not be read
* (including a size/identity mismatch on the fallback capture).
*/
export async function recoverContentOnlyGraphQuery(call, args = {}) {
const live = await readLiveUiGraphForContentDrift(call);
if (!live)
return null;
const api = uiGraphToApiGraph(live.nodes);
if (Object.keys(api).length === 0)
return null;
const queried = queryApiGraph(api, args);
return {
...queried,
content_drift: "content-only",
recovered_from: live.recovered_from,
note: CONTENT_ONLY_QUERY_NOTE,
};
}
//# sourceMappingURL=root-shape-mismatch.js.map