comfyui-mcp 0.48.26 → 0.48.28

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.
@@ -222,6 +222,17 @@ function sleep(ms) {
222
222
  // settle. Mutating graph edits (add_node/connect/set_widget/…) are deliberately
223
223
  // EXCLUDED — re-issuing them could double-apply — so they keep surfacing the
224
224
  // bridge's honest OUTCOME-UNKNOWN error.
225
+ // #599: commands whose FRONTEND handler intentionally awaits a fresh /object_info
226
+ // re-register before it can reply — the refresh-before-validate path in
227
+ // graph_set_widget (#338/#458) and graph_add_node (#289/#458), and the explicit
228
+ // forced node-def refresh in refresh_nodes (#608). On a large install with many
229
+ // custom-node packs a legitimate /object_info fetch can take longer than the
230
+ // bridge's 6000 ms DEFAULT ack window, so the panel replies late and the tool
231
+ // returns a FALSE "tab did not reply" timeout even though the write is valid and
232
+ // in progress. Give these a larger BOUNDED ack budget so a slow-but-valid refresh
233
+ // is not mistaken for a dead tab — still capped (never Infinity) so a genuinely
234
+ // frozen/backgrounded tab fails in bounded time instead of hanging forever.
235
+ const OBJECT_INFO_REFRESH_ACK_TIMEOUT_MS = 30_000;
225
236
  const RETRY_SAFE_CMDS = new Set([
226
237
  // Idempotent reads (mirror UiBridge.READONLY_CMDS + list/status probes).
227
238
  "graph_serialize",
@@ -237,6 +248,10 @@ const RETRY_SAFE_CMDS = new Set([
237
248
  "node_queue_status",
238
249
  // Idempotent full-replace UI state — re-sending the same list is a no-op (#481).
239
250
  "set_todo",
251
+ // #608: a forced /object_info re-register + combo refresh. Non-destructive and
252
+ // idempotent (re-running just re-fetches the current defs), so a dropped
253
+ // transport can safely re-issue it once.
254
+ "refresh_nodes",
240
255
  ]);
241
256
  /** A command whose result is unchanged by being re-issued after a reconnect —
242
257
  * so it is safe to transparently retry once when the transport dropped. */
@@ -244,6 +259,57 @@ function isRetrySafeCmd(cmd) {
244
259
  const name = typeof cmd.cmd === "string" ? cmd.cmd : "";
245
260
  return RETRY_SAFE_CMDS.has(name);
246
261
  }
262
+ // Graph-EDIT mutations that CHANGE the user's canvas (undoable edits). These are
263
+ // the #436 bug surface: a real side effect the bridge will NOT auto-retry, so —
264
+ // unlike a read — such a command can be neither parked mid-command nor retried
265
+ // once, and firing it into the post-restart "Connected: none" window fails with
266
+ // "no connected tab". It must await a stable binding BEFORE dispatch.
267
+ //
268
+ // This is an EXPLICIT ALLOWLIST, deliberately NOT "everything not read-only":
269
+ // several genuine reads/probes/views that flow through ctx.call are absent from
270
+ // BRIDGE_READONLY_CMDS (e.g. graph_list_subgraphs, training_get_state,
271
+ // graph_canvas, graph_screenshot), so an exclusion rule would wrongly make THOSE
272
+ // wait out the reconnect budget. Under-inclusion here is at worst an unfixed edge
273
+ // (a command keeps today's behavior); over-inclusion would regress a read — so we
274
+ // list only commands that unambiguously mutate the graph. Keep in sync when new
275
+ // graph-edit tools are added (mirrors the RETRY_SAFE_CMDS maintenance model).
276
+ const MUTATING_GRAPH_EDIT_CMDS = new Set([
277
+ "graph_add_node",
278
+ "graph_remove_node",
279
+ "graph_clear",
280
+ "graph_connect",
281
+ "graph_disconnect",
282
+ "graph_set_widget",
283
+ "graph_move_node",
284
+ "graph_resize_node",
285
+ "graph_set_title",
286
+ "graph_set_node_mode",
287
+ "graph_set_node_color",
288
+ "graph_set_node_collapsed",
289
+ "graph_update_node",
290
+ "graph_create_group",
291
+ "graph_edit_group",
292
+ "graph_remove_group",
293
+ "graph_move_group",
294
+ "graph_create_subgraph",
295
+ "graph_add_subgraph",
296
+ "graph_save_subgraph",
297
+ "graph_unpack_subgraph",
298
+ "graph_subgraph_group",
299
+ "graph_expose_subgraph_input",
300
+ "graph_expose_subgraph_output",
301
+ "graph_promote_widget",
302
+ "graph_move_rail",
303
+ "graph_paste_nodes",
304
+ "graph_auto_layout",
305
+ "graph_load",
306
+ ]);
307
+ /** A MUTATING graph edit that must await a stable tab binding before dispatch so
308
+ * it never fires into the post-restart "Connected: none" window (#436). */
309
+ function isMutatingGraphCmd(cmd) {
310
+ const name = typeof cmd.cmd === "string" ? cmd.cmd : "";
311
+ return MUTATING_GRAPH_EDIT_CMDS.has(name);
312
+ }
247
313
  /** True when an error is a TRANSIENT transport/reconnect drop (the tab went away
248
314
  * or was replaced), NOT a genuine command error or a live-but-frozen reply
249
315
  * timeout. Deliberately EXCLUDES "did not reply within N ms" (a backgrounded/
@@ -1525,6 +1591,21 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
1525
1591
  };
1526
1592
  const call = async (cmd, timeoutMs) => {
1527
1593
  try {
1594
+ // #436: a MUTATING graph edit must not fire into the "Connected: none"
1595
+ // window a ComfyUI restart/reload opens. A read survives that window (it is
1596
+ // parked mid-command and is retry-safe), but a mutating edit is NEITHER — so
1597
+ // panel_graph_outline succeeds while the very next panel_add_node hits
1598
+ // resolveTarget's momentarily-empty registry and fails with
1599
+ // "no connected tab … Connected: none" (the flap). Await a stable binding
1600
+ // BEFORE dispatch — nothing is sent during the wait, so there is no
1601
+ // double-apply risk — exactly as workflow_open/save already do. This returns
1602
+ // INSTANTLY for a healthy session and only waits in the zero-tab window; the
1603
+ // read path (parking + retry-once below) is left exactly as-is. A wait that
1604
+ // times out unreached still falls through to sendRouted, whose authoritative
1605
+ // dispatched:false surfaces the actionable "nothing applied — rebind" message.
1606
+ if (isMutatingGraphCmd(cmd)) {
1607
+ await awaitReachable();
1608
+ }
1528
1609
  ensureReachable();
1529
1610
  return ok(await sendRouted(cmd, timeoutMs));
1530
1611
  }
@@ -1552,6 +1633,33 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
1552
1633
  return fail(err2);
1553
1634
  }
1554
1635
  }
1636
+ // #442 defect 4: a MUTATING command (deliberately excluded from RETRY_SAFE_CMDS)
1637
+ // that the bridge refused BEFORE any socket write surfaced the bare routing error
1638
+ // ("no connected tab … Connected: none") with no recovery path — whereas a
1639
+ // retry-safe read like graph_get_errors, via the branch above, names the rebind.
1640
+ // That asymmetry made a brief post-reconnect read/edit-channel disagreement look
1641
+ // like a dead agent (panel_list_workflows kept answering while panel_set_widget
1642
+ // failed, in a multi-tab session the strict-single silent auto-heal won't touch).
1643
+ // We must NOT retry the mutating command (double-apply risk), but the bridge's
1644
+ // AUTHORITATIVE typed flag proves nothing was dispatched (dispatchOutcomeOf ===
1645
+ // false) — so it is safe to state nothing was applied and name the rebind recovery,
1646
+ // preserving the raw cause. Keying on the TYPED flag (not error text) means a
1647
+ // POST-dispatch executor ok:false reply that merely quotes "no connected tab" is
1648
+ // never mis-wrapped as "nothing applied".
1649
+ if (dispatchOutcomeOf(err) === false) {
1650
+ const name = typeof cmd.cmd === "string" ? cmd.cmd : "panel command";
1651
+ // Neutral wording: a dispatched:false flag proves only that the command was NOT
1652
+ // dispatched — it can be a routing refusal (the bound tab is gone / reconnecting),
1653
+ // an ambiguous-or-multiple-tab resolver refusal (other tabs DO exist), or a socket
1654
+ // write failure. All share the same TRUE facts (nothing applied) and the same
1655
+ // recovery (rebind onto the tab that's live now); the raw cause carries the
1656
+ // specifics. Do NOT overstate "disconnected", which is false for the ambiguity case.
1657
+ return fail(`${name} could not be dispatched to this session's panel tab — nothing was applied. ` +
1658
+ `The tab may be disconnected, still reconnecting after a restart/reload, or the ` +
1659
+ `session's binding is stale (e.g. another workflow tab is now active). Retry in a ` +
1660
+ `moment, or rebind with panel_set_workflow_target({mode:"current"}) to follow the ` +
1661
+ `tab that's live now. (${err instanceof Error ? err.message : String(err)})`);
1662
+ }
1555
1663
  return fail(err);
1556
1664
  }
1557
1665
  };
@@ -2130,7 +2238,12 @@ export function buildPanelToolDefs() {
2130
2238
  .optional()
2131
2239
  .describe("Canvas [x, y] (two numbers). Auto-placed beside existing nodes when omitted."),
2132
2240
  title: z.string().optional().describe("Optional custom node title."),
2133
- }, async (args, ctx) => ctx.call({ cmd: "graph_add_node", class_type: args.class_type, pos: args.pos, title: args.title })),
2241
+ }, async (args, ctx) =>
2242
+ // #599: the frontend gates the add on a FRESH /object_info (assertAddNode-
2243
+ // ResolvableRefreshing) so an uninstalled class can't be added as a
2244
+ // placeholder — that fetch can outlast the 6000 ms default on a large
2245
+ // install. Give it the bounded refresh ack budget.
2246
+ ctx.call({ cmd: "graph_add_node", class_type: args.class_type, pos: args.pos, title: args.title }, OBJECT_INFO_REFRESH_ACK_TIMEOUT_MS)),
2134
2247
  def("panel_remove_node", "Remove a node (and its connections) from the user's open graph by id. Undoable with Ctrl+Z.", { node_id: z.number().int().describe("Node id from panel_graph_outline / panel_query_graph.") }, async (args, ctx) => ctx.call({ cmd: "graph_remove_node", node_id: args.node_id })),
2135
2248
  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) => {
2136
2249
  const decision = await ctx.confirm("Clear the canvas? This removes every node from the open workflow. (One Ctrl+Z undoes it.)", "Clear canvas");
@@ -2360,7 +2473,12 @@ export function buildPanelToolDefs() {
2360
2473
  if (value === undefined) {
2361
2474
  return fail("panel_set_widget needs a `value`. To set an empty string, pass `clear: true` (some clients drop an empty-string `value`).");
2362
2475
  }
2363
- return ctx.call({ cmd: "graph_set_widget", node_id: args.node_id, widget: args.widget, value });
2476
+ // #599: the frontend runs refresh-before-validate here (pulls a fresh
2477
+ // /object_info so a just-staged/-downloaded/-installed value is accepted on
2478
+ // a single revalidation, #338/#458) — that authoritative fetch can outlast
2479
+ // the 6000 ms default ack on a large install and return a FALSE timeout.
2480
+ // Give the guarded write the bounded refresh ack budget.
2481
+ return ctx.call({ cmd: "graph_set_widget", node_id: args.node_id, widget: args.widget, value }, OBJECT_INFO_REFRESH_ACK_TIMEOUT_MS);
2364
2482
  }),
2365
2483
  def("panel_move_node", "Move a node to a new canvas position [x, y] in the user's open graph. Undoable.", {
2366
2484
  node_id: z.number().int().describe("Node id from panel_graph_outline / panel_query_graph."),
@@ -2492,6 +2610,11 @@ export function buildPanelToolDefs() {
2492
2610
  return res;
2493
2611
  }),
2494
2612
  def("panel_get_errors", "WHY IS THAT NODE RED / WHY DID THE RUN FAIL? The single error surface for the user's open tab: every errored node JOINED TO ITS CAUSE, which ComfyUI itself does not show — LiteGraph only paints a red outline and stores no reason, which is why users report \"red node, no error message\". Call this whenever the user mentions a red/highlighted/erroring node, a failed run, or \"required models are missing\" — instead of guessing from widget values. Each entry in `nodes[]` is the node's full detail summary plus `red_outline` and `reasons[]`, drawn from every source: `missing_model` (exact file, its models directory, the widget holding it, and a download URL when known), `missing_media` (a referenced input image/video that isn't on disk — the usual cause of a red LoadImage), `validation` (per-input errors from the last queue attempt: message, details, offending input), and `execution` (runtime failure with `exception_type`, e.g. PIL.UnidentifiedImageError). TWO THINGS THAT MAKE THIS ESSENTIAL: (1) missing model/media assets paint nodes red AS SOON AS THE WORKFLOW LOADS, long before any queue attempt — so the raw validation map is still EMPTY while the user is staring at red nodes; (2) a node that throws AT RUNTIME is never painted red at all, so it can't be spotted on the canvas — it appears here with red_outline:false. Also returns graph-level `missing_models`, `missing_media`, `missing_node_types` (or `missing_node_count`), plus the raw `node_errors` map and `last_execution_error` for reference. A ⚠️ GRAPH VALIDATION block is auto-injected at your turn start when this state changes; call this to re-check on demand (e.g. after you edit widgets/links). Read-only.", {}, async (_args, ctx) => ctx.call({ cmd: "graph_get_errors" })),
2613
+ def("panel_refresh_nodes", "Re-pull the live ComfyUI server's /object_info and rebuild every combo/loader option list in the user's open tab, so an asset that appeared server-side AFTER the tab loaded becomes SELECTABLE without a manual reload (the 'press R' step) or a restart. Use this right after stage_output_as_input (chaining a stage's output into a LoadImage / VHS_LoadVideo / LoadAudio loader — the returned filename won't be in the loader's dropdown until you refresh), after downloading a model / LoRA / VAE (a freshly downloaded file is otherwise 'not a valid option' in its loader), or after installing a node pack. Then panel_set_widget / panel_add_node will accept the new value. Non-destructive: it only re-registers node defs and refreshes combo option lists — it does NOT change your graph and is undo-neutral. Idempotent (safe to call repeatedly). Returns whether the refresh authoritatively fetched fresh defs.", {}, async (_args, ctx) =>
2614
+ // Same bounded ack budget as the refresh-before-validate writes (#599): a
2615
+ // fresh /object_info on a large install routinely exceeds the 6000 ms
2616
+ // default, and this command's WHOLE purpose is to await that fetch.
2617
+ ctx.call({ cmd: "refresh_nodes" }, OBJECT_INFO_REFRESH_ACK_TIMEOUT_MS)),
2495
2618
  def("panel_reload", "Soft-reload yourself to pick up code changes WITHOUT restarting ComfyUI — your chat session resumes automatically and you'll be nudged to continue. Use scope 'orchestrator' (default) after backend/orchestrator code changed (new tools, system prompt, services); use scope 'frontend' after the panel UI (web JS/CSS) changed. This ENDS the current turn — your tools/prompt are reloaded and you continue fresh. For custom-node or model changes that need a full ComfyUI restart, use panel_restart_comfyui instead. Only call this when code has actually changed and needs to take effect now.", {
2496
2619
  scope: z
2497
2620
  .enum(["orchestrator", "frontend"])
@@ -2514,9 +2637,14 @@ export function buildPanelToolDefs() {
2514
2637
  // Count only INTERACTIVE (canvas-owning) tabs for the ambiguity guard: one
2515
2638
  // desktop canvas alongside headless viewers is NOT ambiguous — rebindToActiveTab
2516
2639
  // binds the sole desktop tab. Only 2+ real canvas tabs are unpickable here.
2517
- const headless = ctx.bridge.isHeadless;
2518
- const interactive = Array.isArray(live) && typeof headless === "function"
2519
- ? live.filter((t) => !headless(t.tab_id))
2640
+ // Call isHeadless THROUGH the bridge (not an extracted reference): it is a
2641
+ // plain method that reads `this.conns` invoking a detached `const headless
2642
+ // = ctx.bridge.isHeadless` loses `this`, so `this.conns` throws "Cannot read
2643
+ // properties of undefined (reading 'conns')" and panel_reload fails outright
2644
+ // (panel #478). Mirror the bound `isHeadlessTab` helper used elsewhere here.
2645
+ const isHeadlessTab = (id) => typeof ctx.bridge.isHeadless === "function" && ctx.bridge.isHeadless(id);
2646
+ const interactive = Array.isArray(live)
2647
+ ? live.filter((t) => !isHeadlessTab(t.tab_id))
2520
2648
  : live;
2521
2649
  if (orphaned && Array.isArray(interactive) && interactive.length > 1) {
2522
2650
  return fail("This session's ComfyUI tab was replaced and multiple tabs are now open — " +
@@ -2984,9 +3112,11 @@ export function buildPanelToolDefs() {
2984
3112
  if (Array.isArray(live)) {
2985
3113
  // Count only INTERACTIVE (canvas-owning) tabs: a headless-only reconnect is
2986
3114
  // NOT a usable graph binding, so it defers (binds once a real canvas tab
2987
- // connects) rather than failing as if a tab were pickable.
2988
- const headless = ctx.bridge.isHeadless;
2989
- const interactive = typeof headless === "function" ? live.filter((t) => !headless(t.tab_id)) : live;
3115
+ // connects) rather than failing as if a tab were pickable. Call isHeadless
3116
+ // THROUGH the bridge (it reads `this.conns`) — a detached reference would
3117
+ // lose `this` and throw "reading 'conns'" (the same #478 unbound-method bug).
3118
+ const isHeadlessTab = (id) => typeof ctx.bridge.isHeadless === "function" && ctx.bridge.isHeadless(id);
3119
+ const interactive = live.filter((t) => !isHeadlessTab(t.tab_id));
2990
3120
  noTabsConnected = interactive.length === 0;
2991
3121
  }
2992
3122
  else {