comfyui-mcp 0.52.17 → 0.52.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/orchestrator/panel-tools.js +96 -9
- package/dist/orchestrator/panel-tools.js.map +1 -1
- package/dist/services/panel-sync.js +9 -0
- package/dist/services/panel-sync.js.map +1 -1
- package/dist/services/ui-bridge.js +20 -0
- package/dist/services/ui-bridge.js.map +1 -1
- package/dist/tools/vocabulary.js +1 -1
- package/docs/design/panel-surface.txt +2 -0
- package/package.json +1 -1
|
@@ -40,8 +40,9 @@ import { isPanelAnsweredError } from "../services/panel-answered.js";
|
|
|
40
40
|
import { isPreExecutorRefusal } from "../services/panel-refusal.js";
|
|
41
41
|
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
42
42
|
import { parse as parseYaml } from "yaml";
|
|
43
|
-
import { SEMVER_RE } from "../services/ui-bridge.js";
|
|
43
|
+
import { requiredPanelVersion, SEMVER_RE } from "../services/ui-bridge.js";
|
|
44
44
|
import { compareSemver } from "../services/self-update.js";
|
|
45
|
+
import { describeInstallPanelAction } from "../services/panel-recovery.js";
|
|
45
46
|
import { primePanelBase, verifiedPanelDiskVersion, } from "../services/panel-workspace.js";
|
|
46
47
|
import { conversationOfScopeAddress, isScopeAddress, shortTabId } from "../services/session-scope.js";
|
|
47
48
|
import { NODE_ID_MESSAGE, NODE_ID_PATTERN, normalizeNodeId } from "./node-id.js";
|
|
@@ -760,6 +761,8 @@ const MUTATING_GRAPH_EDIT_CMDS = new Set([
|
|
|
760
761
|
"graph_subgraph_group",
|
|
761
762
|
"graph_expose_subgraph_input",
|
|
762
763
|
"graph_expose_subgraph_output",
|
|
764
|
+
"graph_unexpose_subgraph_input",
|
|
765
|
+
"graph_unexpose_subgraph_output",
|
|
763
766
|
"graph_promote_widget",
|
|
764
767
|
"graph_move_rail",
|
|
765
768
|
"graph_paste_nodes",
|
|
@@ -2549,6 +2552,80 @@ function withLoraManagerAutocompleteNote(res) {
|
|
|
2549
2552
|
return res;
|
|
2550
2553
|
return appendToolResultText(res, LORA_MANAGER_AUTOCOMPLETE_NOTE);
|
|
2551
2554
|
}
|
|
2555
|
+
/**
|
|
2556
|
+
* Types the CURRENT panel's add-node guard allowlists (comfyui-mcp-panel
|
|
2557
|
+
* `web/js/lib/node-resolve.js` `FRONTEND_ONLY_NODE_TYPES`). An older panel
|
|
2558
|
+
* without that allowlist refuses them as "the ComfyUI backend does not provide
|
|
2559
|
+
* it" and points at unrelated failed-import packs (#1828).
|
|
2560
|
+
*
|
|
2561
|
+
* Kept as a copy of the panel's list rather than the orchestrator's
|
|
2562
|
+
* `FRONTEND_ONLY_NODE_TYPES` (workflow-converter): that set is the runtime
|
|
2563
|
+
* classifier's skip list and deliberately omits third-party names. Adding
|
|
2564
|
+
* rgthree types there would change check_runtime, which is not this bug.
|
|
2565
|
+
*/
|
|
2566
|
+
const PANEL_ADD_NODE_FRONTEND_ONLY_TYPES = new Set([
|
|
2567
|
+
"Note",
|
|
2568
|
+
"MarkdownNote",
|
|
2569
|
+
"Reroute",
|
|
2570
|
+
"PrimitiveNode",
|
|
2571
|
+
"Fast Bypasser (rgthree)",
|
|
2572
|
+
"Fast Muter (rgthree)",
|
|
2573
|
+
"Fast Groups Bypasser (rgthree)",
|
|
2574
|
+
"Fast Groups Muter (rgthree)",
|
|
2575
|
+
"Label (rgthree)",
|
|
2576
|
+
"Reroute (rgthree)",
|
|
2577
|
+
"Node Collector (rgthree)",
|
|
2578
|
+
"SetNode",
|
|
2579
|
+
"GetNode",
|
|
2580
|
+
]);
|
|
2581
|
+
function isUnknownBackendNodeRefusal(res) {
|
|
2582
|
+
if (!res.isError)
|
|
2583
|
+
return false;
|
|
2584
|
+
return /Unknown node type .+ — the ComfyUI backend does not provide it/i.test(textOfToolResult(res));
|
|
2585
|
+
}
|
|
2586
|
+
function tabAdvertisedPanelVersion(ctx) {
|
|
2587
|
+
const fn = ctx.bridge.advertisedPanelVersion;
|
|
2588
|
+
if (typeof fn !== "function")
|
|
2589
|
+
return undefined;
|
|
2590
|
+
let reading;
|
|
2591
|
+
try {
|
|
2592
|
+
reading = fn.call(ctx.bridge, ctx.tabId);
|
|
2593
|
+
}
|
|
2594
|
+
catch {
|
|
2595
|
+
return undefined;
|
|
2596
|
+
}
|
|
2597
|
+
const v = reading?.version;
|
|
2598
|
+
return typeof v === "string" && SEMVER_RE.test(v.trim()) ? v.trim() : undefined;
|
|
2599
|
+
}
|
|
2600
|
+
/**
|
|
2601
|
+
* #1828 — an allowlisted frontend-only type refused as "backend does not
|
|
2602
|
+
* provide it" is version skew when the tab's advertised panel is below this
|
|
2603
|
+
* orchestrator's floor, not a missing pack. Keep the panel's refusal and name
|
|
2604
|
+
* the pack update + hard-refresh; a restart alone leaves cached JS running the
|
|
2605
|
+
* old guard.
|
|
2606
|
+
*/
|
|
2607
|
+
function withFrontendOnlyPanelSkewNote(res, classType, ctx) {
|
|
2608
|
+
if (typeof classType !== "string")
|
|
2609
|
+
return res;
|
|
2610
|
+
if (!isUnknownBackendNodeRefusal(res))
|
|
2611
|
+
return res;
|
|
2612
|
+
if (!PANEL_ADD_NODE_FRONTEND_ONLY_TYPES.has(classType))
|
|
2613
|
+
return res;
|
|
2614
|
+
const advertised = tabAdvertisedPanelVersion(ctx);
|
|
2615
|
+
if (!advertised)
|
|
2616
|
+
return res;
|
|
2617
|
+
const required = requiredPanelVersion();
|
|
2618
|
+
if (compareSemver(advertised, required) >= 0)
|
|
2619
|
+
return res;
|
|
2620
|
+
const update = describeInstallPanelAction("update", "update the panel pack on the ComfyUI host");
|
|
2621
|
+
return appendToolResultText(res, `\n\nThis is NOT a missing backend pack. "${classType}" is a frontend-only type ` +
|
|
2622
|
+
`that current panels (${required}+) allowlist, so its absence from /object_info is ` +
|
|
2623
|
+
`expected. This tab announced panel ${advertised}, which is below that floor — ` +
|
|
2624
|
+
`the add-node guard in this tab's JavaScript does not have the allowlist. ` +
|
|
2625
|
+
`${update}, restart ComfyUI, then HARD-REFRESH the ComfyUI browser tab ` +
|
|
2626
|
+
`(Ctrl+Shift+R, or Cmd+Shift+R on macOS). A restart alone leaves the tab running ` +
|
|
2627
|
+
`cached old JS, which is why this error can persist after an update.`);
|
|
2628
|
+
}
|
|
2552
2629
|
function appendToolResultText(res, extra) {
|
|
2553
2630
|
const idx = res?.content?.findIndex((c) => c.type === "text") ?? -1;
|
|
2554
2631
|
const block = idx >= 0 ? res.content[idx] : undefined;
|
|
@@ -8924,6 +9001,8 @@ export const RETRY_TOKEN_CMD_BY_TOOL = {
|
|
|
8924
9001
|
panel_promote_widget: "graph_promote_widget",
|
|
8925
9002
|
panel_expose_subgraph_output: "graph_expose_subgraph_output",
|
|
8926
9003
|
panel_expose_subgraph_input: "graph_expose_subgraph_input",
|
|
9004
|
+
panel_unexpose_subgraph_output: "graph_unexpose_subgraph_output",
|
|
9005
|
+
panel_unexpose_subgraph_input: "graph_unexpose_subgraph_input",
|
|
8927
9006
|
panel_unpack_subgraph: "graph_unpack_subgraph",
|
|
8928
9007
|
panel_update_node: "graph_update_node",
|
|
8929
9008
|
};
|
|
@@ -9342,8 +9421,9 @@ export function buildPanelToolDefs() {
|
|
|
9342
9421
|
// install. Give it the bounded refresh ack budget.
|
|
9343
9422
|
const add = () => ctx.call({ cmd: "graph_add_node", class_type: args.class_type, pos: args.pos, title: args.title }, OBJECT_INFO_REFRESH_ACK_TIMEOUT_MS);
|
|
9344
9423
|
const first = await add();
|
|
9345
|
-
if (!isStaleNodeSchemaRefusal(first))
|
|
9346
|
-
return withLoraManagerAutocompleteNote(first);
|
|
9424
|
+
if (!isStaleNodeSchemaRefusal(first)) {
|
|
9425
|
+
return withFrontendOnlyPanelSkewNote(withLoraManagerAutocompleteNote(first), args.class_type, ctx);
|
|
9426
|
+
}
|
|
9347
9427
|
// #1329 — DO THE REFRESH THE REFUSAL ASKS FOR, instead of billing the caller.
|
|
9348
9428
|
//
|
|
9349
9429
|
// The reporter uploaded two images, and both LoadImage adds were refused with
|
|
@@ -9361,19 +9441,20 @@ export function buildPanelToolDefs() {
|
|
|
9361
9441
|
// The refusal is the better error: it names what is wrong with the SCHEMA and
|
|
9362
9442
|
// what clears it. Carry the refresh failure alongside so the caller knows the
|
|
9363
9443
|
// automatic attempt happened and why it did not help.
|
|
9364
|
-
return withLoraManagerAutocompleteNote(appendToolResultText(first, `\n\n(Tried to clear this automatically: panel_refresh_nodes was dispatched and FAILED, ` +
|
|
9444
|
+
return withFrontendOnlyPanelSkewNote(withLoraManagerAutocompleteNote(appendToolResultText(first, `\n\n(Tried to clear this automatically: panel_refresh_nodes was dispatched and FAILED, ` +
|
|
9365
9445
|
`so the schema is unchanged and retrying the add will refuse again. ` +
|
|
9366
|
-
`${textOfToolResult(refreshed)})`));
|
|
9446
|
+
`${textOfToolResult(refreshed)})`)), args.class_type, ctx);
|
|
9367
9447
|
}
|
|
9368
9448
|
const second = await add();
|
|
9369
|
-
if (!isStaleNodeSchemaRefusal(second))
|
|
9370
|
-
return withLoraManagerAutocompleteNote(second);
|
|
9449
|
+
if (!isStaleNodeSchemaRefusal(second)) {
|
|
9450
|
+
return withFrontendOnlyPanelSkewNote(withLoraManagerAutocompleteNote(second), args.class_type, ctx);
|
|
9451
|
+
}
|
|
9371
9452
|
// Still stale after a successful refresh: report THAT, because it means the
|
|
9372
9453
|
// remedy the refusal prescribes does not fix this instance and a caller
|
|
9373
9454
|
// following it by hand would loop.
|
|
9374
|
-
return withLoraManagerAutocompleteNote(appendToolResultText(second, `\n\n(This was already retried ONCE automatically: panel_refresh_nodes reported success ` +
|
|
9455
|
+
return withFrontendOnlyPanelSkewNote(withLoraManagerAutocompleteNote(appendToolResultText(second, `\n\n(This was already retried ONCE automatically: panel_refresh_nodes reported success ` +
|
|
9375
9456
|
`and the add still refuses, so repeating panel_refresh_nodes will not clear it. ` +
|
|
9376
|
-
`Reload the ComfyUI browser tab, which rebuilds the page's node registry from scratch.)`));
|
|
9457
|
+
`Reload the ComfyUI browser tab, which rebuilds the page's node registry from scratch.)`)), args.class_type, ctx);
|
|
9377
9458
|
}),
|
|
9378
9459
|
def("panel_remove_node", "Remove a node (and its connections) from the user's open graph by id. Undoable with Ctrl+Z.", { node_id: nodeId().describe("Node id from panel_graph_outline / panel_query_graph.") }, async (args, ctx) => ctx.call({ cmd: "graph_remove_node", node_id: args.node_id })),
|
|
9379
9460
|
def("panel_clear", "Remove EVERY node from the user's open graph — only for an explicit 'clear/reset the canvas'. Just CALL THIS DIRECTLY when they ask to clear: the tool itself pops a confirm card and only wipes on a yes (don't ask separately first). The wipe is a single Ctrl+Z undo. NEVER use this for a 'new workflow' — that's panel_new_workflow (a new tab, leaves this graph intact).", {}, async (_args, ctx) => {
|
|
@@ -12096,6 +12177,12 @@ CHECKED FOR YOU: the graph read this message prescribes was just run, and it ` +
|
|
|
12096
12177
|
to_input: args.to_input,
|
|
12097
12178
|
name: args.name,
|
|
12098
12179
|
}, 15000)),
|
|
12180
|
+
def("panel_unexpose_subgraph_output", "REMOVE a subgraph's OUTPUT boundary slot — UN-expose it (the inverse of panel_expose_subgraph_output), so the PARENT graph's subgraph node loses that output slot. You MUST be INSIDE the subgraph first (panel_enter_subgraph). Identify the slot by the string `name` only — the boundary slot name exactly as panel_query_graph lists it under `rails.output.accepts_inputs`. A rail_node_id string such as \"-20\" is forwarded to the panel, which REFUSES it: that is the synthetic id of the whole rail, not of a slot on it. DESTRUCTIVE: the interior wire feeding the slot and any parent-graph wires on host subgraph nodes' matching slot are dropped with it — the reply's `removed.interior_links_dropped` / `removed.host_links_dropped` say how many went. An unknown name refuses and lists the slots that DO exist; nothing is removed on a refusal. Undoable with Ctrl+Z.", {
|
|
12181
|
+
name: z.string().describe("Boundary output slot name (e.g. 'IMAGE') exactly as panel_query_graph lists it under rails.output.accepts_inputs — NOT a rail_node_id."),
|
|
12182
|
+
}, async (args, ctx) => ctx.call({ cmd: "graph_unexpose_subgraph_output", name: args.name }, 15000)),
|
|
12183
|
+
def("panel_unexpose_subgraph_input", "REMOVE a subgraph's INPUT boundary slot — UN-expose it (the inverse of panel_expose_subgraph_input), so the PARENT graph's subgraph node loses that input slot. You MUST be INSIDE the subgraph first (panel_enter_subgraph). Identify the slot by the string `name` only — the boundary slot name exactly as panel_query_graph lists it under `rails.input.provides_outputs`. A rail_node_id string such as \"-10\" is forwarded to the panel, which REFUSES it: that is the synthetic id of the whole rail, not of a slot on it. DESTRUCTIVE: the interior wire the slot fed and any parent-graph wires on host subgraph nodes' matching slot are dropped with it — the reply's `removed.interior_links_dropped` / `removed.host_links_dropped` say how many went. An unknown name refuses and lists the slots that DO exist; nothing is removed on a refusal. Undoable with Ctrl+Z.", {
|
|
12184
|
+
name: z.string().describe("Boundary input slot name (e.g. 'model') exactly as panel_query_graph lists it under rails.input.provides_outputs — NOT a rail_node_id."),
|
|
12185
|
+
}, async (args, ctx) => ctx.call({ cmd: "graph_unexpose_subgraph_input", name: args.name }, 15000)),
|
|
12099
12186
|
def("panel_unpack_subgraph", "EXPAND / DISSOLVE a subgraph node on the user's open graph — inline its interior nodes back into the PARENT graph, rewire all external links to those now-inlined nodes, and remove the subgraph wrapper. This is the frontend's \"Unpack Subgraph\" (litegraph LGraph.unpackSubgraph) and the exact INVERSE of panel_create_subgraph. Use it to flatten a stage that was over-nested, or to edit interior nodes directly at the parent level. The interior nodes reappear on the parent canvas with their connections preserved. Undoable with Ctrl+Z.", { node_id: nodeId().describe("Subgraph node id to unpack/dissolve (is_subgraph=true, from panel_graph_outline / panel_query_graph).") }, async (args, ctx) => ctx.call({ cmd: "graph_unpack_subgraph", node_id: args.node_id }, 15000)),
|
|
12100
12187
|
def("panel_search_nodes", "Search installable custom-node packs via the user's BUILT-IN ComfyUI Manager (the same source the Manager UI uses). Returns matching packs {id, title, description}. Use the `id` with panel_install_node. Prefer this over the headless search_custom_nodes tool — it works against the user's actual (Desktop) Manager. If Manager's cache mappings endpoint returns HTTP 5xx, this retries remote/local and still searches; a remaining 5xx is a Manager outage, not proof the pack is missing.", { query: z.string().describe("Search text, e.g. 'kjnodes', 'controlnet', 'ipadapter'."), limit: z.number().int().min(1).max(40).optional().describe("Max results to return, 1-40 (default 15). Requests above 40 are refused; the panel also clamps to 40 and discloses limit_cap.") }, async (args, ctx) => {
|
|
12101
12188
|
// #1669 — the panel asks getmappings?mode=cache and used to fail the
|