comfyui-mcp 0.52.119 → 0.52.121

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/README.md CHANGED
@@ -862,7 +862,8 @@ plugin/
862
862
  job-complete-notify.mjs # Job completion notification via temp files
863
863
  scripts/ # Background scripts
864
864
  monitor-progress.mjs # Real-time WebSocket progress monitor
865
- launch-server.mjs # MCP server launcher — prefers a global install over cold `npx` (#1447)
865
+ launch-server.mjs # MCP server launcher — global install if present, else npx with a
866
+ # cold-start handshake rescue so a first run cannot time out (#1447)
866
867
  ```
867
868
 
868
869
  ---
@@ -4724,6 +4724,7 @@ const ANIMA_REGIONAL_WIRED_PROMPT_INPUT = {
4724
4724
  };
4725
4725
  const DASIWA_STACK_WIDGET = "stack_data";
4726
4726
  const DASIWA_LTX2_LORA_LOADER = "DaSiWa_LTX2LoraLoader";
4727
+ const COMFY_DYNAMICCOMBO_V3 = "COMFY_DYNAMICCOMBO_V3";
4727
4728
  function isAnimaRegionalPromptWidget(widget) {
4728
4729
  return ANIMA_REGIONAL_PROMPT_WIDGETS.has(widget);
4729
4730
  }
@@ -4870,6 +4871,166 @@ function animaRegionalPromptRefusal(type, widget) {
4870
4871
  `APPLY / savePrompts() copies the textarea back over widget.value, so a success here would be a lie. ` +
4871
4872
  `${route} Do not retry panel_set_widget on this widget.`);
4872
4873
  }
4874
+ /** Strictly parse one detail row for the dynamic-combo child gate. A compact
4875
+ * response is intentionally not enough: this guard needs the live input names
4876
+ * and types, not just the node type. */
4877
+ function parseVerifiedQueriedNodeDetail(payload) {
4878
+ if (!payload)
4879
+ return null;
4880
+ if (Object.prototype.hasOwnProperty.call(payload, "truncated") &&
4881
+ payload.truncated !== false) {
4882
+ return null;
4883
+ }
4884
+ let row;
4885
+ if (Object.prototype.hasOwnProperty.call(payload, "nodes")) {
4886
+ if (!Array.isArray(payload.nodes) || payload.nodes.length !== 1)
4887
+ return null;
4888
+ row = payload.nodes[0];
4889
+ }
4890
+ else if (typeof payload.text === "string") {
4891
+ let headerSeen = false;
4892
+ for (const line of payload.text.split(/\r?\n/)) {
4893
+ const trimmed = line.trim();
4894
+ if (!trimmed)
4895
+ continue;
4896
+ if (!headerSeen && /^\d+ match\(es\) of \d+ in scope/.test(trimmed)) {
4897
+ headerSeen = true;
4898
+ continue;
4899
+ }
4900
+ if (!trimmed.startsWith("{")) {
4901
+ // Detail replies may carry a bounded clipping/truncation footer after
4902
+ // the one JSON row. It cannot establish a second node identity.
4903
+ if (row !== undefined)
4904
+ continue;
4905
+ return null;
4906
+ }
4907
+ if (row !== undefined)
4908
+ return null;
4909
+ try {
4910
+ row = JSON.parse(trimmed);
4911
+ }
4912
+ catch {
4913
+ return null;
4914
+ }
4915
+ headerSeen = true;
4916
+ }
4917
+ }
4918
+ if (!row || typeof row !== "object" || Array.isArray(row))
4919
+ return null;
4920
+ const record = row;
4921
+ const identity = parseQueriedNodeIdentityRow(record);
4922
+ if (!identity || !Array.isArray(record.inputs))
4923
+ return null;
4924
+ const widgets = record.widgets && typeof record.widgets === "object" && !Array.isArray(record.widgets)
4925
+ ? record.widgets
4926
+ : null;
4927
+ return { ...identity, inputs: record.inputs, widgets };
4928
+ }
4929
+ /** The ONLY dynamic-combo child type this refuses.
4930
+ *
4931
+ * #2299 measured exactly one reverting child — `model.prompt`, a STRING — and a
4932
+ * STRING child is the only one with a durable escape: it is exposed as a real
4933
+ * input socket, so a PrimitiveStringMultiline link survives the frontend's
4934
+ * re-serialize pass and wins at execution.
4935
+ *
4936
+ * Refusing the parent's whole child set instead would be a capability removal.
4937
+ * `src/services/api-nodes.ts` classifies a v3 dynamic combo's children as
4938
+ * INT | FLOAT | STRING | BOOLEAN | COMBO, and the Nano Banana 2 shape fixtured in
4939
+ * `src/__tests__/services/api-nodes.test.ts` reveals `model.aspect_ratio`,
4940
+ * `model.resolution` and `model.thinking_level` — all COMBO, and all REQUIRED:
4941
+ * the server 400s with `required_input_missing` when a dotted child is absent.
4942
+ * A refusal there would leave a required input with NO route, while naming a
4943
+ * remedy that cannot be carried out (a STRING output does not reach a COMBO
4944
+ * input). No non-STRING child has been measured reverting; if one ever is, it
4945
+ * needs an honest receipt ("written, not confirmed persistent"), not a refusal.
4946
+ *
4947
+ * So: prove STRING, or keep the normal setter path. */
4948
+ const DYNAMIC_COMBO_REFUSED_CHILD_TYPE = "STRING";
4949
+ /** Budget for the gate's own detail probe — the panel_query_graph `max_chars`
4950
+ * ceiling, not the default.
4951
+ *
4952
+ * This is a PINPOINT read: one id, `limit: 1`, one row. The survey cap exists to
4953
+ * keep a 200-node listing small and has nothing to protect here. Leaving it at the
4954
+ * default is what makes the gate miss the case it was written for: the panel caps a
4955
+ * detail row and, past a point, degrades it to an `{id, type, title}` stub — which
4956
+ * drops `inputs`, so the parse cannot prove the shape and the write falls open.
4957
+ * The node most likely to overflow that budget is the node already holding a long
4958
+ * prompt, i.e. exactly the #2299 report ("<any long prompt>"). */
4959
+ const DYNAMIC_COMBO_PROBE_MAX_CHARS = 60000;
4960
+ function dynamicComboSubWidgetRefusal(nodeType, parentInput, widget) {
4961
+ return fail(`panel_set_widget cannot set dynamic-combo sub-widget "${widget}" on ${nodeType}. ` +
4962
+ `The parent input "${parentInput}" is ${COMFY_DYNAMICCOMBO_V3} and "${widget}" is a ` +
4963
+ `${DYNAMIC_COMBO_REFUSED_CHILD_TYPE} child; its frontend ` +
4964
+ `serializer can re-materialize the combo and overwrite widget.value after a ` +
4965
+ `successful write and immediate readback, so panel_run could queue an empty or ` +
4966
+ `stale value. Drive the child input by link instead: add a ` +
4967
+ `PrimitiveStringMultiline, set its STRING widget, then panel_connect its ` +
4968
+ `STRING output to this node's "${widget}" input. Set the parent "${parentInput}" ` +
4969
+ `only to choose the combo option. No graph_set_widget was dispatched; do not ` +
4970
+ `retry this dotted child write.`);
4971
+ }
4972
+ /** Refuse a dotted widget only when the live detail row proves BOTH that its
4973
+ * prefix is a COMFY_DYNAMICCOMBO_V3 input and that the addressed child is a
4974
+ * STRING (see {@link DYNAMIC_COMBO_REFUSED_CHILD_TYPE} for why the child type is
4975
+ * load-bearing rather than the parent type alone).
4976
+ * Ordinary composite widgets (for example rgthree `lora_1.on`) and ordinary
4977
+ * dotted STRING names remain on the normal setter path. An unreadable detail
4978
+ * probe is not evidence of a dynamic combo, so it falls through to the panel's
4979
+ * existing validation rather than broadening this refusal. */
4980
+ async function refuseDynamicComboSubWidgetWrite(ctx, nodeId, widget) {
4981
+ const dot = widget.indexOf(".");
4982
+ if (dot <= 0 || dot === widget.length - 1)
4983
+ return null;
4984
+ const probe = await ctx.call({
4985
+ cmd: "graph_query",
4986
+ ids: [nodeId],
4987
+ fields: "detail",
4988
+ limit: 1,
4989
+ max_chars: DYNAMIC_COMBO_PROBE_MAX_CHARS,
4990
+ });
4991
+ if (probe.isError)
4992
+ return null;
4993
+ // An unreadable or truncated probe is NOT evidence of a dynamic combo, so it
4994
+ // falls open — the same discipline as refuseAnimaRegionalPromptWrite. Failing
4995
+ // CLOSED here would refuse every dotted widget on every node whenever a probe
4996
+ // hiccups (rgthree `lora_N.*`, promoted subgraph paths, JSON composite fields),
4997
+ // which is a far larger blast radius than the residual it would close. The
4998
+ // residual is a slice of the PRE-EXISTING bug, not one this gate introduces:
4999
+ // without the gate every one of these writes is a false success. Shrinking that
5000
+ // slice is what the generous probe budget above is for. Closing it entirely
5001
+ // needs #2299's option 2 — an honest "written, not confirmed persistent" receipt
5002
+ // — which changes the success contract of the tool and is not this guard's call.
5003
+ const detail = parseVerifiedQueriedNodeDetail(parseToolResultJson(probe));
5004
+ const requestedId = canonicalQueriedNodeId(nodeId);
5005
+ if (!detail || !requestedId || detail.id !== requestedId)
5006
+ return null;
5007
+ const parentInput = widget.slice(0, dot);
5008
+ const childInput = detail.inputs.find((input) => input &&
5009
+ typeof input === "object" &&
5010
+ !Array.isArray(input) &&
5011
+ input.name === widget);
5012
+ const childExists = childInput !== undefined ||
5013
+ (detail.widgets !== null && Object.prototype.hasOwnProperty.call(detail.widgets, widget));
5014
+ if (!childExists)
5015
+ return null;
5016
+ const parent = detail.inputs.find((input) => input &&
5017
+ typeof input === "object" &&
5018
+ !Array.isArray(input) &&
5019
+ input.name === parentInput);
5020
+ if (!parent || typeof parent !== "object" || Array.isArray(parent))
5021
+ return null;
5022
+ if (parent.type !== COMFY_DYNAMICCOMBO_V3)
5023
+ return null;
5024
+ // A dynamic-combo parent is not on its own the #2299 shape. Only a child the
5025
+ // probe PROVES is STRING is refused; a COMBO/INT/FLOAT/BOOLEAN child, or one
5026
+ // whose declared type this row does not carry, keeps the normal setter path.
5027
+ const childType = childInput && typeof childInput === "object" && !Array.isArray(childInput)
5028
+ ? childInput.type
5029
+ : undefined;
5030
+ if (childType !== DYNAMIC_COMBO_REFUSED_CHILD_TYPE)
5031
+ return null;
5032
+ return dynamicComboSubWidgetRefusal(detail.type, parentInput, widget);
5033
+ }
4873
5034
  /** Refuse the known LC123 regional-canvas prompt widgets. Identity is read
4874
5035
  * from a one-node `graph_query`; anything else (query failed, type unreadable,
4875
5036
  * some other node that happens to own `negative_prompt`) is fail-open. */
