comfyui-mcp 0.49.7 → 0.49.8

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.
@@ -36,11 +36,12 @@ import { withWorkflowTarget, } from "../services/workflow-target-store.js";
36
36
  import { addUserMcpServer, readUserMcpServers, removeUserMcpServer, setUserMcpServerSecret, } from "../services/user-mcp-config.js";
37
37
  import { setComfyuiSecret, setAgentSecret, isAllowedAgentSecretKey } from "../services/panel-secrets.js";
38
38
  import { flattenUiWorkflow } from "../services/flatten-workflow.js";
39
+ import { listWorkflowLibraryKeys, userdataFetch } from "../services/userdata-library.js";
39
40
  import { getNsfwConsent, setNsfwConsent } from "../services/panel-settings.js";
40
41
  import { QueueMonitor } from "../services/queue-monitor.js";
41
42
  import { RunCompletions } from "./run-completion-journal.js";
42
43
  import { AskAnswers, askFingerprint, PANEL_ASK_ID_PREFIX, } from "./ask-answer-journal.js";
43
- import { getClient, getObjectInfo, backfillObjectInfo, resetClient, resetObjectInfoCache, } from "../comfyui/client.js";
44
+ import { getObjectInfo, backfillObjectInfo, resetClient, resetObjectInfoCache, } from "../comfyui/client.js";
44
45
  import { convertUiToApi, collectNodeTypes } from "../services/workflow-converter.js";
45
46
  import { restartComfyUI, preflightLocalRestart, recordRestartDispatch, clearRestartDispatch, getRestartDispatchRecord, RESTART_DISPATCH_CAUSATION_WINDOW_MS, PROCESS_WIDE_RESTART_DISPATCH_TOKEN, __processControlTestHooks, } from "../services/process-control.js";
46
47
  import { resetManagerApiCache } from "../services/manager-api-cache.js";
@@ -1087,6 +1088,331 @@ function fixedCapHint(what, shown, total, targeted) {
1087
1088
  return (`Showing ${shownOf(shown, total)} ${what} — this view has a FIXED cap and no parameter raises it. ` +
1088
1089
  targeted);
1089
1090
  }
