comfyui-mcp 0.52.75 → 0.52.76

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.
@@ -4297,6 +4297,8 @@ const ANIMA_REGIONAL_WIRED_PROMPT_INPUT = {
4297
4297
  scene_prompt: "scene_prompt_in",
4298
4298
  negative_prompt: "negative_prompt_in",
4299
4299
  };
4300
+ const DASIWA_STACK_WIDGET = "stack_data";
4301
+ const DASIWA_LTX2_LORA_LOADER = "DaSiWa_LTX2LoraLoader";
4300
4302
  function isAnimaRegionalPromptWidget(widget) {
4301
4303
  return ANIMA_REGIONAL_PROMPT_WIDGETS.has(widget);
4302
4304
  }
@@ -4338,6 +4340,101 @@ function parseQueriedNodeType(payload) {
4338
4340
  }
4339
4341
  return null;
4340
4342
  }
4343
+ function canonicalQueriedNodeId(value) {
4344
+ if (typeof value === "number") {
4345
+ return Number.isSafeInteger(value) ? String(value) : null;
4346
+ }
4347
+ if (typeof value !== "string" || !NODE_ID_PATTERN.test(value))
4348
+ return null;
4349
+ const normalized = normalizeNodeId(value);
4350
+ return typeof normalized === "number" && !Number.isSafeInteger(normalized)
4351
+ ? null
4352
+ : String(normalized);
4353
+ }
4354
+ function parseQueriedNodeIdentityRow(row) {
4355
+ if (!row || typeof row !== "object" || Array.isArray(row))
4356
+ return null;
4357
+ const record = row;
4358
+ const id = canonicalQueriedNodeId(record.id);
4359
+ if (!id)
4360
+ return null;
4361
+ const type = record.type;
4362
+ const classType = record.class_type;
4363
+ if (type !== undefined && typeof type !== "string")
4364
+ return null;
4365
+ if (classType !== undefined && typeof classType !== "string")
4366
+ return null;
4367
+ if (typeof type === "string" && typeof classType === "string" && type !== classType) {
4368
+ return null;
4369
+ }
4370
+ const resolvedType = typeof type === "string" ? type : classType;
4371
+ return resolvedType ? { id, type: resolvedType } : null;
4372
+ }
4373
+ /** Strict identity parser for the DaSiWa refusal gate. Unlike the older type-only
4374
+ * parser above, this accepts exactly one non-truncated row and authenticates its id. */
4375
+ function parseVerifiedQueriedNodeIdentity(payload) {
4376
+ if (!payload)
4377
+ return null;
4378
+ if (Object.prototype.hasOwnProperty.call(payload, "truncated") &&
4379
+ payload.truncated !== false) {
4380
+ return null;
4381
+ }
4382
+ if (Object.prototype.hasOwnProperty.call(payload, "nodes")) {
4383
+ return Array.isArray(payload.nodes) && payload.nodes.length === 1
4384
+ ? parseQueriedNodeIdentityRow(payload.nodes[0])
4385
+ : null;
4386
+ }
4387
+ if (typeof payload.text !== "string")
4388
+ return null;
4389
+ const rows = [];
4390
+ const lines = payload.text.split(/\r?\n/);
4391
+ for (const [index, line] of lines.entries()) {
4392
+ const trimmed = line.trim();
4393
+ if (!trimmed)
4394
+ continue;
4395
+ // The live compact projection always prefixes its rows with this bounded summary.
4396
+ // Keep the grammar narrow so arbitrary prose cannot make an unverified row usable,
4397
+ // while accepting the documented header that older tests and panels may omit.
4398
+ if (index === 0 &&
4399
+ /^\d+ match\(es\) of \d+ in scope \(viewing: \d+ nodes\)(?: · traversal depth≤-?\d+)?$/.test(trimmed)) {
4400
+ continue;
4401
+ }
4402
+ // A compact row may be followed by one of the panel's bounded clipping notes or
4403
+ // truncation remedies. They do not identify nodes and are ignored only after a row
4404
+ // has been authenticated below.
4405
+ if (rows.length > 0 &&
4406
+ (/^\(\d+ widget value\(s\) clipped to \d+ chars/.test(trimmed) ||
4407
+ /^… truncated at \d+ of \d+/.test(trimmed))) {
4408
+ continue;
4409
+ }
4410
+ if (trimmed.startsWith("#")) {
4411
+ const compact = trimmed.match(/^#(\S+)\s+(\S+)(?:\s|$)/);
4412
+ if (!compact)
4413
+ return null;
4414
+ const id = canonicalQueriedNodeId(compact[1]);
4415
+ if (!id)
4416
+ return null;
4417
+ rows.push({ id, type: compact[2] });
4418
+ continue;
4419
+ }
4420
+ if (trimmed.startsWith("{")) {
4421
+ let row;
4422
+ try {
4423
+ row = JSON.parse(trimmed);
4424
+ }
4425
+ catch {
4426
+ return null;
4427
+ }
4428
+ const parsed = parseQueriedNodeIdentityRow(row);
4429
+ if (!parsed)
4430
+ return null;
4431
+ rows.push(parsed);
4432
+ continue;
4433
+ }
4434
+ return null;
4435
+ }
4436
+ return rows.length === 1 ? rows[0] : null;
4437
+ }
4341
4438
  function animaRegionalPromptRefusal(type, widget) {
4342
4439
  const wired = ANIMA_REGIONAL_WIRED_PROMPT_INPUT[widget];
4343
4440
  const route = wired
@@ -4367,6 +4464,162 @@ async function refuseAnimaRegionalPromptWrite(ctx, nodeId, widget) {
4367
4464
  return null;
4368
4465
  return animaRegionalPromptRefusal(type, widget);
4369
4466
  }
4467
+ function daSiWaStackRefusal(type) {
4468
+ return fail(`panel_set_widget cannot set "${DASIWA_STACK_WIDGET}" on ${type}. ` +
4469
+ `The node's custom multi-row widget owns the LoRA stack in its internal JS state and ` +
4470
+ `re-serializes that state over widget.value, so the normal graph_set_widget success ` +
4471
+ `echo would be a false success. Edit the stack rows in the node UI instead; do not retry ` +
4472
+ `panel_set_widget for this widget.`);
4473
+ }
4474
+ function daSiWaIdentityRefusal(reason) {
4475
+ return fail(`panel_set_widget refused "${DASIWA_STACK_WIDGET}" because the panel probe could not ` +
4476
+ `prove the requested node is the intended ${DASIWA_LTX2_LORA_LOADER} (${reason}). ` +
4477
+ `No graph_set_widget was dispatched. Edit the stack rows in the node UI instead; ` +
4478
+ `do not retry this write until the node identity can be read exactly.`);
4479
+ }
4480
+ function isUsablePanelConnectionIdentity(identity) {
4481
+ return (!!identity &&
4482
+ Number.isSafeInteger(identity.generation) &&
4483
+ typeof identity.tabSessionId === "string" &&
4484
+ identity.tabSessionId.length > 0);
4485
+ }
4486
+ function samePanelConnectionIdentity(left, right) {
4487
+ return left.generation === right.generation && left.tabSessionId === right.tabSessionId;
4488
+ }
4489
+ /** Refuse the DaSiWa stack widget whose custom UI deterministically overwrites a
4490
+ * graph_set_widget write after acknowledging it. This gate fails closed unless
4491
+ * the probe proves the requested node and remains bound to the same panel tab. */
4492
+ async function refuseDaSiWaStackWrite(ctx, nodeId, widget) {
4493
+ if (widget !== DASIWA_STACK_WIDGET)
4494
+ return null;
4495
+ try {
4496
+ if (ctx.tabExpectedNodeTypeFenceCapability?.() !== true) {
4497
+ return daSiWaIdentityRefusal("the bound panel does not advertise the atomic expected-node-type write fence; update the panel and hard-refresh");
4498
+ }
4499
+ }
4500
+ catch {
4501
+ return daSiWaIdentityRefusal("the panel expected-node-type write fence could not be verified");
4502
+ }
4503
+ const tabBefore = ctx.tabId;
4504
+ let identityBefore;
4505
+ try {
4506
+ identityBefore = ctx.panelConnectionIdentity?.();
4507
+ }
4508
+ catch {
4509
+ return daSiWaIdentityRefusal("the bound browser-tab identity was unreadable");
4510
+ }
4511
+ if (!isUsablePanelConnectionIdentity(identityBefore)) {
4512
+ return daSiWaIdentityRefusal("the bound browser-tab identity was unavailable");
4513
+ }
4514
+ let probe;
4515
+ try {
4516
+ probe = await ctx.call({
4517
+ cmd: "graph_query",
4518
+ ids: [nodeId],
4519
+ fields: "compact",
4520
+ limit: 1,
4521
+ });
4522
+ }
4523
+ catch {
4524
+ return daSiWaIdentityRefusal("the graph_query probe failed before identity could be verified");
4525
+ }
4526
+ if (ctx.tabId !== tabBefore) {
4527
+ return daSiWaIdentityRefusal("the probe was answered by a different panel tab");
4528
+ }
4529
+ let identityAfter;
4530
+ try {
4531
+ identityAfter = ctx.panelConnectionIdentity?.();
4532
+ }
4533
+ catch {
4534
+ return daSiWaIdentityRefusal("the panel-tab identity became unreadable after the probe");
4535
+ }
4536
+ if (!isUsablePanelConnectionIdentity(identityAfter)) {
4537
+ return daSiWaIdentityRefusal("the panel-tab identity became unavailable after the probe");
4538
+ }
4539
+ if (!samePanelConnectionIdentity(identityBefore, identityAfter)) {
4540
+ return daSiWaIdentityRefusal("the panel-tab connection changed during the probe");
4541
+ }
4542
+ if (probe.isError) {
4543
+ return daSiWaIdentityRefusal("the graph_query probe returned an error");
4544
+ }
4545
+ const identity = parseVerifiedQueriedNodeIdentity(parseToolResultJson(probe));
4546
+ const requestedId = canonicalQueriedNodeId(nodeId);
4547
+ if (!identity || !requestedId) {
4548
+ return daSiWaIdentityRefusal("the graph_query reply was malformed, truncated, or did not contain exactly one verifiable row");
4549
+ }
4550
+ if (identity.id !== requestedId) {
4551
+ return daSiWaIdentityRefusal("the graph_query row identified a different node_id");
4552
+ }
4553
+ if (identity.type !== DASIWA_LTX2_LORA_LOADER)
4554
+ return null;
4555
+ return daSiWaStackRefusal(identity.type);
4556
+ }
4557
+ /** Recheck the target immediately before a stack_data mutation. The first probe
4558
+ * prevents the known refusal from dispatching; this final fence catches a tab
4559
+ * reconnect or node replacement observed between that probe and graph_set_widget. */
4560
+ async function verifyDaSiWaStackWriteFence(ctx, nodeId) {
4561
+ try {
4562
+ if (ctx.tabExpectedNodeTypeFenceCapability?.() !== true) {
4563
+ return daSiWaIdentityRefusal("the bound panel does not advertise the atomic expected-node-type write fence; update the panel and hard-refresh");
4564
+ }
4565
+ }
4566
+ catch {
4567
+ return daSiWaIdentityRefusal("the panel expected-node-type write fence could not be verified");
4568
+ }
4569
+ const tabBefore = ctx.tabId;
4570
+ let identityBefore;
4571
+ try {
4572
+ identityBefore = ctx.panelConnectionIdentity?.();
4573
+ }
4574
+ catch {
4575
+ return daSiWaIdentityRefusal("the bound browser-tab identity was unreadable before dispatch");
4576
+ }
4577
+ if (!isUsablePanelConnectionIdentity(identityBefore)) {
4578
+ return daSiWaIdentityRefusal("the bound browser-tab identity was unavailable before dispatch");
4579
+ }
4580
+ let probe;
4581
+ try {
4582
+ probe = await ctx.call({
4583
+ cmd: "graph_query",
4584
+ ids: [nodeId],
4585
+ fields: "compact",
4586
+ limit: 1,
4587
+ });
4588
+ }
4589
+ catch {
4590
+ return daSiWaIdentityRefusal("the final graph_query fence failed before dispatch");
4591
+ }
4592
+ if (ctx.tabId !== tabBefore) {
4593
+ return daSiWaIdentityRefusal("the final fence was answered by a different panel tab");
4594
+ }
4595
+ let identityAfter;
4596
+ try {
4597
+ identityAfter = ctx.panelConnectionIdentity?.();
4598
+ }
4599
+ catch {
4600
+ return daSiWaIdentityRefusal("the panel-tab identity became unreadable before dispatch");
4601
+ }
4602
+ if (!isUsablePanelConnectionIdentity(identityAfter)) {
4603
+ return daSiWaIdentityRefusal("the panel-tab identity became unavailable before dispatch");
4604
+ }
4605
+ if (!samePanelConnectionIdentity(identityBefore, identityAfter)) {
4606
+ return daSiWaIdentityRefusal("the panel-tab connection changed before dispatch");
4607
+ }
4608
+ if (probe.isError) {
4609
+ return daSiWaIdentityRefusal("the final graph_query fence returned an error");
4610
+ }
4611
+ const identity = parseVerifiedQueriedNodeIdentity(parseToolResultJson(probe));
4612
+ const requestedId = canonicalQueriedNodeId(nodeId);
4613
+ if (!identity || !requestedId) {
4614
+ return daSiWaIdentityRefusal("the final graph_query fence was malformed, truncated, or did not contain exactly one verifiable row");
4615
+ }
4616
+ if (identity.id !== requestedId) {
4617
+ return daSiWaIdentityRefusal("the final graph_query fence identified a different node_id");
4618
+ }
4619
+ return identity.type === DASIWA_LTX2_LORA_LOADER
4620
+ ? daSiWaStackRefusal(identity.type)
4621
+ : { expectedNodeType: identity.type };
4622
+ }
4370
4623
  // ---- #809: turn the panel's silent `truncated: true` booleans into a remedy --------
4371
4624
  //
4372
4625
  // A bare boolean is the WORST truncation signal there is: it is a field, not prose, so
@@ -10216,6 +10469,7 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets, onRunTicketOpen
10216
10469
  ctx.panelConnectionIdentity = panelConnectionIdentity;
10217
10470
  ctx.awaitPostRestartReachable = awaitPostRestartReachable;
10218
10471
  ctx.tabCanMutateGraph = () => bridge.tabCanMutateGraph(ctx.tabId);
10472
+ ctx.tabExpectedNodeTypeFenceCapability = () => bridge.tabExpectedNodeTypeFenceCapability(ctx.tabId);
10219
10473
  ctx.tabGraphMutationCapability = () => bridge.tabGraphMutationCapability(ctx.tabId);
10220
10474
  return ctx;
10221
10475
  }
@@ -12386,7 +12640,7 @@ export function buildPanelToolDefs() {
12386
12640
  node_id: nodeId().describe("Node id whose input to disconnect."),
12387
12641
  input: slotRef.optional().describe("Input slot name or index (default 0)."),
12388
12642
  }, async (args, ctx) => ctx.call({ cmd: "graph_disconnect", node_id: args.node_id, input: args.input })),
12389
- def("panel_set_widget", "Set a widget value on a node in the user's open graph (steps, cfg, seed, ckpt_name, text prompts, …). Returns the previous and new value (a string longer than 1000 chars is echoed as {chars, sha256, preview} so batched calls stay inside the outer tool-result budget — pass `echo: \"full\"` for the verbatim string). Undoable with Ctrl+Z. To CLEAR a text widget to an empty string, pass `clear: true` (some MCP clients drop an empty-string `value` from the serialized payload, so `value: \"\"` may not arrive — `clear: true` always works). For the LTXDirector timeline node (WhatDreamsCost CSGlide), set `timeline_data` with the FULL timeline JSON (segments + global_prompt) to drive its custom timeline UI — this re-syncs the editor and regenerates its derived `local_prompts`/`segment_lengths`/`guide_strength` widgets; setting those derived widgets directly is refused (they are silently reverted). For AnimaRegionalCanvasInline / Krea2RegionalCanvasInline (LC123), quality/scene/red/green/blue/negative prompt writes are refused: the custom textarea and node.properties.animaPrompts overwrite widget.value on APPLY. Drive quality/scene/negative via a PrimitiveStringMultiline wired into quality_prompt_in / scene_prompt_in / negative_prompt_in; red/green/blue have no socket.", {
12643
+ def("panel_set_widget", "Set a widget value on a node in the user's open graph (steps, cfg, seed, ckpt_name, text prompts, …). Returns the previous and new value (a string longer than 1000 chars is echoed as {chars, sha256, preview} so batched calls stay inside the outer tool-result budget — pass `echo: \"full\"` for the verbatim string). Undoable with Ctrl+Z. To CLEAR a text widget to an empty string, pass `clear: true` (some MCP clients drop an empty-string `value` from the serialized payload, so `value: \"\"` may not arrive — `clear: true` always works). For the LTXDirector timeline node (WhatDreamsCost CSGlide), set `timeline_data` with the FULL timeline JSON (segments + global_prompt) to drive its custom timeline UI — this re-syncs the editor and regenerates its derived `local_prompts`/`segment_lengths`/`guide_strength` widgets; setting those derived widgets directly is refused (they are silently reverted). For AnimaRegionalCanvasInline / Krea2RegionalCanvasInline (LC123), quality/scene/red/green/blue/negative prompt writes are refused: the custom textarea and node.properties.animaPrompts overwrite widget.value on APPLY. Drive quality/scene/negative via a PrimitiveStringMultiline wired into quality_prompt_in / scene_prompt_in / negative_prompt_in; red/green/blue have no socket. DaSiWa_LTX2LoraLoader's `stack_data` write is also refused: its custom multi-row widget reserializes its own JS state over `widget.value`, so the echoed write would be a false success; edit the stack rows in the node UI instead.", {
12390
12644
  node_id: nodeId().describe("Node id from panel_graph_outline / panel_query_graph."),
12391
12645
  widget: z.string().describe("Widget name (e.g. 'steps', 'cfg', 'text')."),
12392
12646
  value: z
@@ -12417,6 +12671,16 @@ export function buildPanelToolDefs() {
12417
12671
  const blocked = await refuseAnimaRegionalPromptWrite(ctx, args.node_id, args.widget);
12418
12672
  if (blocked)
12419
12673
  return blocked;
12674
+ const daSiWaBlocked = await refuseDaSiWaStackWrite(ctx, args.node_id, args.widget);
12675
+ if (daSiWaBlocked)
12676
+ return daSiWaBlocked;
12677
+ let expectedNodeType;
12678
+ if (args.widget === DASIWA_STACK_WIDGET) {
12679
+ const daSiWaFence = await verifyDaSiWaStackWriteFence(ctx, args.node_id);
12680
+ if (daSiWaFence && "content" in daSiWaFence)
12681
+ return daSiWaFence;
12682
+ expectedNodeType = daSiWaFence?.expectedNodeType;
12683
+ }
12420
12684
  // #599: the frontend runs refresh-before-validate here (pulls a fresh
12421
12685
  // /object_info so a just-staged/-downloaded/-installed value is accepted on
12422
12686
  // a single revalidation, #338/#458) — that authoritative fetch can outlast
@@ -12429,7 +12693,19 @@ export function buildPanelToolDefs() {
12429
12693
  // to the raw success reply so a later appendToolResultText disclosure
12430
12694
  // (which makes the text no longer parse as JSON) cannot dodge it.
12431
12695
  const echoFull = args.echo === "full";
12432
- const write = async (nodeId, widget) => stripVerifiedLastObservedSchemaNote(summarizeSetWidgetEcho(await ctx.call({ cmd: "graph_set_widget", node_id: nodeId, widget, value }, OBJECT_INFO_REFRESH_ACK_TIMEOUT_MS), echoFull));
12696
+ const write = async (nodeId, widget, targetExpectedNodeType = expectedNodeType) => stripVerifiedLastObservedSchemaNote(summarizeSetWidgetEcho(await ctx.call({
12697
+ cmd: "graph_set_widget",
12698
+ node_id: nodeId,
12699
+ widget,
12700
+ value,
12701
+ // The caller supplies the type proven for THIS addressed node.
12702
+ // The outer final fence is used for direct/re-mapped writes;
12703
+ // promoted recovery re-probes its inner node after entering
12704
+ // and supplies that node's own type.
12705
+ ...(targetExpectedNodeType
12706
+ ? { expected_node_type: targetExpectedNodeType }
12707
+ : {}),
12708
+ }, OBJECT_INFO_REFRESH_ACK_TIMEOUT_MS), echoFull));
12433
12709
  let first = await write(args.node_id, args.widget);
12434
12710
  if (!first.isError) {
12435
12711
  markPanelSchemaReady(panelSchemaKey(ctx));
@@ -12527,7 +12803,38 @@ export function buildPanelToolDefs() {
12527
12803
  `Resolved it to inner node ${inner.innerNodeId} but panel_enter_subgraph FAILED: ` +
12528
12804
  `${textOfToolResult(entered)})`);
12529
12805
  }
12530
- const written = await write(inner.innerNodeId, inner.widget);
12806
+ let innerExpectedNodeType;
12807
+ if (expectedNodeType !== undefined) {
12808
+ // The inner mapping was discovered before an awaited enter. Re-query
12809
+ // the addressed inner node after entering so the stack_data write gets
12810
+ // its OWN live type fence; carrying the outer wrapper's type would
12811
+ // reject a valid inner node, while omitting a fence would let a same-
12812
+ // type replacement mutate a detached object and report false success.
12813
+ const innerProbe = await ctx.call({ cmd: "graph_query", ids: [inner.innerNodeId], fields: "compact", limit: 1 }, OBJECT_INFO_REFRESH_ACK_TIMEOUT_MS);
12814
+ const innerIdentity = innerProbe.isError
12815
+ ? null
12816
+ : parseVerifiedQueriedNodeIdentity(parseToolResultJson(innerProbe));
12817
+ const expectedInnerId = canonicalQueriedNodeId(inner.innerNodeId);
12818
+ if (!innerIdentity || !expectedInnerId || innerIdentity.id !== expectedInnerId) {
12819
+ const exited = await ctx.call({ cmd: "graph_exit_subgraph" }, 15000);
12820
+ return appendToolResultText(first, `\n\n(The panel listed "${refusal.widget}" as promoted and mapped it to inner node ` +
12821
+ `${inner.innerNodeId}, but the post-enter identity probe did not verify that ` +
12822
+ `exact live node${innerProbe.isError ? `: ${textOfToolResult(innerProbe)}` : "."} ` +
12823
+ `The write was not retried.${exited.isError
12824
+ ? ` panel_exit_subgraph also FAILED: ${textOfToolResult(exited)}`
12825
+ : ""})`);
12826
+ }
12827
+ if (innerIdentity.type === DASIWA_LTX2_LORA_LOADER) {
12828
+ const exited = await ctx.call({ cmd: "graph_exit_subgraph" }, 15000);
12829
+ return appendToolResultText(first, `\n\n(The promoted inner node ${inner.innerNodeId} was identified as ` +
12830
+ `${DASIWA_LTX2_LORA_LOADER}; ${textOfToolResult(daSiWaStackRefusal(innerIdentity.type))} ` +
12831
+ `No inner graph_set_widget was dispatched.${exited.isError
12832
+ ? ` panel_exit_subgraph also FAILED: ${textOfToolResult(exited)}`
12833
+ : ""})`);
12834
+ }
12835
+ innerExpectedNodeType = innerIdentity.type;
12836
+ }
12837
+ const written = await write(inner.innerNodeId, inner.widget, innerExpectedNodeType);
12531
12838
  const exited = await ctx.call({ cmd: "graph_exit_subgraph" }, 15000);
12532
12839
  if (!written.isError) {
12533
12840
  const via = `\n\n(Applied via the inner widget this promotion lists: node ${inner.innerNodeId} ` +