comfyui-mcp 0.50.64 → 0.50.66

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
@@ -797,7 +797,6 @@ src/
797
797
  model-resolver.ts # HuggingFace search, local models, downloads
798
798
  generation-tracker.ts # SQLite generation log, settings dedup, stats
799
799
  file-hasher.ts # SHA256 hashing of .safetensors with cache
800
- civitai-lookup.ts # CivitAI API lookup by content hash
801
800
  workflow-settings-extractor.ts # Extract settings from workflow JSON
802
801
  process-control.ts # Stop, start, restart ComfyUI process
803
802
  registry-client.ts # ComfyUI Registry API
@@ -714,7 +714,10 @@ function isLoopbackHostName(host) {
714
714
  }
715
715
  /** The scheme://host:port origin of a URL (default ports made explicit), or null if
716
716
  * unparseable. Loopback hosts canonicalize to their FAMILY loopback (v4 → 127.0.0.1,
717
- * v6 → ::1) — so localhost/127.0.0.1/0.0.0.0 compare equal, and ::1/:: compare equal,
717
+ * v6 → ::1) — so 127.0.0.1/0.0.0.0 compare equal, and ::1/:: compare equal. NOTE:
718
+ * `localhost` is deliberately NOT folded (loopbackFamily returns null for it), so it
719
+ * compares UNEQUAL to a concrete literal — callers that would harm a user by treating
720
+ * that as proof of difference must degrade it to "unknown" themselves (#1233),
718
721
  * but a v4 host and a v6 host DIFFER (they may be different instances). Ports differ. */
