comfyui-mcp 0.52.138 → 0.52.139

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.
@@ -5181,11 +5181,41 @@ function promotedEnvelopeCarriesEvidence(payload) {
5181
5181
  /** The shipped panel's only definitive non-promoted result is its own
5182
5182
  * `Node <id> (<type>) is not a subgraph` refusal. Every other graph_get_subgraph
5183
5183
  * error is an indeterminate read: a disconnect, stale route, old panel, or
5184
- * transport failure can all leave a promoted container unresolved. */
5184
+ * transport failure can all leave a promoted container unresolved.
5185
+ *
5186
+ * #2394 — the parenthesised segment is the node TYPE, and a node type may itself
5187
+ * contain parentheses: rgthree names every node that way (`Power Lora Loader
5188
+ * (rgthree)`), as do several other packs (`KSampler (Efficient)`). A `[^)]*`
5189
+ * body stops at the FIRST `)`, so `Node 82 (Power Lora Loader (rgthree)) is not
5190
+ * a subgraph` did not match its own definitive message. The read was then
5191
+ * treated as indeterminate and the write refused with "graph_get_subgraph could
5192
+ * not determine whether the addressed node is a promoted container" — the exact
5193
+ * refusal #2394 reported, on a node that is provably ordinary.
5194
+ *
5195
+ * The type body is matched as a BALANCED group (plain text, or text containing
5196
+ * one parenthesised group) UNIONED with the original `[^)]*` branch. Both halves
5197
+ * of that union are load-bearing, and each was added because a simpler edit
5198
+ * regressed a case that already worked (both caught by the codex gate):
5199
+ *
5200
+ * - Widening to `.*` looks equivalent but is not: `.` does not cross a newline
5201
+ * and `[^)]*` did, so a node type carrying a newline stopped matching.
5202
+ * - The balanced branch alone rejects an UNMATCHED `(` — a type like
5203
+ * `Power (rgthree` yields `Node 82 (Power (rgthree) is not a subgraph`, which
5204
+ * the original matched by stopping at the first `)`.
5205
+ *
5206
+ * Keeping the original branch in the union makes the accept set a strict
5207
+ * superset of the original's, so no message that was definitive before can
5208
+ * become indeterminate now. The reject set is unchanged: still anchored at
5209
+ * `^Error: Node `, still requiring the literal ` is not a subgraph` tail. A type
5210
+ * nesting parentheses more than one level deep still does not match and is
5211
+ * refused as indeterminate — the fail-closed direction, which is the correct
5212
+ * default for a predicate whose `true` authorizes an ordinary write. The
5213
+ * `[canvas-root-divergence]` diagnosis never begins with `Error: Node `, so it
5214
+ * cannot collide. */
5185
5215
  function isDefinitiveNonPromotedSubgraphRead(res) {
5186
5216
  if (!res.isError)
5187
5217
  return false;
5188
- return /^Error:\s*Node\s+\S+(?:\s+\([^)]*\))?\s+is not a subgraph\b/i.test(textOfToolResult(res));
5218
+ return /^Error:\s*Node\s+\S+(?:\s+\((?:(?:[^()]|\([^()]*\))*|[^)]*)\))?\s+is not a subgraph\b/i.test(textOfToolResult(res));
5189
5219
  }
5190
5220
  /**
5191
5221
  * panel#1869 — the panel's `[canvas-root-divergence]` refusal is a DIAGNOSIS,
@@ -5564,6 +5594,41 @@ function promotedPanelBuildRefusal(ctx, nodeId, widget, reason, capability) {
5564
5594
  * indeterminate read fails closed because the panel can resolve a promoted
5565
5595
  * container to its unsafe inner node.
5566
5596
  */