@@ -12686,7 +12847,17 @@ function withRetryToken(d) {
12686
12847
  */
12687
12848
  export function buildPanelToolDefs() {
12688
12849
  // Local helper so each def reads like the original `tool(...)` call.
12689
- const def = (name, description, schema, handler) => ({ name, description, schema, handler });
12850
+ const def = (name, description, schema, handler) => ({
12851
+ name,
12852
+ // Keep the long historical setter description readable at its call site;
12853
+ // #2299's dynamic-combo warning is appended here so both registrations
12854
+ // expose the same actionable workaround.
12855
+ description: name === "panel_set_widget"
12856
+ ? `${description} A dotted STRING child of a live COMFY_DYNAMICCOMBO_V3 input (for example model.prompt) is refused when the live detail proves both halves of that shape (non-STRING children such as Nano Banana 2's model.resolution stay writable): the frontend can reserialize the combo after immediate readback and panel_run may then use an empty/stale child. Drive the corresponding STRING child input with a PrimitiveStringMultiline link instead; set the parent only to choose the combo option.`
12857
+ : description,
12858
+ schema,
12859
+ handler,
12860
+ });
12690
12861
  const capturePanelKitchenTarget = (ctx) => {
12691
12862
  const base = getComfyUIBaseUrl().replace(/\/+$/, "");
12692
12863
  const observed = ctx.bridge.tabServerOrigin?.(ctx.tabId);
@@ -13584,6 +13755,9 @@ export function buildPanelToolDefs() {
13584
13755
  const blocked = await refuseAnimaRegionalPromptWrite(ctx, args.node_id, args.widget);
13585
13756
  if (blocked)
13586
13757
  return blocked;
13758
+ const dynamicComboBlocked = await refuseDynamicComboSubWidgetWrite(ctx, args.node_id, args.widget);
13759
+ if (dynamicComboBlocked)
13760
+ return dynamicComboBlocked;
13587
13761
  const daSiWaBlocked = await refuseDaSiWaStackWrite(ctx, args.node_id, args.widget);
13588
13762
  if (daSiWaBlocked)
13589
13763
  return daSiWaBlocked;
@@ -13791,6 +13965,29 @@ export function buildPanelToolDefs() {
13791
13965
  }
13792
13966
  innerExpectedNodeType = innerIdentity.type;
13793
13967
  }
13968
+ // #2299 — every pre-write guard above probed the OUTER scope, but this retry
13969
+ // writes a DIFFERENT node: the promoted inner one. For a dynamic-combo child
13970
+ // that is exactly the false success the guard exists to stop. The outer probe
13971
+ // cannot see it and correctly falls open — the container exposes the promoted
13972
+ // child but not the `model` PARENT, so the COMFY_DYNAMICCOMBO_V3 half of the
13973
+ // shape is unprovable there. Only the inner node carries both halves.
13974
+ //
13975
+ // The stack_data fence above does re-query after entering, but it is gated on
13976
+ // `expectedNodeType`, which is set only when the addressed widget IS stack_data
13977
+ // — so it never runs for any other widget. This is the same re-probe for the
13978
+ // dotted-child case, and the canvas is already inside the subgraph here, so
13979
+ // `inner.innerNodeId` resolves.
13980
+ const innerDynamicComboBlocked = await refuseDynamicComboSubWidgetWrite(ctx, inner.innerNodeId, inner.widget);
13981
+ if (innerDynamicComboBlocked) {
13982
+ const exitedEarly = await ctx.call({ cmd: "graph_exit_subgraph" }, 15000);
13983
+ return appendToolResultText(first, `
13984
+
13985
+ (The promoted inner node ${inner.innerNodeId} owns "${inner.widget}" as a ` +
13986
+ `dynamic-combo child; ${textOfToolResult(innerDynamicComboBlocked)} ` +
13987
+ `No inner graph_set_widget was dispatched.${exitedEarly.isError
13988
+ ? ` panel_exit_subgraph also FAILED: ${textOfToolResult(exitedEarly)}`
13989
+ : ""})`);
13990
+ }
13794
13991
  const written = await write(inner.innerNodeId, inner.widget, innerExpectedNodeType);
13795
13992
  const exited = await ctx.call({ cmd: "graph_exit_subgraph" }, 15000);
13796
13993
  if (!written.isError) {