1091
+ // ---- #807: ONE budget, for the WHOLE panel_query_graph reply ----------------------
1092
+ //
1093
+ // `max_chars` used to bound the `text` field and nothing else. Everything else in the
1094
+ // reply — the `groups` array with each group's full member `node_ids`, and the subgraph
1095
+ // `rails` — rode ALONGSIDE it under separate fixed caps, and was serialized BEFORE the
1096
+ // rows the caller had asked for. On a 690-node graph that is not a rounding error: at
1097
+ // the panel's own caps those riders alone can render to well over 100k characters, so a
1098
+ // reply announcing "truncated at 12 of 690 by max_chars=12000" could hand back ten times
1099
+ // that. A budget figure that does not describe the actual payload is a fabricated
1100
+ // observation, and the agent that reads it concludes the TOOL cannot show it node
1101
+ // detail — which is exactly what the reporter's session concluded.
1102
+ //
1103
+ // The accounting is applied HERE, once, over the finished payload, for the same reason
1104
+ // the #809 riders are: this is the last place the reply is touched before the caller
1105
+ // sees it, it measures the EXACT string that will be returned (pretty-printed JSON,
1106
+ // escaping and all), and it holds against every panel build rather than only the ones
1107
+ // that ship after it.
1108
+ //
1109
+ // Two rules govern what gets shed:
1110
+ // 1. The rows that ANSWER THE QUERY are never dropped to make room for context. The
1111
+ // riders are spent from what is left over, and are moved to the END of the object
1112
+ // so the answer is also what a reader meets first.
1113
+ // 2. Coverage before resolution, mirroring panel_graph_outline's ladder: dropping
1114
+ // every group's membership still leaves a complete group index, whereas listing
1115
+ // half the groups reads as "this graph has that many groups".
1116
+ const QUERY_GRAPH_MAX_CHARS_FLOOR = 500;
1117
+ const QUERY_GRAPH_MAX_CHARS_DEFAULT = 12000;
1118
+ const QUERY_GRAPH_MAX_CHARS_CEILING = 60000;
1119
+ /** The panel's own clamp for graph_query's budget, mirrored so the figure this module
1120
+ * reports and enforces is the one the panel was actually working to. */
1121
+ function clampQueryGraphMaxChars(v) {
1122
+ const n = Number(v);
1123
+ if (!Number.isFinite(n))
1124
+ return QUERY_GRAPH_MAX_CHARS_DEFAULT;
1125
+ return Math.min(Math.max(Math.floor(n), QUERY_GRAPH_MAX_CHARS_FLOOR), QUERY_GRAPH_MAX_CHARS_CEILING);
1126
+ }
1127
+ /** The reply fields that are CONTEXT rather than answer. `viewing` is deliberately not
1128
+ * one of them: it identifies which graph the answer describes, it is a couple of small
1129
+ * fields, and a reply that cannot say what it is about is worse than a bigger one. */
1130
+ const QUERY_GRAPH_RIDER_KEYS = [
1131
+ "groups_truncated",
1132
+ "groups_truncation_hint",
1133
+ "groups",
1134
+ "rails",
1135
+ ];
1136
+ /** The fields THIS accounting authors. Any inbound copy is dropped before measuring, so
1137
+ * a stale claim from a different measurement can never ride out as if this one made it
1138
+ * (independent gate P0). `max_chars` is re-set unconditionally just below. */
1139
+ const QUERY_GRAPH_OWNED_KEYS = new Set([
1140
+ "max_chars",
1141
+ "groups_membership_omitted",
1142
+ "groups_omitted",
1143
+ "rails_omitted",
1144
+ "budget_overrun",
1145
+ ]);
1146
+ /** A group reduced to its INDEX form: which groups exist, what they are called, and how
1147
+ * many nodes each holds. Membership and geometry are what grow with the graph. */
1148
+ function groupIndexEntry(g) {
1149
+ if (!g || typeof g !== "object" || Array.isArray(g))
1150
+ return g;
1151
+ const r = g;
1152
+ const out = {};
1153
+ if ("id" in r)
1154
+ out.id = r.id;
1155
+ if ("title" in r)
1156
+ out.title = r.title;
1157
+ // `node_count` is the group's TRUE size on every panel that reports it. Falling back
1158
+ // to the length of `node_ids` is only sound when that list was not itself clipped —
1159
+ // otherwise the count would understate the group, which is the same lie one rung down.
1160
+ if (typeof r.node_count === "number")
1161
+ out.node_count = r.node_count;
1162
+ else if (Array.isArray(r.node_ids) && r.node_ids_truncated === undefined)
1163
+ out.node_count = r.node_ids.length;
1164
+ return out;
1165
+ }
1166
+ /**
1167
+ * Fit the WHOLE panel_query_graph reply inside the `max_chars` it advertises, by
1168
+ * shedding CONTEXT — never the rows that answer the query.
1169
+ *
1170
+ * EVERY reply is measured, including the ones with no riders to shed (codex gate
1171
+ * SEVERE): the early "there is nothing to cut, so skip the accounting" shortcut let a
1172
+ * reply with a large `viewing`, a field this build has never seen, or simply very long
1173
+ * rows exceed the budget it reports with nothing said. There is no third outcome here —
1174
+ * a reply either fits the budget it names or carries `budget_overrun` saying it does
1175
+ * not. Nothing is invented on the way: a result that fits is never dressed up as a
1176
+ * truncated one (the false-truncation defect #809 also exists to remove).
1177
+ *
1178
+ * An error or non-JSON reply is passed through untouched. It makes no budget CLAIM —
1179
+ * there is no `max_chars` on it to be wrong — and rewriting a failure message to
1180
+ * mention a character budget would bury the failure.
1181
+ *
1182
+ * EVERY text block is counted, not only the one carrying the JSON, and a non-text block
1183
+ * (this tool emits none, but the shape allows them) has its PRESENCE disclosed rather
1184
+ * than silently ignored — `max_chars` bounds characters, and calling image bytes
1185
+ * characters would be its own false figure.
1186
+ */
1187
+ function fitQueryGraphReply(res, requested) {
1188
+ const payload = parseToolResultJson(res);
1189
+ if (!payload)
1190
+ return res;
1191
+ const idx = res.content.findIndex((c) => c.type === "text");
1192
+ if (idx < 0)
1193
+ return res;
1194
+ const budget = clampQueryGraphMaxChars(requested);
1195
+ // EVERY text block counts, not just the one carrying the JSON (independent gate P0).
1196
+ // Measuring the first block and leaving the rest untouched is the SAME defect this
1197
+ // function exists to remove — a budget describing one part of a reply while something
1198
+ // rides beside it — relocated from `groups`/`rails` to the block list. A small fitted
1199
+ // payload next to an unmeasured 100k second block would report `max_chars` and no
1200
+ // overrun. "The last place the reply is touched" has to mean the whole reply.
1201
+ const siblingText = res.content.reduce((n, c, i) => (i !== idx && c.type === "text" ? n + (c.text?.length ?? 0) : n), 0);
1202
+ // Blocks that are not text (this tool emits none, but the shape allows them) are not
1203
+ // counted — `max_chars` bounds characters, and calling image bytes characters would be
1204
+ // its own false figure — so their PRESENCE is disclosed instead of silently ignored.
1205
+ const nonTextBlocks = res.content.filter((c) => c.type !== "text").length;
1206
+ const render = (p) => JSON.stringify(p, null, 2);
1207
+ /** The whole reply's character count: the fitted JSON plus every sibling text block. */
1208
+ const sizeOf = (p) => render(p).length + siblingText;
1209
+ const replaceWith = (p) => ({
1210
+ ...res,
1211
+ content: res.content.map((c, i) => i === idx && c.type === "text" ? { ...c, text: render(p) } : c),
1212
+ });
1213
+ const riderKeys = new Set(QUERY_GRAPH_RIDER_KEYS);
1214
+ const answer = {};
1215
+ const riders = {};
1216
+ for (const [k, v] of Object.entries(payload)) {
1217
+ if (riderKeys.has(k))
1218
+ riders[k] = v;
1219
+ else if (!QUERY_GRAPH_OWNED_KEYS.has(k))
1220
+ answer[k] = v;
1221
+ }
1222
+ // The budget this call ENFORCED, always — it is the number the fitting below works
1223
+ // to, and it is measured with this field already present. Deferring to a panel-supplied
1224
+ // `max_chars` would report a bound nothing checked (codex gate SEVERE): a reply fitted
1225
+ // to 4000 could announce 3777 and look compliant at 3900.
1226
+ //
1227
+ // The loop above drops any INBOUND copy of the fields this function authors, for the
1228
+ // same reason (independent gate P0). An arriving `budget_overrun` is a claim from a
1229
+ // different measurement: carried through, a reply that fits after fitting would still
1230
+ // announce that it does not — a false positive on the one marker that has to be
1231
+ // trustworthy, and a caller who learns to distrust it will ignore the real ones. This
1232
+ // accounting is the sole author of these fields, so it is also their sole source.
1233
+ answer.max_chars = budget;
1234
+ const groups = Array.isArray(riders.groups) ? riders.groups : null;
1235
+ const hasGroups = !!groups && groups.length > 0;
1236
+ const hasRails = riders.rails !== undefined;
1237
+ /** The answer plus whichever riders `r` still carries, riders last. */
1238
+ const assemble = (r) => {
1239
+ const p = { ...answer };
1240
+ for (const k of QUERY_GRAPH_RIDER_KEYS)
1241
+ if (r[k] !== undefined)
1242
+ p[k] = r[k];
1243
+ return p;
1244
+ };
1245
+ const full = assemble(riders);
1246
+ const fullChars = sizeOf(full);
1247
+ if (fullChars <= budget)
1248
+ return replaceWith(full);
1249
+ /**
1250
+ * How the caller gets shed context back, judged against where they actually are.
1251
+ *
1252
+ * `needed` is the size of the reply that still carries it; `floor` is that same reply
1253
+ * with NO matching rows at all — the smallest this call could ever be while keeping
1254
+ * it. Both are MEASURED, not estimated, which is what lets each branch below be true:
1255
+ * narrowing the query can only help while `floor` fits, and raising `max_chars` can
1256
+ * only help while `needed` (or, combined with narrowing, `floor`) is under the
1257
+ * ceiling. Every other phrasing is a dead retry — a real lever that cannot move,
1258
+ * which costs the same round trip as naming one that does not exist.
1259
+ *
1260
+ * Response FIELDS are named bare on purpose: backticks here mean "a lever on this
1261
+ * tool", and dressing a field as a parameter is that same wasted retry.
1262
+ */
1263
+ //
1264
+ // And the ROWS IN HAND may already be a cut set. The panel stops adding rows at this
1265
+ // same `max_chars` and reports `truncated_by:"max_chars"` when it did, so on that path
1266
+ // raising the budget returns MORE ROWS as well — a budget sized to "these rows plus
1267
+ // the riders" is then not enough to keep both, and calling that size "the untruncated
1268
+ // reply" describes a reply nobody has seen (codex gate MAJOR).
1269
+ const rowsCutByBudget = answer.truncated_by === "max_chars";
1270
+ const recovery = (needed, floor) => {
1271
+ const narrow = floor <= budget
1272
+ ? " Narrowing this query (`ids`/`types`/`where`/`limit`) frees budget for them too."
1273
+ : "";
1274
+ const alsoMoreRows = rowsCutByBudget
1275
+ ? " But the panel cut the ROWS at this same budget too, so raising it returns more rows as well and may still not leave room for them — narrow the query (`ids`/`types`/`where`/`limit`) in the same call to be sure."
1276
+ : narrow;
1277
+ if (budget >= QUERY_GRAPH_MAX_CHARS_CEILING) {
1278
+ return floor <= budget
1279
+ ? `\`max_chars\` is already at its ceiling of ${QUERY_GRAPH_MAX_CHARS_CEILING}.${narrow}`
1280
+ : `\`max_chars\` is already at its ceiling of ${QUERY_GRAPH_MAX_CHARS_CEILING}, and they need ~${floor} chars even with no matching rows at all, so one reply cannot carry them.`;
1281
+ }
1282
+ if (needed <= QUERY_GRAPH_MAX_CHARS_CEILING)
1283
+ return `Keeping them alongside THESE rows takes ~${needed} chars: raise \`max_chars\` (up to ${QUERY_GRAPH_MAX_CHARS_CEILING}) to about that.${alsoMoreRows}`;
1284
+ if (floor <= QUERY_GRAPH_MAX_CHARS_CEILING)
1285
+ return `Keeping them alongside THESE rows takes ~${needed} chars, past this tool's ceiling — but only ~${floor} chars without the rows, so raise \`max_chars\` (up to ${QUERY_GRAPH_MAX_CHARS_CEILING}) AND narrow the query (\`ids\`/\`types\`/\`where\`/\`limit\`) together.`;
1286
+ return `They need ~${floor} chars even with no matching rows at all, past \`max_chars\`'s ceiling of ${QUERY_GRAPH_MAX_CHARS_CEILING}, so no combination of raising it and narrowing this query returns them here.`;
1287
+ };
1288
+ /** The same reply with the answer rows removed — the floor `recovery` reasons about. */
1289
+ const floorOf = (r) => sizeOf({ ...assemble(r), ...(answer.text !== undefined ? { text: "" } : {}) });
1290
+ const outline = ` panel_graph_outline's GROUPS index lists every member id — but only while its own max_chars affords a node-level rung; if it reports detail_level:"groups" it gives counts, not ids.`;
1291
+ // Every note below states what was OBSERVED — that the reply did not fit — and never
1292
+ // that dropping this made it fit (codex gate MAJOR). Whether it ultimately did is
1293
+ // answered by the presence or absence of `budget_overrun`, which is checked, not
1294
+ // predicted.
1295
+ // The panel caps its own groups list at 200 BEFORE this code sees it, so the coverage
1296
+ // claim any note here can make is about what the reply CARRIED, never about the graph
1297
+ // (codex gate MAJOR — "every group is still listed" sat next to the panel's own
1298
+ // "showing 200 of 640" and contradicted it).
1299
+ const groupsPreCapped = riders.groups_truncated === true;
1300
+ // Rung 1 — every group the reply carried still listed, membership and geometry gone.
1301
+ if (hasGroups) {
1302
+ const reduced = { ...riders, groups: groups.map(groupIndexEntry) };
1303
+ const p = assemble(reduced);
1304
+ p.groups_membership_omitted =
1305
+ `Member node_ids and box geometry were omitted from all ${groups.length} group(s): the whole reply did not fit \`max_chars\`=${budget}, ` +
1306
+ `and the rows answering your query are never dropped to make room for context. ` +
1307
+ (groupsPreCapped
1308
+ ? `Every group this reply CARRIED is still listed with an exact node_count — but the panel had already capped that list before this call saw it (see groups_truncation_hint), so ${groups.length} is not the graph's total. `
1309
+ : `Every group is still listed and each node_count is exact. `) +
1310
+ recovery(fullChars, floorOf(riders)) +
1311
+ outline;
1312
+ if (sizeOf(p) <= budget)
1313
+ return replaceWith(p);
1314
+ }
1315
+ // The panel's OWN cap markers are evidence about the GRAPH, not payload this code is
1316
+ // free to spend: `groups_truncation_hint` is the only place the real group count
1317
+ // ("Showing 200 of 640") survives, so dropping it while dropping the list destroys the
1318
+ // one coverage fact the caller could still have had (codex gate MAJOR). They are two
1319
+ // short fields and they are kept on every rung below the index.
1320
+ const capEvidence = {};
1321
+ if (riders.groups_truncated !== undefined)
1322
+ capEvidence.groups_truncated = riders.groups_truncated;
1323
+ if (riders.groups_truncation_hint !== undefined)
1324
+ capEvidence.groups_truncation_hint = riders.groups_truncation_hint;
1325
+ // Rung 2 — the group index itself goes. Its true size is stated in its place.
1326
+ //
1327
+ // An EMPTY `groups: []` is not a rider to shed, it is an OBSERVATION — "this graph has
1328
+ // no groups" — costing about fifteen characters. Deleting it saved nothing and quietly
1329
+ // turned a stated zero into an absent field, which is the same coverage loss one rung
1330
+ // up (codex gate MAJOR). Only a non-empty list is dropped here.
1331
+ const withoutGroups = { ...riders };
1332
+ if (hasGroups)
1333
+ delete withoutGroups.groups;
1334
+ const noGroups = assemble(withoutGroups);
1335
+ if (hasGroups) {
1336
+ // The count we can vouch for is "what this reply carried", not "what the graph has".
1337
+ const carried = groupsPreCapped
1338
+ ? `the ${groups.length} group(s) this reply carried (the panel had already capped that list, so that is not the graph's total — its own groups_truncated / groups_truncation_hint are kept here and record the real figure)`
1339
+ : `all ${groups.length} group(s)`;
1340
+ noGroups.groups_omitted =
1341
+ `The groups rider was dropped entirely — ${carried} — because the whole reply did not fit \`max_chars\`=${budget} even with their membership already omitted, ` +
1342
+ `and the rows answering your query are never dropped to make room for it. ` +
1343
+ recovery(fullChars, floorOf(riders)) +
1344
+ outline;
1345
+ }
1346
+ if (sizeOf(noGroups) <= budget)
1347
+ return replaceWith(noGroups);
1348
+ // Rung 3 — the subgraph rails go too. The cap evidence, and an empty groups
1349
+ // observation, still ride.
1350
+ const bareRiders = { ...capEvidence };
1351
+ if (!hasGroups && riders.groups !== undefined)
1352
+ bareRiders.groups = riders.groups;
1353
+ const bare = assemble(bareRiders);
1354
+ if (hasGroups && noGroups.groups_omitted !== undefined)
1355
+ bare.groups_omitted = noGroups.groups_omitted;
1356
+ if (hasRails) {
1357
+ bare.rails_omitted =
1358
+ `The subgraph boundary rails were dropped because the reply did not fit \`max_chars\`=${budget} without them. ` +
1359
+ recovery(sizeOf(noGroups), floorOf(withoutGroups));
1360
+ }
1361
+ // Every rider is gone and it STILL does not fit. What is left is the rows, the fields
1362
+ // that say which graph they came from, and the sentences saying what was dropped —
1363
+ // none of which may be silently cut, so the overrun is DISCLOSED with the real number
1364
+ // rather than left standing behind a `max_chars` that did not hold.
1365
+ //
1366
+ // WHICH part is responsible has to be measured, not assumed (codex gate MAJOR). The
1367
+ // earlier wording called every non-rider byte "the rows that answer your query" and
1368
+ // then offered to shrink them, which on a reply carrying a 3000-character subgraph
1369
+ // title blamed the wrong field AND named two levers that cannot touch it — the dead
1370
+ // retry this whole accounting exists to prevent. `lower max_chars` and `narrow the
1371
+ // query` both act on the ROWS ONLY, so they are offered only while the rest of the
1372
+ // reply would fit without them.
1373
+ const withoutRows = (p) => answer.text !== undefined ? { ...p, text: "" } : p;
1374
+ const rowsChars = render(answer).length - render(withoutRows(answer)).length;
1375
+ const floorWithNotes = sizeOf(withoutRows(bare));
1376
+ const shrink = floorWithNotes <= budget
1377
+ ? budget > QUERY_GRAPH_MAX_CHARS_FLOOR
1378
+ ? `Both levers act on the rows, so for a smaller reply lower \`max_chars\` (floor ${QUERY_GRAPH_MAX_CHARS_FLOOR}) or narrow the query with \`ids\`/\`types\`/\`where\`/\`limit\`.`
1379
+ : `\`max_chars\` is already at its floor of ${QUERY_GRAPH_MAX_CHARS_FLOOR}, so narrow the query with \`ids\`/\`types\`/\`where\`/\`limit\` for a smaller reply.`
1380
+ : `Lowering \`max_chars\` and narrowing the query both act on the ROWS ONLY, and everything else here is already ${floorWithNotes} chars on its own — so neither would bring this reply under the budget, and no parameter shrinks the rest.`;
1381
+ // "Nothing was discarded" is FALSE next to a groups_omitted note saying exactly what
1382
+ // was (codex gate r4). Two fields down, that contradiction is the kind a reader
1383
+ // resolves by distrusting both. Say which of the two situations this is.
1384
+ const shedSomething = hasGroups || hasRails;
1385
+ // Sibling text blocks are part of the reply and part of the figure, so they are named
1386
+ // rather than folded silently into "everything else" (independent gate P0).
1387
+ const siblings = siblingText > 0
1388
+ ? `${res.content.filter((c, i) => i !== idx && c.type === "text").length} further text block(s) carrying ${siblingText} chars, `
1389
+ : "";
1390
+ const nonText = nonTextBlocks > 0
1391
+ ? ` This reply also carries ${nonTextBlocks} non-text block(s), whose bytes \`max_chars\` does not bound and this figure does not count.`
1392
+ : "";
1393
+ const overrun = (size) => `This reply is ${size} chars, over \`max_chars\`=${budget} — that figure counts the JSON framing and escaping, every text block, and this note itself. ` +
1394
+ `Of it, the rows answering your query are ${rowsChars} chars; the remaining ${size - rowsChars} is ${siblings}the fields identifying the graph this came from, anything else the panel sent, and the note(s) above saying which context was dropped. ` +
1395
+ (shedSomething
1396
+ ? `The context that COULD be dropped for the budget already has been, and the note(s) above record it; what remains was not cut down any further. `
1397
+ : `Nothing was discarded to meet the budget — there was no context to drop. `) +
1398
+ `The rows are your answer, and a silently short answer — or a silently missing rider, or a silently missing explanation of one — is the defect this accounting exists to prevent. ` +
1399
+ shrink +
1400
+ nonText;
1401
+ // The stated size must include the statement (codex gate MAJOR), so solve for it: the
1402
+ // note only grows the payload, and only its own digit count feeds back, so this
1403
+ // settles in one or two passes. The loop is bounded and the last pass is emitted
1404
+ // regardless — a size that is a character or two low is still the honest order of
1405
+ // magnitude, and the `budget_overrun` field itself is what carries the finding.
1406
+ let claimed = sizeOf({ ...bare, budget_overrun: overrun(sizeOf(bare)) });
1407
+ for (let i = 0; i < 4; i++) {
1408
+ const attempt = { ...bare, budget_overrun: overrun(claimed) };
1409
+ const size = sizeOf(attempt);
1410
+ if (size === claimed)
1411
+ return replaceWith(attempt);
1412
+ claimed = size;
1413
+ }
1414
+ return replaceWith({ ...bare, budget_overrun: overrun(claimed) });
1415
+ }
1090
1416
  // ---- panel_civitai_results inline sample thumbnails (#623) -------------------
