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.
@@ -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