5597
+ /**
5598
+ * Why the panel binding is no longer the one we captured, or undefined when it is.
5599
+ *
5600
+ * WHY THIS IS A FUNCTION AND NOT A THIRD COPY. An early `return null` out of the
5601
+ * promoted pre-dispatch check does not mean "refuse" — it means "this is not a promoted
5602
+ * write, proceed on the ordinary path". So every exit that crosses an `await` is a WRITE
5603
+ * AUTHORIZATION issued against a binding that may have moved underneath it, and each one
5604
+ * needs the same re-read. There were two hand-written copies of these five checks; a third
5605
+ * exit was then added between them without one, which is #2401, and fixing that exit alone
5606
+ * left the next one bare, which is #2409. One copy, called by each exit, so the next early
5607
+ * return has one obvious thing to call.
5608
+ *
5609
+ * `where` is appended to each message so the caller keeps naming its own await.
5610
+ */
5611
+ function panelBindingDriftReason(ctx, where, hasIdentityApi, identityBefore, tabBefore) {
5612
+ if (!hasIdentityApi)
5613
+ return `the receiver identity was unavailable ${where}`;
5614
+ if (!isUsablePanelConnectionIdentity(identityBefore)) {
5615
+ return `the panel connection identity was unavailable ${where}`;
5616
+ }
5617
+ let identityAfter;
5618
+ try {
5619
+ identityAfter = ctx.panelConnectionIdentity?.();
5620
+ }
5621
+ catch {
5622
+ return `the panel connection identity became unreadable ${where}`;
5623
+ }
5624
+ if (ctx.tabId !== tabBefore || !isUsablePanelConnectionIdentity(identityAfter)) {
5625
+ return `the panel tab or connection changed ${where}`;
5626
+ }
5627
+ if (!samePanelConnectionIdentity(identityBefore, identityAfter)) {
5628
+ return `the panel session or connection changed ${where}`;
5629
+ }
5630
+ return undefined;
5631
+ }
5567
5632
  async function preparePromotedWidgetWrite(ctx, nodeId, widget) {
5568
5633
  // Production PanelToolCtx is always backed by UiBridge, which exposes this
5569
5634
  // per-hello capability getter. A few transport-only forwarding contexts (and
@@ -5600,12 +5665,23 @@ async function preparePromotedWidgetWrite(ctx, nodeId, widget) {
5600
5665
  // absent. An unreadable scope probe is not permission for an outer write;
5601
5666
  // it falls through to the existing conservative graph_get_subgraph path.
5602
5667
  const targetScope = await readPromotedTargetScope(ctx, nodeId);
5603
- if (targetScope?.activeView === "root" && targetScope.node === "ordinary")
5668
+ if (targetScope?.activeView === "root" && targetScope.node === "ordinary") {
5669
+ // The scope probe crossed an await. Re-read the binding before taking the
5670
+ // ordinary fast path; otherwise a tab/connection rebind can make this
5671
+ // probe's ordinary classification authorize a write on the new receiver.
5672
+ const drift = panelBindingDriftReason(ctx, "after the scope probe", hasIdentityApi, identityBefore, tabBefore);
5673
+ if (drift)
5674
+ return promotedWriteRefusal(widget, drift);
5604
5675
  return null;
5676
+ }
5605
5677
  const sub = await ctx.call({ cmd: "graph_get_subgraph", node_id: nodeId });
5606
5678
  if (sub.isError) {
5607
- if (isDefinitiveNonPromotedSubgraphRead(sub))
5679
+ if (isDefinitiveNonPromotedSubgraphRead(sub)) {
5680
+ const drift2 = panelBindingDriftReason(ctx, "after the subgraph read", hasIdentityApi, identityBefore, tabBefore);
5681
+ if (drift2)
5682
+ return promotedWriteRefusal(widget, drift2);
5608
5683
  return null;
5684
+ }
5609
5685
  return promotedSubgraphReadRefusal(widget, sub, "graph_get_subgraph could not determine whether the addressed node is a promoted container");
5610
5686
  }
5611
5687
  let payload = parseToolResultJson(sub);
@@ -8522,6 +8598,7 @@ async function rebindWorkflowFence(ctx, opts) {
8522
8598
  before,
8523
8599
  kind: "no_uuid",
8524
8600
  why: "the active workflow record carries no usable workflow_uuid",
8601
+ active,
8525
8602
  };
8526
8603
  }
8527
8604
  // "already current" requires a KNOWN prior fence equal to the live uuid. An
@@ -9161,8 +9238,19 @@ WHAT TO DO: settle it with a graph read — call panel_graph_outline. That ` +
9161
9238
  * active object and accept the returned UUID only while it still names this
9162
9239
  * open's canonical target; otherwise leave the old stamp to fail closed.
9163
9240
  */
9164
- async function refreshOpenWorkflowUuid(ctx, requestedPath, openResult) {
9241
+ async function refreshOpenWorkflowUuid(ctx, requestedPath, openResult, legacyRebind) {
9165
9242
  const parsedOpen = parseToolResultJson(openResult);
9243
+ // #971 compatibility: older/lightweight bridges may return only an explicit
9244
+ // transport route for a successful open. The proof is supplied by the one
9245
+ // immediately following open only after the caller consumed it before any
9246
+ // await; it is never a substitute for the modern bare-alias `opened.path` +
9247
+ // confirmed active workflow contract.
9248
+ const legacyReboundRoute = legacyRebind?.tabId === ctx.tabId &&
9249
+ legacyRebind.savedIdentity === canonicalBareSavedIdentity(requestedPath) &&
9250
+ !openResult.isError &&
9251
+ parsedOpen?.ok === true &&
9252
+ parsedOpen.routedTo === ctx.tabId &&
9253
+ (typeof ctx.bridge.canReach !== "function" || ctx.bridge.canReach(ctx.tabId));
9166
9254
  const opened = parsedOpen?.opened;
9167
9255
  const openedPath = opened && typeof opened === "object" && typeof opened.path === "string"
9168
9256
  ? opened.path
@@ -9171,11 +9259,118 @@ async function refreshOpenWorkflowUuid(ctx, requestedPath, openResult) {
9171
9259
  // alias/basename to a path, but that reply must never retroactively turn the
9172
9260
  // alias into a UUID-refresh authorization. Require the reply to corroborate
9173
9261
  // the original exact saved identity before consulting the live active record.
9262
+ const requestedSavedPath = canonicalSavedWorkflowPath(requestedPath);
9263
+ const requestedIsBareAlias = !!requestedSavedPath && !requestedSavedPath.includes("/");
9174
9264
  const requestedIdentity = canonicalRequestedSavedIdentity(requestedPath);
9175
9265
  const openedIdentity = openedPath
9176
9266
  ? canonicalSavedRecordIdentity({ path: openedPath, routing_key: parsedOpen?.routing_key })
9177
9267
  : null;
9268
+ if (requestedIsBareAlias) {
9269
+ // A bare filename is only a selector. It is not safe to let a native
9270
+ // success through unless the panel both resolved it to a saved path and
9271
+ // gave us a fresh, explicitly confirmed active observation. In particular,
9272
+ // an omitted `opened.path` is not a resolution, and an omitted
9273
+ // `active_confirmed` is not confirmation (#1639).
9274
+ if (!openedPath) {
9275
+ if (legacyReboundRoute)
9276
+ return null;
9277
+ return {
9278
+ drifted: true,
9279
+ unverified: true,
9280
+ activeLabel: "the panel did not return a resolved path for this filename",
9281
+ };
9282
+ }
9283
+ if (!canonicalRequestedSavedIdentity(openedPath)) {
9284
+ return {
9285
+ drifted: true,
9286
+ unverified: true,
9287
+ activeLabel: "the panel did not return a resolved path for this filename",
9288
+ };
9289
+ }
9290
+ let list = null;
9291
+ try {
9292
+ const res = await ctx.call({ cmd: "workflow_list" }, 6000);
9293
+ if (!res?.isError)
9294
+ list = parseToolResultJson(res);
9295
+ }
9296
+ catch {
9297
+ list = null;
9298
+ }
9299
+ if (!list || list.active_confirmed !== true) {
9300
+ return {
9301
+ drifted: true,
9302
+ unverified: true,
9303
+ activeLabel: "the panel did not return a freshly confirmed active workflow after resolving this filename",
9304
+ };
9305
+ }
9306
+ const resolvedIdentity = canonicalSavedRecordIdentity({
9307
+ path: openedPath,
9308
+ routing_key: parsedOpen?.routing_key,
9309
+ });
9310
+ const activeIdentity = canonicalSavedRecordIdentity(list.active);
9311
+ if (resolvedIdentity && activeIdentity === resolvedIdentity) {
9312
+ // The re-read proves which resolved path is active, but the caller only
9313
+ // supplied an alias. Preserve #716's no-adoption rule.
9314
+ return null;
9315
+ }
9316
+ if (activeIdentity) {
9317
+ return { drifted: true, activeLabel: describeActiveRecord(list.active) };
9318
+ }
9319
+ return {
9320
+ drifted: true,
9321
+ unverified: true,
9322
+ activeLabel: "the panel returned an unconfirmed active workflow after resolving this filename",
9323
+ };
9324
+ }
9178
9325
  if (!requestedIdentity || requestedIdentity !== openedIdentity) {
9326
+ // A bare filename is an alias, not a saved identity. The panel's `opened.path`
9327
+ // is the resolution of that alias; once it is available, corroborate THAT exact
9328
+ // path against a fresh active-list read before allowing the caller to treat the
9329
+ // success as an active-canvas success. Without this branch, the alias exits here
9330
+ // before any live observation and a stale previous tab can be reported as active
9331
+ // and bound (#1639).
9332
+ const resolvedIdentity = openedPath ? canonicalRequestedSavedIdentity(openedPath) : null;
9333
+ if (!requestedIdentity && resolvedIdentity) {
9334
+ let list = null;
9335
+ try {
9336
+ const res = await ctx.call({ cmd: "workflow_list" }, 6000);
9337
+ if (!res?.isError)
9338
+ list = parseToolResultJson(res);
9339
+ }
9340
+ catch {
9341
+ list = null;
9342
+ }
9343
+ if (!list) {
9344
+ return {
9345
+ drifted: true,
9346
+ unverified: true,
9347
+ activeLabel: "the panel did not return a confirmed active workflow after resolving this filename",
9348
+ };
9349
+ }
9350
+ if (list.active_confirmed !== true) {
9351
+ return {
9352
+ drifted: true,
9353
+ unverified: true,
9354
+ activeLabel: "the panel returned an unconfirmed active workflow after resolving this filename",
9355
+ };
9356
+ }
9357
+ const activeIdentity = canonicalSavedRecordIdentity(list.active);
9358
+ if (activeIdentity === resolvedIdentity) {
9359
+ // The re-read proves which resolved path is active, but the caller only
9360
+ // supplied an alias. Preserve #716's no-adoption rule: an alias may be
9361
+ // observed for the #1639 wrong-canvas guard, never promoted into a new
9362
+ // command-fence UUID by the reply-resolved path.
9363
+ return null;
9364
+ }
9365
+ if (activeIdentity) {
9366
+ return { drifted: true, activeLabel: describeActiveRecord(list.active) };
9367
+ }
9368
+ return {
9369
+ drifted: true,
9370
+ unverified: true,
9371
+ activeLabel: "the panel returned an unconfirmed active workflow after resolving this filename",
9372
+ };
9373
+ }
9179
9374
  // #812 — the SAVED corroboration above can never succeed for an unsaved
9180
9375
  // target (there is no path), so try the parallel UNSAVED identity: the
9181
9376
  // caller's literal token against the panel's own proven routing_key for
@@ -9413,6 +9608,11 @@ function noReachableTabFail(cmd, ctx) {
9413
9608
  `sent. Retry in a moment, or rebind with panel_set_workflow_target({mode:"current"}).`);
9414
9609
  }
9415
9610
  async function openWorkflowWithVerify(path, ctx) {
9611
+ // #971 — consume the explicit-current proof BEFORE the first await. This
9612
+ // makes it one-shot even when this open fails, and prevents a concurrent or
9613
+ // later open from inheriting a proof belonging to an earlier operation.
9614
+ const legacyRebind = ctx.lastExplicitCurrentRebind;
9615
+ ctx.lastExplicitCurrentRebind = undefined;
9416
9616
  // #402: after a full ComfyUI restart the browser tab re-registers a few seconds
9417
9617
  // later. Awaiting a stable binding BEFORE dispatching a mutating workflow_open
9418
9618
  // (nothing is sent yet — no double-apply risk) means the command reaches a live
@@ -9476,7 +9676,7 @@ async function openWorkflowWithVerify(path, ctx) {
9476
9676
  `save-as, or edit expecting ${path}. Call panel_list_workflows to see the current state.`);
9477
9677
  }
9478
9678
  {
9479
- const drift = await refreshOpenWorkflowUuid(ctx, path, res);
9679
+ const drift = await refreshOpenWorkflowUuid(ctx, path, res, legacyRebind);
9480
9680
  // #887 — the read above is the ONLY observation in this whole path taken
9481
9681
  // after a real round trip, so it is the only thing that can catch the active
9482
9682
  // pointer having settled elsewhere. It already declined to adopt the uuid;
@@ -9489,6 +9689,12 @@ async function openWorkflowWithVerify(path, ctx) {
9489
9689
  // act is typically a write. The session's fence is untouched (nothing was
9490
9690
  // adopted), so the tab that IS active keeps its own protection.
9491
9691
  if (drift) {
9692
+ if (drift.unverified) {
9693
+ return fail(`workflow_open: ${path} was reported applied, but the panel could not prove that ` +
9694
+ `${drift.activeLabel}. The active canvas is UNKNOWN, so this open is not a ` +
9695
+ `success and this session's workflow identity was NOT re-pointed. Inspect ` +
9696
+ `panel_list_workflows before reading or writing the graph.`);
9697
+ }
9492
9698
  return fail(`workflow_open: ${path} was opened, but ${drift.activeLabel} is the ACTIVE workflow now — ` +
9493
9699
  `the panel confirmed this on a re-read after the open completed. This session's ` +
9494
9700
  `workflow identity was NOT re-pointed at ${path}, so the canvas you would read or ` +
@@ -9829,6 +10035,11 @@ function canonicalRequestedSavedIdentity(path) {
9829
10035
  // authorize replacing its existing UUID stamp.
9830
10036
  return canonicalPath && canonicalPath.includes("/") ? `wf:${canonicalPath}` : null;
9831
10037
  }
10038
+ /** Canonical identity for a bare saved-workflow selector, for one-shot #971 proof matching. */
10039
+ function canonicalBareSavedIdentity(path) {
10040
+ const canonicalPath = canonicalSavedWorkflowPath(path);
10041
+ return canonicalPath && !canonicalPath.includes("/") ? `wf:${canonicalPath}` : null;
10042
+ }
9832
10043
  /**
9833
10044
  * Canonical `wf:<path>` identity, but only for a complete corroborating record.
9834
10045
  * Command-fence replacement is stricter than pin routing: a same-path record
@@ -17087,6 +17298,10 @@ export function buildPanelToolDefs() {
17087
17298
  if (mode === "pinned" && !(path ?? "").trim()) {
17088
17299
  return fail("Provide path when pinning — use panel_list_workflows to list open workflows.");
17089
17300
  }
17301
+ // A prior explicit recovery proof must not survive another targeting
17302
+ // call. It is valid only for the open immediately following the rebind
17303
+ // that produced it.
17304
+ ctx.lastExplicitCurrentRebind = undefined;
17090
17305
  // mode:'current' is the explicit, user/agent-initiated "rebind me to the
17091
17306
  // tab that's live now" consent signal. Self-heal a session whose captured
17092
17307
  // tab id was orphaned (reconnect/reload/workflow-switch) BEFORE writing the
@@ -17099,8 +17314,10 @@ export function buildPanelToolDefs() {
17099
17314
  // on a successful turn-pin recovery. Track that separately from the
17100
17315
  // real-tab rebind note below.
17101
17316
  let currentModeTurnRepinned = false;
17317
+ let explicitCurrentRebindBefore;
17102
17318
  if (mode === "current" && ctx.rebindToActiveTab) {
17103
17319
  const before = ctx.tabId;
17320
+ explicitCurrentRebindBefore = before;
17104
17321
  const recoveringScope = isScopeAddress(before);
17105
17322
  // Hold the send() wait BEFORE the first await so a same-batch sibling
17106
17323
  // that already hit the null pin waits instead of minting #884.
@@ -17360,6 +17577,21 @@ export function buildPanelToolDefs() {
17360
17577
  rebindNote += ` The panel dropped mid-call: this session moved from tab ${tabBeforeProbe} onto tab ${ctx.tabId}, and mode:"current" was applied there too.`;
17361
17578
  }
17362
17579
  }
17580
+ // #971 — preserve the old-panel recovery only when the current-mode
17581
+ // operation itself corroborated the saved identity now selected. The
17582
+ // proof is consumed by the next open before its first await, so a failed
17583
+ // open, a different alias, or a later open cannot inherit it.
17584
+ if (mode === "current" &&
17585
+ !deferredBind &&
17586
+ explicitCurrentRebindBefore !== undefined &&
17587
+ ctx.tabId !== explicitCurrentRebindBefore &&
17588
+ fenceRebind &&
17589
+ "active" in fenceRebind) {
17590
+ const savedIdentity = canonicalSavedRecordIdentity(fenceRebind.active);
17591
+ if (savedIdentity) {
17592
+ ctx.lastExplicitCurrentRebind = { tabId: ctx.tabId, savedIdentity };
17593
+ }
17594
+ }
17363
17595
  // panel#1529 — "Graph tools will target that workflow" is a CLAIM ABOUT
17364
17596
  // ROUTING, and this call only ever wrote the workflow-target store. It is
17365
17597
  // true when the panel confirmed the target IS the live canvas, because a
@@ -19535,7 +19767,7 @@ CHECKED FOR YOU: the graph read this message prescribes was just run, and it ` +
19535
19767
  // and refuse to report freed:true when a device is still pinned.
19536
19768
  return annotateFreeVramAck(ctx, res);
19537
19769
  }),
19538
- def("panel_show_media", "Display one or more images, videos or AUDIO files directly in the panel chat. Use this whenever the user asks to SEE, SHOW, PLAY or HEAR a file — a disk path you composited/downloaded/generated (absolute path on the orchestrator host) OR a ComfyUI output ref ({ filename, subfolder?, type? }). Items are rendered as media cards in the agent chat area — audio gets a real player (.wav/.mp3/.flac/.ogg/.oga/.opus/.m4a/.aac), so a generated TTS or voice take is played back with this tool, not described; supply optional captions. You are never sent the audio yourself and cannot hear it. Max 8 items per call. A path item OVER the 20 MB inline cap that is not under any directory ComfyUI serves can still be shown by passing stage:true on that item — the orchestrator COPIES it into <output>/_panel_staged (an opt-in, persistent disk write; 512 MB per-file and 2 GB total caps) and displays the copy by reference. NEVER describe an image with emoji or text placeholders — call this tool instead.", {
19770
+ def("panel_show_media", "Display one or more images, videos or AUDIO files directly in the panel chat. Use this whenever the user asks to SEE, SHOW, PLAY or HEAR a file — a disk path you composited/downloaded/generated (absolute path on the orchestrator host) OR a ComfyUI output ref ({ filename, subfolder?, type? }). Items are rendered as media cards in the agent chat area — audio gets a real player (.wav/.mp3/.flac/.ogg/.oga/.opus/.m4a/.aac), so a generated TTS or voice take is played back with this tool, not described; supply optional captions. You are never sent the audio yourself and cannot hear it. Max 8 items per call. A path item OVER the 20 MB inline cap that is not under any directory ComfyUI serves can still be shown by passing stage:true on that item — the orchestrator COPIES it into <output>/_panel_staged (an opt-in, persistent disk write; 512 MB per-file and 2 GB total caps) and displays the copy by reference. Staging needs a LOCAL ComfyUI: it is a filesystem copy into ComfyUI's own output directory, so it cannot run against a remote target (--comfyui-url on another host) and the call reports that instead of staging. NEVER describe an image with emoji or text placeholders — call this tool instead.", {
19539
19771
  items: z
19540
19772
  .array(z.object({
19541
19773
  // #1968 — OPTIONAL in the shape, required by normalizeShowMediaItem