1091
1417
  // The agent recommends CivitAI models/LoRAs for a VISUAL medium, so it must be
1092
1418
  // able to SEE the sample images — not just read titles + download counts. The
@@ -2015,29 +2341,10 @@ function comfyWorkflowsDirs() {
2015
2341
  join(base, "user", "workflows"),
2016
2342
  ];
2017
2343
  }
2018
- /**
2019
- * Issue ONE userdata GET and hand back the raw Response.
2020
- *
2021
- * This deliberately bypasses the client's own `fetchApi` (codex/gate MAJOR).
2022
- * `fetchApi` throws for every non-2xx AND calls `response.json()` while building
2023
- * that error — so a refusal with a non-JSON body (a `403 text/plain` from a proxy,
2024
- * an HTML 502) rejects with a STATUSLESS SyntaxError. There is then no way to tell
2025
- * "the server refused" from "the server was never reached", and the resolver's
2026
- * local fallback would re-open for a server that had explicitly refused, loading a
2027
- * different graph (#202).
2028
- *
2029
- * Building the request from the client's own `apiURL` + `apiHeaders` keeps every
2030
- * transport concern identical (host, ssl, base path, clientId, Comfy-User, the
2031
- * injected COMFYUI_AUTH_* fetch) while making the outcome unambiguous:
2032
- * - a returned Response = the server ANSWERED; classify by status, decode later
2033
- * - a thrown error = no Response exists, so the request never got one
2034
- * Nothing here reads a body, so a body that cannot be decoded can never be
2035
- * mistaken for a transport failure.
2036
- */
2037
- async function userdataFetch(route) {
2038
- const client = getClient();
2039
- return await client.fetch(client.apiURL(route), { headers: client.apiHeaders() });
2040
- }
2344
+ // The raw-Response userdata GET (why it bypasses `fetchApi`, and what a thrown error
2345
+ // vs a returned Response each prove) now lives in services/userdata-library.ts, shared
2346
+ // with list_workflows — see #810: the two callers had drifted, one recursing into
2347
+ // subfolders and one not.
2041
2348
  /**
2042
2349
  * Drop the one leading "workflows/" segment from CALLER input, leaving a key that
2043
2350
  * is relative to the workflow store root.
@@ -2107,32 +2414,12 @@ const looseName = (name) => name.replace(/\\/g, "/").normalize("NFC").toLowerCas
2107
2414
  * so this needs no version gate.
2108
2415
  */