719
722
  function httpOriginOf(rawUrl) {
720
723
  try {
@@ -1002,14 +1005,50 @@ async function probeDeclineRecovery(base, windowMs, intervalMs, probeTimeoutMs,
1002
1005
  *
1003
1006
  * Pure, so the three-way decision is testable without a live panel.
1004
1007
  */
1005
- export function restartTimeoutFallbackAdvice({ headlessBase, panelBase, }) {
1008
+ export function restartTimeoutFallbackAdvice({ headlessBase, panelBase, observedOrigin = null, }) {
1006
1009
  if (panelBase != null && sameHttpBase(headlessBase, panelBase)) {
1007
1010
  return "or use restart_comfyui to restart the server directly without a panel card.";
1008
1011
  }
1009
- if (panelBase != null) {
1012
+ // Proven different: either the proof resolved a base that differs, or the observed
1013
+ // origin — which the browser sets and page JS cannot forge — differs outright.
1014
+ // ORIGIN-only compare for the raw Origin, deliberately. `tabServerOrigin` is pathless
1015
+ // (scheme+host+port — the browser sends no path on a WS upgrade), while `headlessBase`
1016
+ // may carry a basePath mount such as :8188/comfy. A path-AWARE compare therefore reads
1017
+ // a correctly-mounted install as a mismatch and fires the strong warning at a server
1018
+ // that IS the right one — the cry-wolf failure this branch exists to avoid, which is
1019
+ // how it was found. A pathless Origin can prove a different host:port; it cannot prove
1020
+ // a different path, so it is only ever used for the claim it can actually support.
1021
+ // (captureRebootHealthBase fails closed on the same ambiguity for the same reason.)
1022
+ // #1233 (codex) — a DNS-ambiguous `localhost` on EITHER side is NOT proof of a
1023
+ // different server. loopbackFamily() deliberately excludes it (a coordinator P0: it
1024
+ // can resolve to either family, or to something else entirely), so `localhost:8188`
1025
+ // vs `127.0.0.1:8188` canonicalizes unequal and would fire the strong warning at what
1026
+ // is very likely the same instance. Absence of proof is not proof of difference, so
1027
+ // an ambiguous host degrades to UNPROVEN — the same direction captureRebootHealthBase
1028
+ // takes for the same input.
1029
+ const dnsAmbiguous = (u) => {
1030
+ if (!u)
1031
+ return false;
1032
+ try {
1033
+ return new URL(u).hostname.toLowerCase().replace(/^\[|\]$/g, "") === "localhost";
1034
+ }
1035
+ catch {
1036
+ return false;
1037
+ }
1038
+ };
1039
+ const originMismatch = observedOrigin != null &&
1040
+ !dnsAmbiguous(observedOrigin) &&
1041
+ !dnsAmbiguous(headlessBase) &&
1042
+ !sameHttpOrigin(headlessBase, observedOrigin)
1043
+ ? observedOrigin
1044
+ : null;
1045
+ const proven = panelBase ?? originMismatch;
1046
+ if (proven != null) {
1010
1047
  return (`Do NOT reach for restart_comfyui here without checking: it targets ${headlessBase}, ` +
1011
- `while this panel is running inside ${panelBase}. Restarting the first would hit a ` +
1012
- "different server than the one you have been working on and may find nothing there at all.");
1048
+ `while this panel is running inside ${proven}. Restarting the first would hit a ` +
1049
+ "different server than the one you have been working on. That may find nothing there " +
1050
+ "at all — or it may SUCCEED and take down a ComfyUI you did not mean to touch, which " +
1051
+ "on a shared instance is the worse outcome.");
1013
1052
  }
1014
1053
  return (`or use restart_comfyui, which restarts ${headlessBase} directly without a panel ` +
1015
1054
  "card — check that this is the same ComfyUI you have been working on, since I " +
@@ -1432,6 +1471,102 @@ async function confirmEmptyOutline(ctx, res, reread) {
1432
1471
  * a longer poll would buy rarer restores at the expense of every empty read.
1433
1472
  */
1434
1473
  const EMPTY_OUTLINE_RECHECK_STEPS_MS = [250, 400, 550];
1474
+ /**
1475
+ * Enforce `max_chars` when the PANEL could not (#1203).
1476
+ *
1477
+ * A panel older than the budget protocol ignores `max_chars` and returns the
1478
+ * whole outline. The orchestrator already noticed — that is what
1479
+ * `markBudgetIgnored` is for — and then forwarded the unbounded reply anyway,
1480
+ * with a note explaining that the bound the caller had explicitly set did not
1481
+ * apply. A 137-node outline arrived that way, which is the exact context flood
1482
+ * `max_chars` exists to prevent, and the disclosure came AFTER the cost was
1483
+ * already paid. A warning is not a bound.
1484
+ *
1485
+ * WHY REFUSE RATHER THAN TRUNCATE. This tool's contract is that coverage is
1486
+ * never traded away: over budget the outline sheds RESOLUTION — widget values,
1487
+ * then titles, then a per-group summary — so whatever comes back still describes
1488
+ * the whole graph, and if even the floor will not fit it returns NO outline
1489
+ * rather than a partial one that reads as complete. Cutting the rendered text at
1490
+ * a character count would break exactly that guarantee: the result would look
1491
+ * like a finished outline that simply ends, and a caller acting on it would
1492
+ * believe the graph stops where the string does — the #1184 failure again, with
1493
+ * the truncation invisible instead of announced.
1494
+ *
1495
+ * Shedding resolution is the panel's job and needs the graph, not its rendering.
1496
+ * The orchestrator has only the rendered text, so the honest move is the one the
1497
+ * panel itself makes at its floor: return no outline, and say precisely what
1498
+ * happened, how big the outline actually was, and which of the three levers
1499
+ * (raise the bound, update the panel, scope the read) will work.
1500
+ *
1501
+ * The counts and `viewing` survive — they are the part of the reply that is
1502
+ * small, true, and independent of the budget.
1503
+ */
1504
+ function enforceOutlineBudget(res, requested) {
1505
+ if (typeof requested !== "number")
1506
+ return res;
1507
+ const payload = parseToolResultJson(res);
1508
+ if (!payload)
1509
+ return res;
1510
+ // A panel that supports the budget echoes it back and has already applied its
1511
+ // own ladder. Nothing to enforce, and second-guessing it would be wrong.
1512
+ if (typeof payload.max_chars === "number")
1513
+ return res;
1514
+ const outline = payload.outline;
1515
+ if (typeof outline !== "string")
1516
+ return res;
1517
+ // Within the bound already. The panel ignored the parameter, but the reply
1518
+ // honours it, and refusing a reply that FITS would be a bound in name only.
1519
+ if (outline.length <= requested)
1520
+ return res;
1521
+ const idx = res.content.findIndex((c) => c.type === "text");
1522
+ if (idx < 0)
1523
+ return res;
1524
+ const nodes = typeof payload.node_count === "number" ? payload.node_count : null;
1525
+ const groups = typeof payload.group_count === "number" ? payload.group_count : null;
1526
+ const shape = nodes != null
1527
+ ? `The graph has ${nodes} node(s)${groups != null ? ` and ${groups} group(s)` : ""}.`
1528
+ : "";
1529
+ // The refusal goes IN the outline field, not an empty string (codex review).
1530
+ // Two reasons. It is what the panel itself does at its own floor, so a caller
1531
+ // that handles one refusal handles both. And an empty outline next to
1532
+ // `node_count: 137` is exactly the contradiction #1184 was about — a consumer
1533
+ // that reads `outline` and finds nothing has been told the canvas is empty by
1534
+ // a reply whose own counts say otherwise. Bounded and fixed-length, so it
1535
+ // cannot itself overrun a budget whose floor is OUTLINE_MAX_CHARS_FLOOR.
1536
+ payload.outline =
1537
+ `NO OUTLINE — this panel build ignores \`max_chars\`, and its full reply (${outline.length} chars) exceeds the bound of ${requested} you set. ` +
1538
+ `It is withheld rather than cut, because a truncated outline would read as a complete one. ` +
1539
+ `${shape} See \`max_chars_hint\` for what to do.`.trim();
1540
+ payload.detail_level = "refused";
1541
+ payload.degraded = true;
1542
+ payload.budget_applied_by = "orchestrator";
1543
+ payload.unbounded_chars = outline.length;
1544
+ payload.degraded_reason =
1545
+ `This panel build ignores \`max_chars\`, so it returned the FULL outline (${outline.length} chars) against a bound of ${requested}. ` +
1546
+ `It is withheld rather than cut: a truncated outline would read as a complete one. ${shape}`.trim();
1547
+ return {
1548
+ ...res,
1549
+ content: res.content.map((c, i) => i === idx && c.type === "text" ? { ...c, text: JSON.stringify(payload, null, 2) } : c),
1550
+ };
1551
+ }
1552
+ /** The three levers when the orchestrator had to withhold an outline (#1203),
1553
+ * in the order worth trying. Shared so the two riders cannot drift. */
1554
+ function outlineBudgetRemedies(unbounded) {
1555
+ // Every "raise it" must name the ceiling, or a caller already at the maximum
1556
+ // is sent on a retry that cannot change anything (the repo-wide remedy check,
1557
+ // which caught this text before it shipped).
1558
+ // The ceiling has to sit next to the parameter name, not at the end of the
1559
+ // sentence: the remedy check reads "raise `X` … up to N" as one phrase, and it
1560
+ // is right to — a caller skimming for whether the retry is possible should not
1561
+ // have to finish the clause.
1562
+ const raise = unbounded == null
1563
+ ? `Raise \`max_chars\` up to ${OUTLINE_MAX_CHARS_CEILING}`
1564
+ : unbounded <= OUTLINE_MAX_CHARS_CEILING
1565
+ ? `Raise \`max_chars\` up to ${OUTLINE_MAX_CHARS_CEILING} — this outline needs at least ${unbounded} to be accepted whole`
1566
+ : `Raising \`max_chars\` will NOT help — the full outline is past its ceiling of ${OUTLINE_MAX_CHARS_CEILING}`;
1567
+ return (`${raise}; update the ComfyUI Agent Panel, which can shed per-node detail and still cover every node inside a smaller bound; ` +
1568
+ `or scope the read with panel_query_graph {ids:[…], fields:'detail'}.`);
1569
+ }
1435
1570
  function markBudgetIgnored(res, requested) {
1436
1571
  if (typeof requested !== "number")
1437
1572
  return res;
@@ -5767,8 +5902,11 @@ export function buildPanelToolDefs() {
5767
5902
  // The synthetic `__budget_ignored` flag below is derived from the reply, not
5768
5903
  // sent by the panel: a build that supports the budget echoes `max_chars` back.
5769
5904
  markBudgetIgnored(
5905
+ // #1203 — and ENFORCE it here when the panel could not, before the
5906
+ // unbounded outline reaches the caller who asked for a bound.
5907
+ enforceOutlineBudget(
5770
5908
  // #1184 — an empty outline is re-verified before it is believed.
5771
- await confirmEmptyOutline(ctx, await ctx.call({ cmd: "graph_outline", max_chars: args.max_chars }), () => ctx.call({ cmd: "graph_outline", max_chars: args.max_chars })), args.max_chars), [
5909
+ await confirmEmptyOutline(ctx, await ctx.call({ cmd: "graph_outline", max_chars: args.max_chars }), () => ctx.call({ cmd: "graph_outline", max_chars: args.max_chars })), args.max_chars), args.max_chars), [
5772
5910
  {
5773
5911
  // #809 (codex gate): a panel older than this budget IGNORES `max_chars` and
5774
5912
  // returns the full outline, so the bound this tool advertises silently did
@@ -5777,8 +5915,24 @@ export function buildPanelToolDefs() {
5777
5915
  // unbounded reply as "this fitted".
5778
5916
  flag: "__budget_ignored",
5779
5917
  key: "max_chars_hint",
5780
- text: (p) => `This panel build does not support \`max_chars\` on the outline, so the budget you set (${typeof args.max_chars === "number" ? args.max_chars : OUTLINE_MAX_CHARS_DEFAULT}) was NOT applied and the outline below is the full, unbounded one (${typeof p.node_count === "number" ? p.node_count : "all"} node(s)). ` +
5781
- `Update the ComfyUI Agent Panel to bound it, or scope the read with panel_query_graph in the meantime.`,
5918
+ text: (p) => {
5919
+ const asked = typeof args.max_chars === "number" ? args.max_chars : OUTLINE_MAX_CHARS_DEFAULT;
5920
+ const nodes = typeof p.node_count === "number" ? p.node_count : "all";
5921
+ // #1203 — three different situations used to share one sentence
5922
+ // that described only the worst of them. Saying "the outline
5923
+ // below is the full, unbounded one" when it was withheld, or
5924
+ // when it fitted anyway, is its own false report.
5925
+ if (p.budget_applied_by === "orchestrator") {
5926
+ const unbounded = typeof p.unbounded_chars === "number" ? p.unbounded_chars : null;
5927
+ return (`This panel build does not support \`max_chars\`, so it returned the FULL outline for all ${nodes} node(s)` +
5928
+ `${unbounded != null ? ` (${unbounded} chars)` : ""} against your bound of ${asked}. ` +
5929
+ `NO outline is included: it is withheld rather than cut, because a truncated outline would read as a complete one. ` +
5930
+ outlineBudgetRemedies(unbounded));
5931
+ }
5932
+ return (`This panel build does not support \`max_chars\`, so the budget you set (${asked}) was not applied by the panel — ` +
5933
+ `the outline below is its full, unbounded reply for all ${nodes} node(s), which happens to fit within your bound. ` +
5934
+ `Update the ComfyUI Agent Panel to have the budget applied properly; on a larger graph an unbounded reply will not fit.`);
5935
+ },
5782
5936
  },
5783
5937
  {
5784
5938
  // On an older panel this is never set either, so the rider is inert there.
@@ -5797,6 +5951,21 @@ export function buildPanelToolDefs() {
5797
5951
  // it "still covers ALL nodes" would describe content the reader cannot
5798
5952
  // see, which is the same lie as a silent cut.
5799
5953
  if (p.detail_level === "refused") {
5954
+ // #1203 — an ORCHESTRATOR refusal is a different situation and
5955
+ // needs different advice. The panel refuses because even its
5956
+ // smallest whole-graph form did not fit, so the question is
5957
+ // whether raising the bound can ever help. Here the panel never
5958
+ // applied the bound at all: the full outline's size is known
5959
+ // exactly, updating the panel unlocks the shed ladder, and the
5960
+ // generic text's "this build does not report how large the
5961
+ // smallest form would be" would send the reader chasing a
5962
+ // capability the reply is already explaining is absent.
5963
+ if (p.budget_applied_by === "orchestrator") {
5964
+ const unbounded = typeof p.unbounded_chars === "number" ? p.unbounded_chars : null;
5965
+ return (`NO outline was returned: this panel build ignores \`max_chars\`, so its reply${unbounded != null ? ` (${unbounded} chars)` : ""} exceeded \`max_chars\`=${inForce} and a PARTIAL outline is deliberately withheld because it would read as complete. ` +
5966
+ `The graph has ${nodes} node(s) and ${groups} group(s). ` +
5967
+ outlineBudgetRemedies(unbounded));
5968
+ }
5800
5969
  // And if the floor exceeds the CEILING, raising is a guaranteed second
5801
5970
  // refusal — a dead retry inside the message explaining the first.
5802
5971
  //
@@ -7970,6 +8139,9 @@ export function buildPanelToolDefs() {
7970
8139
  const fallback = restartTimeoutFallbackAdvice({
7971
8140
  headlessBase: getComfyUIBaseUrl(),
7972
8141
  panelBase: captureRebootHealthBase(ctx),
8142
+ // #1233 — the raw observed origin, so a CONFIRMED mismatch is not lost inside
8143
+ // the proof's null. Server-observed on the WS upgrade; page JS cannot forge it.
8144
+ observedOrigin: ctx.bridge?.tabServerOrigin?.(ctx.tabId) ?? null,
7973
8145
  });
7974
8146
  return ok(`No confirmation received within ${Math.round(confirmBudget / 1000)}s, so I did NOT ` +
7975
8147
  "restart ComfyUI. The panel tab may be backgrounded or still reconnecting after a " +