2109
2416
  async function listUserdataWorkflowKeys() {
2110
- try {
2111
- const res = await userdataFetch("/api/userdata?dir=workflows&recurse=true");
2112
- if (!res.ok)
2113
- return null;
2114
- const body = await res.json();
2115
- if (!Array.isArray(body))
2116
- return null;
2117
- // Tolerate both shapes ComfyUI builds return: bare strings (the default on
2118
- // 0.29.2), and {path} objects (what full_info=true emits, in case a build
2119
- // returns that shape by default).
2120
- return body
2121
- .map((entry) => {
2122
- if (typeof entry === "string")
2123
- return entry;
2124
- const rec = entry;
2125
- if (rec && typeof rec.path === "string")
2126
- return rec.path;
2127
- if (rec && typeof rec.name === "string")
2128
- return rec.name;
2129
- return null;
2130
- })
2131
- .filter((k) => typeof k === "string" && k.length > 0);
2132
- }
2133
- catch {
2134
- return null;
2135
- }
2417
+ // ONE listing read for the whole server (#810): the recursive route, the entry-shape
2418
+ // tolerance and the status classification all live in services/userdata-library.ts.
2419
+ // This resolver only needs "keys, or nothing", so every unreadable flavour collapses
2420
+ // to null here — which is exactly what its refusal path already means.
2421
+ const listing = await listWorkflowLibraryKeys();
2422
+ return listing.ok ? listing.keys : null;
2136
2423
  }
2137
2424
  /** Validate a parsed value is a UI/litegraph workflow (a top-level `nodes`
2138
2425
  * array), throwing a source-labelled error otherwise. */
@@ -3863,7 +4150,7 @@ export function buildPanelToolDefs() {
3863
4150
  // Local helper so each def reads like the original `tool(...)` call.
3864
4151
  const def = (name, description, schema, handler) => ({ name, description, schema, handler });
3865
4152
  const defs = [
3866
- def("panel_query_graph", "FILTER or TRAVERSE a SUBSET of the live canvas, for when you ALREADY KNOW what you're looking for. NOT for 'show me the canvas' or any whole-graph overview — call panel_graph_outline FIRST for that. NOT query_workflow (that queries a saved file or JSON you provide, not the live canvas). Filters, traverses, projects and aggregates over the workflow the user is CURRENTLY VIEWING without dumping the whole graph (replaces the old panel_get_graph full-JSON dump; output is TOKEN-BOUNDED with an explicit truncation marker, so a big graph can never flood your context). Combine: `types` (node type contains any), `title` (contains), `where` widget predicates ANDed ('cfg>7', 'steps<=20', 'sampler_name=euler', 'text~sunset' — ops = != >= <= > < ~contains), `ids` (exact nodes — THE way to read ONE node's exact slot/widget detail: {ids:[42], fields:'detail'}), `upstream_of`/`downstream_of` + `depth` (dependency traversal: upstream = what FEEDS that node, downstream = what CONSUMES it; seed at depth 0), `fields` ('compact' one line per node [default], 'ids', 'detail' = the full node summary with slots + connections + mode), `group_by:'type'` (counts only), `limit` (default 40). detail rows include each node's MODE — a 'bypass' node is skipped and a 'mute' node kills everything downstream, so check modes on the path you care about before running (fix with panel_set_node_mode). Every result also carries `groups` (id, title, member node_ids — groups are geometric, trust this list) and, when viewing a SUBGRAPH (after panel_enter_subgraph), `rails` (boundary rail ids/slots). NOTE: `max_chars` bounds the `text` field ONLY; those two riders are bounded by their own fixed caps and say so in-band when they cut, so lowering `max_chars` shrinks the rows and not the groups list (folding them into one budget is artokun/comfyui-mcp#807). Typical flow: panel_graph_outline to orient → panel_query_graph to pinpoint/inspect → edit. Read-only.", {
4153
+ def("panel_query_graph", "FILTER or TRAVERSE a SUBSET of the live canvas, for when you ALREADY KNOW what you're looking for. NOT for 'show me the canvas' or any whole-graph overview — call panel_graph_outline FIRST for that. NOT query_workflow (that queries a saved file or JSON you provide, not the live canvas). Filters, traverses, projects and aggregates over the workflow the user is CURRENTLY VIEWING without dumping the whole graph (replaces the old panel_get_graph full-JSON dump; output is TOKEN-BOUNDED with an explicit truncation marker, so a big graph can never flood your context). Combine: `types` (node type contains any), `title` (contains), `where` widget predicates ANDed ('cfg>7', 'steps<=20', 'sampler_name=euler', 'text~sunset' — ops = != >= <= > < ~contains), `ids` (exact nodes — THE way to read ONE node's exact slot/widget detail: {ids:[42], fields:'detail'}), `upstream_of`/`downstream_of` + `depth` (dependency traversal: upstream = what FEEDS that node, downstream = what CONSUMES it; seed at depth 0), `fields` ('compact' one line per node [default], 'ids', 'detail' = the full node summary with slots + connections + mode), `group_by:'type'` (counts only), `limit` (default 40). detail rows include each node's MODE — a 'bypass' node is skipped and a 'mute' node kills everything downstream, so check modes on the path you care about before running (fix with panel_set_node_mode). Every result also carries `groups` (id, title, member node_ids — groups are geometric, trust this list) and, when viewing a SUBGRAPH (after panel_enter_subgraph), `rails` (boundary rail ids/slots). `max_chars` bounds the WHOLE result, those riders included, and the rows you asked for are spent first: on a big graph the riders lose their member ids, then drop out entirely, rather than starving your query — and each says in-band that it did, with the true counts. Typical flow: panel_graph_outline to orient → panel_query_graph to pinpoint/inspect → edit. Read-only.", {
3867
4154
  types: z.array(z.string()).optional().describe("Node type contains ANY of these (case-insensitive)."),
3868
4155
  title: z.string().optional().describe("Node title contains this."),
3869
4156
  where: z
@@ -3897,12 +4184,17 @@ export function buildPanelToolDefs() {
3897
4184
  max_chars: z
3898
4185
  .number()
3899
4186
  .int()
3900
- .min(500)
3901
- .max(60000)
4187
+ .min(QUERY_GRAPH_MAX_CHARS_FLOOR)
4188
+ .max(QUERY_GRAPH_MAX_CHARS_CEILING)
3902
4189
  .optional()
3903
- .describe("Output character bound for the `text` field (default 12000, max 60000). Raise only for deliberate full reads, e.g. layout passes needing every node's geometry. " +
3904
- "It bounds `text` ONLY: the `groups` and `rails` riders are bounded separately by their own fixed caps (each marked in-band when it cuts), so lowering this shrinks the rows and not those (artokun/comfyui-mcp#807 tracks folding them into one budget)."),
3905
- }, async (args, ctx) => ctx.call({
4190
+ .describe(`Character bound for the WHOLE result, not just its rows (default ${QUERY_GRAPH_MAX_CHARS_DEFAULT}, max ${QUERY_GRAPH_MAX_CHARS_CEILING}). Raise only for deliberate full reads, e.g. layout passes needing every node's geometry. ` +
4191
+ "The rows answering your query are spent FIRST and are never dropped to fit the contextual groups/rails riders; those are spent from what is left and say in-band when they were reduced or omitted (#807)."),
4192
+ },
4193
+ // #807: the reply is fitted to `max_chars` as a WHOLE here — see fitQueryGraphReply.
4194
+ // The panel bounds `text`; the `groups`/`rails` riders were never in that
4195
+ // accounting, so on a large graph the reply could be many times the budget it
4196
+ // announced. Nothing is shed while the whole reply fits.
4197
+ async (args, ctx) => fitQueryGraphReply(await ctx.call({
3906
4198
  cmd: "graph_query",
3907
4199
  types: args.types,
3908
4200
  title: args.title,
@@ -3915,7 +4207,7 @@ export function buildPanelToolDefs() {
3915
4207
  group_by: args.group_by,
3916
4208
  limit: args.limit,
3917
4209
  max_chars: args.max_chars,
3918
- })),
4210
+ }), args.max_chars)),
3919
4211
  def("panel_graph_outline", "READ THE LIVE CANVAS the user is looking at, as text. 'Show me what's on the canvas' / 'what's on the graph right now' / 'read the current workflow' / 'describe the open graph' -> THIS TOOL, with no arguments. NOT visualize_workflow or visualize_workflow_hierarchical (those DRAW A DIAGRAM of a workflow you PASS IN — a saved file or JSON — and never see the live canvas). NOT panel_query_graph (that FILTERS a SUBSET, for when you already know what you're looking for). Returns one `outline` string covering the WHOLE open graph, topologically sorted (sources first, sinks last): each node as `id Type \"title\" [bypass/mute] [OUTPUT] · group:X widget=value …` with `← inputs` (source_node.output_name) and `→ outputs` (target_node.input_name), after a GROUPS index (title → member node ids). It gives you the WIRING you would otherwise reconstruct by hand — read it FIRST to get oriented, then panel_query_graph to inspect one node ({ids:[42], fields:'detail'}) or panel_find_nodes for free-text search. Over `max_chars` it never cuts the graph short: it sheds per-node detail, or refuses with a reason — never a partial outline. Read-only.", {
3920
4212
  max_chars: z
3921
4213
  .number()