echelongraph-mcp 2.7.2 → 3.0.0

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.
@@ -1,9 +1,13 @@
1
- // The text budget (#2783): what a success's first text block may cost, and how an answer larger
2
- // than that is cut. index.ts's `succeeded` builds every success's first text block with dataText;
3
- // a tool that returns a list of rows names it with a TextCut. structuredContent.data is never cut.
4
- // A module of its own, so a tool module can import its types and TEXT_BUDGET_DESCRIPTION without
5
- // importing index.ts, which starts the stdio server when loaded.
6
- //
1
+ // The text budget (#2783), and the structured result's (#3013): what a success may cost in each
2
+ // channel a client hands a model, and how an answer larger than that is cut. index.ts's
3
+ // `succeeded` builds every success's first text block with dataText (a tool that returns a list of
4
+ // rows names it with a TextCut), and its structured result with cutData (each tool names its lists
5
+ // in the order they are cut with a DataCut, and answering() hands the call's `full` and DataCut to
6
+ // `succeeded`). A module of its own, so a tool module can import its types,
7
+ // TEXT_BUDGET_DESCRIPTION and FULL_ARG without importing index.ts, which starts the stdio server
8
+ // when loaded.
9
+ import { AsyncLocalStorage } from "node:async_hooks";
10
+ import * as z from "zod";
7
11
  // What a success costs in the channel a model reads is mostly its first text block, and through
8
12
  // 2.6.2 that block was the API's JSON pretty-printed, whatever its size. Measured on production
9
13
  // through https://mcp.echelongraph.io/mcp (2.6.2, 2026-10-04): search_cves {search: "openssl"} at
@@ -18,8 +22,8 @@
18
22
  // serialized in a text block too, as a SHOULD, for backwards compatibility, and SEP-1624 proposes
19
23
  // content as the model-facing, token-efficient form and structuredContent as the machine-facing one.
20
24
  //
21
- // So structuredContent.data is always the API's JSON whole, and the first text block is that JSON
22
- // when it fits DATA_TEXT_BUDGET, half the ceiling, and cut when it does not:
25
+ // So the first text block is the API's JSON when it fits DATA_TEXT_BUDGET, half the ceiling, and
26
+ // cut when it does not (structuredContent.data, below, is cut on its own terms, never from this):
23
27
  // 1. pretty-printed, as since 1.x, when that fits;
24
28
  // 2. else the same JSON with each element of a list on a line of its own, which parses to the
25
29
  // same value, when that fits;
@@ -33,19 +37,67 @@
33
37
  // level keeps, and before any row leaves the text;
34
38
  // 4. else every list keeps its first n entries, n halving, and then strings are cut too.
35
39
  // Whenever the text is not the JSON whole, the note ends with one sentence, starting TEXT CUT,
36
- // that says what the first text block leaves out, that structuredContent.data carries all of it,
40
+ // that says what the first text block leaves out, where structuredContent.data carries all of it,
37
41
  // and how to read the rest in the text: the tool that returns one row, and what of it, and the page
38
42
  // size or offset at which a page fits. The cut is deterministic: the same answer is cut the same way.
43
+ //
44
+ // The structured result, per client (#3013). Through 2.7.x structuredContent.data was the API's
45
+ // JSON whole, on the reading above that structuredContent is the machine-facing form. It is not, in
46
+ // the client the README gives first-class install steps: Claude Code hands the model
47
+ // structuredContent, not the text blocks (reproduced 2026-10-11 over raw JSON-RPC: get_cve
48
+ // CVE-2021-44228 was 65,540 characters of structuredContent beside a first text block of 23,759,
49
+ // and Claude Code refused it, "exceeds maximum allowed tokens", against its default
50
+ // MAX_MCP_OUTPUT_TOKENS of 25,000, and saved it to a file, so the model got a path, not an answer).
51
+ // The spec (MCP 2025-06-18, Tools, Structured Content) does not say which of the two a client gives
52
+ // the model. So the text cut above reached every client but that one. Since 3.0.0 the structured
53
+ // result is held to the same budget, STRUCTURED_BUDGET: the whole of it, envelope and notes with
54
+ // data, as compact JSON, since that is what such a client sizes. Past it, data is cut (cutData):
55
+ // 1. the lists the tool's DataCut names, step by step in its order. Where a step names thinning
56
+ // levels (a list whose single entries are large: check_sbom's and scan_manifest's results,
57
+ // check_affected's matches, cve_remediation's vendor_remediations, search_cves' and the
58
+ // vendor-advisory tools' rows), every entry is first cut to the fields each level keeps, level
59
+ // by level, all entries kept, so that no row leaves while thinning its detail would do; then
60
+ // each step keeps the most entries that fit, every list in the step at most the same number,
61
+ // its first ones, or for a list named with leaveOutFirst (check_sbom's results) those entries
62
+ // the tool names leave first (the clean rows, counted in data.summary), then the last of the
63
+ // rest, or for a step of series named keepNewest (exposure_radar's daily and weekly counts,
64
+ // which the API sends oldest first) its last ones, the newest, the oldest leaving first; a
65
+ // step that cannot fit even empty is emptied, and the next one is cut;
66
+ // 2. then every other list at the top level of data, the same way. A list inside an object of
67
+ // data (get_cve's remediation summary, with fix_status, #2841) is cut only where a DataCut
68
+ // names it, and none names get_cve's. Once a step fits, the room it leaves goes back to the
69
+ // steps emptied before it, the last first, each the same way (scan_manifest's not_checked,
70
+ // emptied while its rows were whole, keeps its entries once the thinned rows fit);
71
+ // 3. no string is shortened and no value changes: a thinned entry keeps some of its fields as
72
+ // they are (every field a thinning level drops is optional in the outputSchema, so data still
73
+ // validates against it), and no field outside the lists cut changes; the envelope says
74
+ // data_truncated true, truncated gives each list cut by its path in data with its total, the
75
+ // number returned and, where its entries are thinned, thinned true, and the note ends with
76
+ // sentences starting DATA CUT that say what was cut and to which fields, how to read the rest
77
+ // (the offset or page size, where the tool pages: a page size the note says fits data whole
78
+ // is counted on the entries whole, never on the thinned ones, #3013's review) and that the
79
+ // same call with full: true returns data whole.
80
+ // The first text block is cut from the whole answer as before, not from the cut data: what it
81
+ // holds is unchanged for the clients that read it. full: true is for programs, which read data
82
+ // whole whatever its size; a client that sizes structured results, Claude Code among them, may
83
+ // still refuse such an answer past its own cap (MAX_MCP_OUTPUT_TOKENS raises Claude Code's).
84
+ // Rejected (#3013's decision of 2026-10-11): saying only that Claude Code gets files; cutting only
85
+ // for a client whose clientInfo.name is Claude Code (the same call would answer differently by
86
+ // client, and the next client to pass structuredContent on would get the 65,540 again); and dropping
87
+ // structuredContent (it loses the outputSchema check the spec recommends and the suite relies on).
39
88
  export const DATA_TEXT_BUDGET = 30_000;
40
89
  // What a note may say of reading one CVE's record through get_cve (#2802): get_cve's own first text
41
- // block is cut too past the budget (each list to its first entries, every other field kept), so
42
- // only its structuredContent.data is the record whole. A row tool's `whole` sentence starts with it.
43
- export const GET_CVE_WHOLE = "get_cve returns any one of these CVEs' records, whole in its structuredContent.data, and in its first text block with every field (past 30,000 characters, each list cut to its first entries)";
90
+ // block is cut too past the budget (each list to its first entries, every other field kept), and
91
+ // since #3013 so is its structured result's data, unless the call says full: true. A row tool's
92
+ // `whole` sentence starts with it.
93
+ export const GET_CVE_WHOLE = "get_cve returns any one of these CVEs' records, whole in its structuredContent.data with full: true, and in its first text block with every field (past 30,000 characters, each list cut to its first entries)";
44
94
  // What every tool description says about it, one constant string (marketing-site
45
95
  // lib/mcpToolClaims.test.ts folds only constant strings); the suite holds its number to
46
96
  // DATA_TEXT_BUDGET's. It uses none of the words that check reviews as the page's own prose (how,
47
- // when, does, have, on and the rest of its PROSE list), so no reviewed entry goes stale.
48
- export const TEXT_BUDGET_DESCRIPTION = "Past 30,000 characters of JSON, the first text block holds data cut to fit, and the note says what the cut leaves out and where to read it (TEXT CUT); data in the structured result always holds it whole.";
97
+ // when, does, have, on and the rest of its PROSE list), so no reviewed entry goes stale. #3013: the
98
+ // same length as 2.7.x's (203 characters), since several descriptions sit within ten characters of
99
+ // the floor the suite keeps under the client cut (#2924); FULL_ARG says the rest.
100
+ export const TEXT_BUDGET_DESCRIPTION = "Past 30,000 characters of JSON, the first text block holds data cut to fit, and the note says what the cut leaves out and where to read it (TEXT CUT); so is structured data (DATA CUT), unless full: true.";
49
101
  const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
50
102
  const field = (o, k) => (isPlainObject(o) ? o[k] : undefined);
51
103
  const listed = (xs) => (xs.length < 2 ? xs.join("") : `${xs.slice(0, -1).join(", ")} and ${xs[xs.length - 1]}`);
@@ -165,6 +217,12 @@ function cutSides(data, sides) {
165
217
  export const FIRST_TEXT_BLOCK = {
166
218
  opens: (chars) => `TEXT CUT: the API's answer is ${chars} characters of JSON, more than the ${DATA_TEXT_BUDGET} the first text block holds, so that block is cut, and structuredContent.data carries the answer whole.`,
167
219
  };
220
+ // #3013: the first text block beside a structuredContent.data that is cut too (DATA CUT): the same
221
+ // text, cut the same way, and sentences that say data is whole only with full: true.
222
+ export const FIRST_TEXT_BLOCK_BESIDE_A_CUT = {
223
+ opens: (chars) => `TEXT CUT: the API's answer is ${chars} characters of JSON, more than the ${DATA_TEXT_BUDGET} the first text block holds, so that block is cut, and structuredContent.data carries the answer whole with full: true (DATA CUT, below).`,
224
+ leftOut: (n, which) => `the other ${n} are left out of it${which}, and structuredContent.data holds them with full: true`,
225
+ };
168
226
  /** The first text block for `data` within DATA_TEXT_BUDGET, and the TEXT CUT sentence when it is cut. */
169
227
  export function dataText(data, cut, place = FIRST_TEXT_BLOCK) {
170
228
  const pretty = JSON.stringify(data, null, 2);
@@ -232,7 +290,8 @@ export function dataText(data, cut, place = FIRST_TEXT_BLOCK) {
232
290
  const which = outFirst > 0
233
291
  ? `: the ${outFirst} whose ${firstOut?.field} is ${(firstOut?.values ?? []).join(" or ")} (${firstOut?.why})${out.size > outFirst ? `, and the last ${out.size - outFirst} of the rest` : ""}`
234
292
  : `, the last ${out.size} in order`;
235
- const left = `Only ${kept.length} of the ${n} rows of ${cut.rows} are in it; the other ${out.size} are in structuredContent.data only${which}.`;
293
+ const elsewhere = place.leftOut ? place.leftOut(out.size, which) : `the other ${out.size} are in structuredContent.data only${which}`;
294
+ const left = `Only ${kept.length} of the ${n} rows of ${cut.rows} are in it; ${elsewhere}.`;
236
295
  return { text: shown, said: say(rowsSaid, ...sidesSaid, left, cut.whole, cut.page?.(kept.length, n, data)) };
237
296
  }
238
297
  base = { ...beside, [cut.rows]: cutRows };
@@ -250,5 +309,258 @@ export function dataText(data, cut, place = FIRST_TEXT_BLOCK) {
250
309
  const more = last.caps.size > 8 ? `, and ${last.caps.size - 8} more` : "";
251
310
  const lists = last.caps.size ? `Every list in it keeps at most its first ${last.n} ${last.n === 1 ? "entry" : "entries"}: ${listed(named)}${more}, each named with its full length.` : "";
252
311
  const strings = last.s.clipped ? `Every string in it longer than ${last.clip} characters keeps its first ${last.clip}, ending in "…".` : "";
253
- return { text: last.text, said: say(rowsSaid, ...sidesSaid, lists, strings, cut?.whole) };
312
+ const said = say(rowsSaid, ...sidesSaid, lists, strings, cut?.whole);
313
+ if (last.caps.size === 0)
314
+ return { text: last.text, said };
315
+ return { text: last.text, said, capped: Object.fromEntries([...last.caps].map(([p, len]) => [p || "the answer", { total: len, returned: Math.min(last.n, len) }])) };
316
+ }
317
+ // ── The structured result's budget (#3013) ──
318
+ //
319
+ // What a success's structured result may cost, all of it (the envelope, its notes and data), as
320
+ // compact JSON, which is how a client that hands structuredContent to a model reads it: the same
321
+ // number as the first text block's. See "The structured result, per client" above.
322
+ export const STRUCTURED_BUDGET = DATA_TEXT_BUDGET;
323
+ // The clause of a paging tool's DATA CUT sentence that names the page size whose rows fit data
324
+ // whole, given PageAt.wholeFit for its rows (search_cves, search_vendor_advisories).
325
+ export const wholePage = (n) => n > 0
326
+ ? `a page of ${n} ${n === 1 ? "row" : "rows"} or fewer like these fits data whole.`
327
+ : `even a page of one row like these is past ${STRUCTURED_BUDGET} characters with that row whole, so its data is cut too.`;
328
+ // A tool that names no list: only step 2 (every list at the top level of data) cuts its data.
329
+ export const NO_DATA_CUT = { steps: [] };
330
+ const calls = new AsyncLocalStorage();
331
+ export function answering(full, cut, fn) {
332
+ return calls.run({ full: full === true, cut }, fn);
333
+ }
334
+ export const answeringNow = () => calls.getStore();
335
+ // The argument every tool takes (#3013), and what it says, one constant string. It names no other
336
+ // tool and directs no model (test/description-policy.test.mjs reads inputSchema descriptions too).
337
+ export const FULL_ARG_DESCRIPTION = "true: data in the structured result is the answer whole, however long. Without it, a structured result past 30,000 characters of JSON has its data cut (DATA CUT): in an order the note states, the entries of its longest lists keep only some of their fields, and then the lists only some of their entries; data_truncated is true, and truncated gives each list cut with its total, the number returned and whether its entries are thinned. Whole data is for a program; a client that sizes structured results, as Claude Code does against MAX_MCP_OUTPUT_TOKENS, can refuse an answer past its cap.";
338
+ export const FULL_ARG = z.boolean().optional().describe(FULL_ARG_DESCRIPTION);
339
+ const thinned = (e, keep) => isPlainObject(e) ? cutRow(e, keep, { keep, clip: Number.POSITIVE_INFINITY }, { left: new Set(), clipped: 0, capped: 0 }) : e;
340
+ // An entry JSON cannot write (undefined) is written null in a list.
341
+ const entryChars = (e) => JSON.stringify(e)?.length ?? 4;
342
+ const listOf = (data, at) => at.reduce((o, k) => field(o, k), data);
343
+ function listAt(data, path, firstOut, thin, newest) {
344
+ const at = path.split(".");
345
+ const v = listOf(data, at);
346
+ if (!Array.isArray(v) || v.length === 0)
347
+ return undefined;
348
+ const n = v.length;
349
+ const entries = thin ? v.map((e) => thinned(e, thin)) : v;
350
+ const lens = entries.map(entryChars);
351
+ // The indices in the order they leave: from the end, or, for a series kept newest, from the start.
352
+ const leavingOrder = (i) => (newest ? i : n - 1 - i);
353
+ const order = [];
354
+ for (const value of firstOut?.values ?? [])
355
+ for (let j = 0; j < n; j++)
356
+ if (field(v[leavingOrder(j)], firstOut?.field ?? "") === value)
357
+ order.push(leavingOrder(j));
358
+ const named = order.length;
359
+ const queued = new Set(order);
360
+ for (let j = 0; j < n; j++)
361
+ if (!queued.has(leavingOrder(j)))
362
+ order.push(leavingOrder(j));
363
+ const leaving = [0];
364
+ for (const i of order)
365
+ leaving.push(leaving[leaving.length - 1] + lens[i]);
366
+ const all = leaving[n];
367
+ const kept = (k) => Math.max(0, Math.min(n, k));
368
+ return {
369
+ path,
370
+ at,
371
+ n,
372
+ firstOut,
373
+ thin,
374
+ ...(newest ? { newest } : {}),
375
+ named,
376
+ chars: (k) => all - leaving[n - kept(k)] + Math.max(0, kept(k) - 1),
377
+ keep: (k) => {
378
+ const out = new Set(order.slice(0, n - kept(k)));
379
+ return entries.filter((_, i) => !out.has(i));
380
+ },
381
+ };
382
+ }
383
+ // `o` with the list at `at` replaced by `v`, each object on the way copied, its keys in their order.
384
+ function withList(o, at, v) {
385
+ const [k, ...rest] = at;
386
+ return { ...o, [k]: rest.length ? withList(o[k], rest, v) : v };
387
+ }
388
+ const orListed = (xs) => (xs.length < 2 ? xs.join("") : `${xs.slice(0, -1).join(", ")} or ${xs[xs.length - 1]}`);
389
+ // The fields a thinning level keeps, as a sentence says them.
390
+ const keepSaid = (keep) => listed(keep.map((x) => (typeof x === "string" ? x : `${x[0]} (each with ${listed([...x[1]])})`)));
391
+ // What a cut keeps of each list it has cut so far, as truncated and as the DATA CUT sentences.
392
+ function cutSaid(cut, kept, wholeChars, data, over, wholeFit) {
393
+ const isThinned = ({ list, k }) => list.thin !== undefined && k > 0;
394
+ const cutOnes = kept.filter((x) => x.k < x.list.n || isThinned(x));
395
+ if (cutOnes.length === 0)
396
+ return { said: "" };
397
+ const truncated = Object.fromEntries(cutOnes.map((x) => [x.list.path, { total: x.list.n, returned: x.k, ...(isThinned(x) ? { thinned: true } : {}) }]));
398
+ const parts = [];
399
+ const byKept = new Map();
400
+ for (const x of cutOnes) {
401
+ const { list, k } = x;
402
+ const out = list.n - k;
403
+ const first = Math.min(out, list.named);
404
+ const end = list.newest ? "oldest" : "last";
405
+ const which = list.firstOut ? `whose ${list.firstOut.field} is ${orListed(list.firstOut.values)} (${list.firstOut.why})` : "";
406
+ const leftOut = out === 0 ? "" : first === 0 ? `, the ${end} ${out} left out` : first === out ? `, the ${out} left out being entries ${which}` : `, the ${out} left out being the ${first} ${which} and the ${end} ${out - first} of the rest`;
407
+ if (isThinned(x)) {
408
+ parts.push(`${list.path} keeps ${k === list.n ? `all ${k}` : `${k} of its ${list.n}`} entries, each cut to ${keepSaid(list.thin)}${leftOut}`);
409
+ continue;
410
+ }
411
+ if (!list.firstOut || first === 0 || k === 0) {
412
+ const newest = list.newest === true && k > 0;
413
+ const key = `${k}:${newest}`;
414
+ const group = byKept.get(key);
415
+ if (group)
416
+ group.lists.push(list);
417
+ else {
418
+ const g = { k, newest, lists: [list] };
419
+ byKept.set(key, g);
420
+ parts.push(g);
421
+ }
422
+ continue;
423
+ }
424
+ parts.push(`${list.path} keeps ${k} of its ${list.n} entries${leftOut}`);
425
+ }
426
+ const cuts = parts.map((p) => {
427
+ if (typeof p === "string")
428
+ return p;
429
+ const paths = listed(p.lists.map((l) => l.path));
430
+ const which = p.newest ? "newest" : "first";
431
+ if (p.lists.length === 1)
432
+ return p.k === 0 ? `${paths} keeps none of its ${p.lists[0].n} entries` : `${paths} keeps its ${which} ${p.k} of ${p.lists[0].n} entries`;
433
+ return p.k === 0 ? `${paths} keep none of their entries` : `${paths} keep their ${which} ${p.k} entries each`;
434
+ });
435
+ const keeps = Object.fromEntries(cutOnes.filter(isThinned).map((x) => [x.list.path, x.list.thin]));
436
+ const page = cut.page?.(truncated, data, { keeps, wholeFit }) ?? "";
437
+ const said = [
438
+ `DATA CUT: with data whole, this structured result is ${wholeChars} characters of JSON, more than the ${STRUCTURED_BUDGET} it holds unless the call says full: true, so its data is cut: ${cuts.join("; ")}.`,
439
+ "truncated gives the total and the number returned of each list cut, and thinned where its entries are cut to some of their fields.",
440
+ page,
441
+ over ? `Cut so, the structured result is still past ${STRUCTURED_BUDGET} characters: data holds no other list to cut.` : "",
442
+ "The same call with full: true returns data whole.",
443
+ ];
444
+ return { truncated, said: said.filter(Boolean).join(" ") };
445
+ }
446
+ /**
447
+ * The structured result for `data` cut to STRUCTURED_BUDGET, as `cut` says (see "The structured
448
+ * result, per client" above): `structured` builds the result for a data, its truncated and the DATA
449
+ * CUT sentences, and is called with data null to size the rest of it. `wholeChars` is the result's
450
+ * size with data whole. undefined when data holds no list to cut.
451
+ */
452
+ export function cutData(data, cut, wholeChars, structured) {
453
+ if (!isPlainObject(data))
454
+ return undefined;
455
+ const named = new Set(cut.steps.flatMap((s) => s.lists));
456
+ const steps = [...cut.steps, { lists: Object.keys(data).filter((k) => !named.has(k)) }];
457
+ // Each list cut so far, in the order cut, at the level it was thinned to, how many entries it
458
+ // keeps, and the step that cut it.
459
+ const kept = new Map();
460
+ const apply = (also = []) => [...kept.values(), ...also].reduce((d, { list, k }) => withList(d, list.at, list.keep(k)), data);
461
+ // PageAt.wholeFit: of the list at `path`, the most first entries, each whole, that the result
462
+ // holds within the budget with the rest of data whole and no DATA CUT sentence (structured with
463
+ // truncated undefined): the shape of the same call at that page size. Counted once per path.
464
+ const wholeFits = new Map();
465
+ const wholeFit = (path) => {
466
+ const known = wholeFits.get(path);
467
+ if (known !== undefined)
468
+ return known;
469
+ const at = path.split(".");
470
+ const v = listOf(data, at);
471
+ let k = 0;
472
+ if (Array.isArray(v)) {
473
+ let chars = JSON.stringify(structured(withList(data, at, []), undefined, "")).length;
474
+ for (const e of v) {
475
+ chars += entryChars(e) + (k > 0 ? 1 : 0);
476
+ if (chars > STRUCTURED_BUDGET)
477
+ break;
478
+ k++;
479
+ }
480
+ }
481
+ wholeFits.set(path, k);
482
+ return k;
483
+ };
484
+ // The result's size with this step's lists at k entries each: the rest of the result (data
485
+ // null), data with this step's lists empty (`base`), and their entries.
486
+ const size = (lists, k, base) => {
487
+ const all = [...kept.values(), ...lists.map((list) => ({ list, k: Math.min(k, list.n) }))];
488
+ const { truncated, said } = cutSaid(cut, all, wholeChars, data, false, wholeFit);
489
+ return JSON.stringify(structured(null, truncated, said)).length - "null".length + base + lists.reduce((t, l) => t + l.chars(k), 0);
490
+ };
491
+ let fits = false;
492
+ for (const [s, step] of steps.entries()) {
493
+ // Every entry whole, then each thinning level in turn, every entry kept; at the leanest level
494
+ // the entries leave until the result fits.
495
+ const levels = [undefined, ...(step.thin ?? [])];
496
+ let lists = [];
497
+ let k = 0;
498
+ for (const [i, thin] of levels.entries()) {
499
+ lists = step.lists.map((p) => listAt(data, p, step.leaveOutFirst, thin, step.keepNewest)).filter((l) => l !== undefined && !kept.has(l.path));
500
+ if (lists.length === 0)
501
+ break;
502
+ const base = JSON.stringify(apply(lists.map((list) => ({ list, k: 0 })))).length;
503
+ const top = Math.max(...lists.map((l) => l.n));
504
+ if (i < levels.length - 1) {
505
+ if (size(lists, top, base) <= STRUCTURED_BUDGET) {
506
+ k = top;
507
+ fits = true;
508
+ break;
509
+ }
510
+ continue;
511
+ }
512
+ if (size(lists, 0, base) <= STRUCTURED_BUDGET) {
513
+ // The most entries each list of the step keeps with the result inside the budget.
514
+ let hi = top;
515
+ while (k < hi) {
516
+ const mid = Math.ceil((k + hi) / 2);
517
+ if (size(lists, mid, base) <= STRUCTURED_BUDGET)
518
+ k = mid;
519
+ else
520
+ hi = mid - 1;
521
+ }
522
+ fits = true;
523
+ }
524
+ }
525
+ for (const list of lists)
526
+ kept.set(list.path, { list, k: Math.min(k, list.n), step: s });
527
+ if (fits)
528
+ break;
529
+ }
530
+ // The room the step that fits leaves goes back to the steps emptied before it, the last first,
531
+ // each step's lists keeping at most the same number of their entries, as many as fit, at the
532
+ // level they were thinned to (scan_manifest's not_checked, emptied while its rows were whole,
533
+ // keeps its entries once the thinned rows fit).
534
+ if (fits) {
535
+ const exact = () => {
536
+ const { truncated, said } = cutSaid(cut, [...kept.values()], wholeChars, data, false, wholeFit);
537
+ return JSON.stringify(structured(apply(), truncated, said)).length;
538
+ };
539
+ const last = Math.max(...[...kept.values()].map((e) => e.step));
540
+ for (let s = last - 1; s >= 0; s--) {
541
+ const entries = [...kept.values()].filter((e) => e.step === s);
542
+ if (entries.length === 0)
543
+ continue;
544
+ const at = (k) => {
545
+ for (const e of entries)
546
+ e.k = Math.min(k, e.list.n);
547
+ };
548
+ let k = Math.min(...entries.map((e) => e.k));
549
+ let hi = Math.max(...entries.map((e) => e.list.n));
550
+ while (k < hi) {
551
+ const mid = Math.ceil((k + hi) / 2);
552
+ at(mid);
553
+ if (exact() <= STRUCTURED_BUDGET)
554
+ k = mid;
555
+ else
556
+ hi = mid - 1;
557
+ }
558
+ at(k);
559
+ }
560
+ }
561
+ const { truncated, said } = cutSaid(cut, [...kept.values()], wholeChars, data, !fits, wholeFit);
562
+ if (truncated === undefined)
563
+ return undefined;
564
+ const d = apply();
565
+ return { data: d, truncated, said, structured: structured(d, truncated, said) };
254
566
  }
@@ -27,7 +27,7 @@
27
27
  // The JSON is relayed verbatim as data (#1874): every match keeps kev_listed, ransomware,
28
28
  // epss_score, effective_score, effective_severity and score_assessed as the API sent them.
29
29
  import * as z from "zod";
30
- import { GET_CVE_WHOLE, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
30
+ import { answering, FULL_ARG, GET_CVE_WHOLE, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
31
31
  const TOOL = "check_affected";
32
32
  export const MATCH_PATH = "/api/v1/public/cves/match";
33
33
  // handler.go, the four request headers the endpoint reads before the query string.
@@ -74,10 +74,11 @@ const Match = z.looseObject({
74
74
  vendor_unknown: opt(z.boolean()).describe("true: the vendor was not verified, so the match is real but its attribution to your vendor is not."),
75
75
  match_reason: opt(z.string()),
76
76
  version_evidence: opt(z.string()),
77
- // #2834: registry matches only (core-backend cve/pkgmatch_fixedin.go registryFixedIn).
77
+ // #2834, #3074: registry matches only (core-backend cve/pkgmatch_fixedin.go registryFixFor).
78
78
  interval: opt(z.looseObject({ introduced: opt(z.string()), fixed: opt(z.string()), last_affected: opt(z.string()) })).describe("Registry match: the advisory interval that holds this version (fixed is exclusive, last_affected inclusive); absent when no interval holds it."),
79
- fixed_in: opt(z.string()).describe("Registry match: the fixed bound of the advisory interval that holds this version, always above it. null when that interval records no fix (it ends at last_affected, or has no end) or no interval holds the version, with fixed_in_reason saying which. Absent on a CPE match."),
79
+ fixed_in: opt(z.string()).describe("Registry match: the lowest version above this one that no affected interval of the CVE's record for the package holds, always above it: the fixed bound of the interval that holds this version, moved past any overlapping interval that still holds that bound (fixed_in_detail then names it). null when no such version is on record (an interval on the way records no fix: it ends at last_affected, or has no end) or no interval holds the version, with fixed_in_reason saying which. Absent on a CPE match."),
80
80
  fixed_in_reason: opt(z.string()).describe("Why fixed_in is null; absent when fixed_in is a version."),
81
+ fixed_in_detail: opt(z.string()).describe("Set when fixed_in is not the fixed bound of the interval that holds this version: the overlapping intervals of the same record it moved past, which still hold that bound."),
81
82
  });
82
83
  const Undetermined = z.looseObject({
83
84
  cve_id: opt(z.string()),
@@ -130,7 +131,7 @@ const COVERAGE = z.strictObject({
130
131
  // Constant strings, so marketing-site lib/mcpToolClaims.test.ts can fold them should it read
131
132
  // this file.
132
133
  export const CHECK_AFFECTED_TITLE = "Am I affected? (product or package at a version)";
133
- export const CHECK_AFFECTED_DESCRIPTION = "Whether a product or package at a given version is affected by known CVEs, from the same matcher as echelongraph.io/am-i-affected. The CPE path takes product (an NVD CPE product token, such as openssl or nginx) and version, and returns the CVEs whose NVD CPE match criteria include that product at that version; each match names the vendor NVD asserts (cpe_vendor) and whether it was verified (vendor_unknown). The registry path takes ecosystem (npm, PyPI, Maven and other OSV ecosystem names), package and version, and decides each OSV advisory EchelonGraph holds for that package as affected, not affected or undetermined. count depends on assessed: assessed false means the lookup did not evaluate this component, not_assessed_reason says why, and a count of 0 there is not a finding of not affected. An advisory undecidable at this version is reported as undetermined (undetermined_count, and up to 50 of them in undetermined), never as safe. Each match carries cve_id, kev_listed, ransomware, epss_score, effective_score, effective_severity and score_assessed (false: not yet scored, so echelongraph_score is withheld). A registry match also carries interval, the advisory interval holding this version, and fixed_in, its fixed bound, or null with fixed_in_reason; a CPE match carries no fixed_in. All four inputs travel in request headers, never in the URL." +
134
+ export const CHECK_AFFECTED_DESCRIPTION = "Whether a product or package at a given version is affected by known CVEs, from the same matcher as echelongraph.io/am-i-affected. The CPE path takes product (an NVD CPE product token, such as nginx) and version, and returns the CVEs whose NVD CPE match criteria include that product at that version; each match names the vendor NVD asserts (cpe_vendor) and whether it was verified (vendor_unknown). The registry path takes ecosystem (npm, PyPI, Maven and other OSV ecosystem names), package and version, and decides each OSV advisory EchelonGraph holds for that package as affected, not affected or undetermined. count depends on assessed: assessed false means the lookup did not evaluate this component, not_assessed_reason says why, and a count of 0 there is not a finding of not affected. An advisory undecidable at this version is reported as undetermined (undetermined_count, and up to 50 of them in undetermined), never as safe. Each match carries cve_id, kev_listed, ransomware, epss_score, effective_score, effective_severity and score_assessed (false: not yet scored, so echelongraph_score is withheld). A registry match also carries interval, the advisory interval holding it, and fixed_in, the lowest unaffected version above it, or null with fixed_in_reason; a CPE match carries no fixed_in. All four inputs travel in request headers, never in the URL." +
134
135
  " " +
135
136
  TEXT_BUDGET_DESCRIPTION +
136
137
  " Cut, the excluded and undetermined samples keep their first 10 entries, each its cve_id and reason, cve_ids keeps its first 10, and each match keeps cve_id, kev_listed, ransomware, epss_score, effective_score, score_assessed and fixed_in at least. Every CPE match stays in the text; near the cap, a registry list's last matches can leave it, and the note says how many.";
@@ -139,6 +140,7 @@ export const CHECK_AFFECTED_INPUT = z.object({
139
140
  ecosystem: z.string().optional().describe("Registry path: the package's ecosystem, such as npm, PyPI or Maven. Give with package, not with product."),
140
141
  package: z.string().optional().describe("Registry path: the package name in that ecosystem, such as lodash."),
141
142
  version: z.string().describe("The version to check, such as 3.0.0."),
143
+ full: FULL_ARG,
142
144
  });
143
145
  const field = (o, k) => (o !== null && typeof o === "object" ? o[k] : undefined);
144
146
  const num = (o, k) => {
@@ -172,22 +174,24 @@ function matchesNote(matches) {
172
174
  }
173
175
  return out;
174
176
  }
175
- // #2834: what the registry matches say about a fix. fixed_in is the fixed bound of the interval
176
- // that holds this version (core-backend cve/pkgmatch_fixedin.go), so it is per match: an advisory
177
- // with several branches (Log4Shell: 2.3.1, 2.12.2, 2.15.0) names the one for this version.
177
+ // #2834: what the registry matches say about a fix. fixed_in is the first version above this one
178
+ // that no affected interval of the match's record holds (core-backend cve/pkgmatch_fixedin.go):
179
+ // the fixed bound of the interval that holds this version, moved past any overlapping interval
180
+ // that still holds it (#3074), so it is per match: an advisory with several branches (Log4Shell:
181
+ // 2.3.1, 2.12.2, 2.15.0) names the one for this version.
178
182
  function fixedInNote(matches) {
179
183
  const registry = matches.filter((m) => m !== null && typeof m === "object" && "fixed_in" in m);
180
184
  if (registry.length === 0)
181
185
  return "";
182
186
  const fixed = registry.filter((m) => typeof field(m, "fixed_in") === "string").length;
183
187
  const none = registry.length - fixed;
184
- return ` fixed_in, the fixed bound of the advisory interval that holds this version: ${fixed === registry.length ? `set on every match` : `set on ${fixed} of ${registry.length} matches`}${none > 0 ? `; null on ${none}, where the advisory records no fix for this version (fixed_in_reason says why)` : ""}.`;
188
+ return ` fixed_in, the first version above this one that no affected interval of the advisory holds: ${fixed === registry.length ? `set on every match` : `set on ${fixed} of ${registry.length} matches`}${none > 0 ? `; null on ${none}, where the advisory records no fix for this version (fixed_in_reason says why)` : ""}.`;
185
189
  }
186
190
  // #2783: a production match is about 2,100 characters as pretty JSON, most of it the CVE's
187
191
  // description and match_reason, so openssl 3.0.0 (71 matches) was 156,058 characters of text and
188
192
  // linux_kernel 5.10.0 (200, the API's cap) 364,408. Past DATA_TEXT_BUDGET each match in the first
189
193
  // text block keeps its scores, flags, vendor attribution and match_reason (for a registry match,
190
- // the advisory interval its version falls inside: "[A, B)" names the fixed version B, "[A, B]" the
194
+ // the advisory interval its version falls inside: "[A, B)" names that interval's fixed bound B, "[A, B]" the
191
195
  // last affected one, #2830) with the
192
196
  // description cut to 200 characters; then the same without the description; then only the fields
193
197
  // the description promises; then, for a list at the cap, without cpe_vendor and vendor_unknown;
@@ -221,7 +225,7 @@ const CHECK_AFFECTED_TEXT = {
221
225
  keep: [
222
226
  "cve_id", "severity", "cvss_v3_score", "description", "kev_listed", "ransomware", "epss_score", "echelongraph_score", "echelongraph_severity", "effective_score", "effective_severity",
223
227
  "score_assessed", "score_unassessed_reason", "matched_criteria", "cpe_vendor", "vendor_unknown", "match_confidence", "match_reason", "version_evidence", "fixed_in",
224
- "fixed_in_reason",
228
+ "fixed_in_reason", "fixed_in_detail",
225
229
  ],
226
230
  clip: 200,
227
231
  },
@@ -250,6 +254,32 @@ const CHECK_AFFECTED_TEXT = {
250
254
  ],
251
255
  whole: `${GET_CVE_WHOLE}, and cve_intel a CVE's affected packages and fixed versions.`,
252
256
  };
257
+ // #3013: the structured result's cut. A production match is about 1,650 characters of compact JSON
258
+ // (its description, matched criteria and match reason), so openssl 3.0.0's 71 matches were 147,564
259
+ // characters of structured result and linux_kernel 5.10.0's 200 (the API's cap) 331,682. As in the
260
+ // text, the samples beside the matches go first (excluded and undetermined, each to at most the same
261
+ // number of its first entries; excluded_count and undetermined_count count every entry), then the
262
+ // matches, and cve_ids, each match's cve_id in order, last: it names every match in a few thousand
263
+ // characters. Before any match leaves, every match is thinned (most of a match is its description,
264
+ // about 1,400 characters for openssl, which get_cve returns): first to its scores, flags, the
265
+ // criteria it matched and fixed_in, then to the fields this tool's description says a match keeps
266
+ // at least, with fixed_in_reason. Only then do matches leave from the end (the lowest ranked).
267
+ const CHECK_AFFECTED_DATA = {
268
+ steps: [
269
+ { lists: ["excluded", "undetermined"] },
270
+ {
271
+ lists: ["matches"],
272
+ thin: [
273
+ [
274
+ "cve_id", "severity", "cvss_v3_score", "kev_listed", "ransomware", "epss_score", "echelongraph_score", "echelongraph_severity", "effective_score", "effective_severity", "score_assessed",
275
+ "score_unassessed_reason", "matched_criteria", "cpe_vendor", "vendor_unknown", "match_confidence", "version_evidence", "fixed_in", "fixed_in_reason",
276
+ ],
277
+ ["cve_id", "kev_listed", "ransomware", "epss_score", "effective_score", "score_assessed", "fixed_in", "fixed_in_reason"],
278
+ ],
279
+ },
280
+ { lists: ["cve_ids"] },
281
+ ],
282
+ };
253
283
  async function checkAffected(kit, a) {
254
284
  const product = (a.product ?? "").trim();
255
285
  const ecosystem = (a.ecosystem ?? "").trim();
@@ -351,5 +381,5 @@ export function registerCheckAffected(server, kit) {
351
381
  inputSchema: CHECK_AFFECTED_INPUT,
352
382
  outputSchema: output,
353
383
  annotations: kit.annotations,
354
- }, async (a) => kit.checked(TOOL, output, await checkAffected(kit, a)));
384
+ }, async (a) => kit.checked(TOOL, output, await answering(a.full, CHECK_AFFECTED_DATA, () => checkAffected(kit, a))));
355
385
  }
@@ -1,5 +1,5 @@
1
1
  import * as z from "zod";
2
- import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
2
+ import { answering, FULL_ARG, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
3
3
  import { CLEAN_NOTE, DISTRO_NOTE, FRESHNESS_NOTE, MAX_PURLS, MEASURED_AT_NOTE, METHOD, batchDataShape, dedupe, field, isObj, plural, purlsRefusal, sendBatches, sendingCoverage, sendingCoverageShape, sendingSentences, verdictSentences, } from "./match_batch.js";
4
4
  // #2835: the batch loop, its constants and the row schema moved to match_batch.ts, which
5
5
  // scan_manifest shares; re-exported here, where the tests and prompts.ts import them.
@@ -25,7 +25,7 @@ export const CHECK_SBOM_TITLE = "Check an SBOM against the advisory corpus";
25
25
  export const CHECK_SBOM_ROUTING = "For container images and Kubernetes pods, the input is an SBOM of each image, or its purls.";
26
26
  export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGraph's advisory corpus, one verdict per component. " +
27
27
  CHECK_SBOM_ROUTING +
28
- " Pass purls (package URLs, up to 2,000 distinct) or sbom (a CycloneDX or SPDX JSON document, up to 5,000,000 characters). This server reads the purls from the document; only they are sent to the API, in POST bodies of at most 200, never in a URL; the document itself is not sent on. Run from npm, this server is on your machine; over the hosted endpoint (mcp.echelongraph.io) it is EchelonGraph's, and the document is the request body, accepted up to 6 MiB. data.results holds one row per component sent, in order, with verdict (affected, not_affected, undetermined or not_assessed), not_assessed_reason, cve_ids, matched_package and matched_via (a deb or apk purl's upstream qualifier matches as its source package); each match carries fixed_in (its interval's fixed bound) or null with fixed_in_reason. data.summary counts the verdicts. not_affected is the only clean verdict: not_assessed (no verdict, as for a deb, apk or rpm purl without a distro qualifier naming its release, which EchelonGraph does not guess) and undetermined are not, and the note counts both. The API allows 1,200 components a minute per caller; on a 429 the tool waits out Retry-After, up to 50 seconds a call, then answers what it has, the rest in data.not_sent_purls, unchecked and not clean. Input with more than 2,000 distinct purls is refused, not truncated." +
28
+ " Pass purls (package URLs, up to 2,000 distinct) or sbom (a CycloneDX or SPDX JSON document, up to 5,000,000 characters). This server reads the purls from the document; only they are sent to the API, in POST bodies of at most 200, never in a URL; the document itself is not sent on. Run from npm, this server is on your machine; over the hosted endpoint (mcp.echelongraph.io) it is EchelonGraph's, and the document is the request body, accepted up to 6 MiB. data.results holds one row per component sent, in order, with verdict (affected, not_affected, undetermined or not_assessed), not_assessed_reason, cve_ids, matched_package and matched_via (a deb or apk purl's upstream qualifier matches as its source package); each match carries fixed_in (the next unaffected version) or null with fixed_in_reason. data.summary counts the verdicts. not_affected is the only clean verdict: not_assessed (no verdict, as for a deb, apk or rpm purl without a distro qualifier naming its release, which EchelonGraph does not guess) and undetermined are not, and the note counts both. The API allows 1,200 components a minute per caller; on a 429 the tool waits out Retry-After, up to 50 seconds a call, then answers what it has, the rest in data.not_sent_purls, unchecked and not clean. Input with more than 2,000 distinct purls is refused, not truncated." +
29
29
  " " +
30
30
  TEXT_BUDGET_DESCRIPTION +
31
31
  " Cut, each row keeps index, purl, verdict, not_assessed_reason and cve_ids at least, each match its fixed_in while kept; not_affected rows leave the text first, then not_assessed ones; not_sent_purls keeps its first 10.";
@@ -34,7 +34,7 @@ export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGr
34
34
  // text, and 2,000 purls about 1,200,000. Past DATA_TEXT_BUDGET each row in the first text block
35
35
  // keeps its verdict, counts, cve_ids, matched_package and matched_via (#2836), and each match its
36
36
  // scores, flags, fixed_in (#2834) and match_reason (the
37
- // advisory interval the version falls inside: "[A, B)" names the fixed version B, "[A, B]" the last
37
+ // advisory interval the version falls inside: "[A, B)" names that interval's fixed bound B, "[A, B]" the last
38
38
  // affected one, #2830) without the
39
39
  // description; then the same without match_reason; then only index, purl, verdict,
40
40
  // not_assessed_reason and cve_ids. When rows must still go, the clean ones (not_affected) leave
@@ -49,7 +49,7 @@ export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGr
49
49
  // reading the list. noteFor gives that order (READ_ORDER) beside the position, so this sentence
50
50
  // points to it.
51
51
  export function sbomText(sent) {
52
- const matches = ["cve_id", "severity", "effective_severity", "effective_score", "kev_listed", "ransomware", "epss_score", "score_assessed", "fixed_in"];
52
+ const matches = ["cve_id", "severity", "effective_severity", "effective_score", "kev_listed", "ransomware", "epss_score", "score_assessed", "fixed_in", "fixed_in_reason"];
53
53
  const row = [
54
54
  "index", "purl", "ecosystem", "package", "version", "verdict", "assessed", "not_assessed_reason", "count", "not_affected_count", "undetermined_count", "cve_ids", "matched_package", "matched_via",
55
55
  ];
@@ -69,11 +69,49 @@ export function sbomText(sent) {
69
69
  ],
70
70
  leaveOutFirst: { field: "verdict", values: ["not_affected", "not_assessed"], why: "data.summary counts every verdict" },
71
71
  // check_affected cuts its text too past the budget (#2802): only its structuredContent.data is
72
- // a component's matches whole.
73
- whole: "check_affected, given a row's ecosystem, package and version, returns that component's matches, whole in its structuredContent.data, and cve_intel a CVE's affected packages and fixed versions.",
72
+ // a component's matches whole, with full: true (#3013).
73
+ whole: "check_affected, given a row's ecosystem, package and version, returns that component's matches, whole in its structuredContent.data with full: true, and cve_intel a CVE's affected packages and fixed versions.",
74
74
  page: (shown) => `To read every row in the text, check ${shown} or fewer purls per call.`,
75
75
  };
76
76
  }
77
+ // #3013: the structured result's cut. A production row is about 1,900 characters of compact JSON
78
+ // (its matches, each a CVE with its description), so the 50-component Juice Shop document was 98,682
79
+ // characters of structured result and 2,000 purls 3,790,686. The lists beside the rows go first
80
+ // (not_sent_purls: the note says from which position of the input they run, and in what order; and,
81
+ // for scan_manifest, not_checked, which coverage.not_checked_by_reason counts). Then, before any
82
+ // row leaves, every row is thinned to SBOM_ROW_THIN: what a row says of the component and its
83
+ // verdict, and per match what a triage reads (a match's description, about 535 characters, is
84
+ // get_cve's); the first #3013 cut, before release, kept 3 of Juice Shop's 50 rows whole, all affected, and named the
85
+ // other 7 affected components nowhere a client reading structuredContent sees. Only then do rows
86
+ // leave, as in the text: the not_affected rows first, then the not_assessed ones (data.summary
87
+ // counts every verdict), then the last of the rest, so the affected and undetermined rows are the
88
+ // last to go.
89
+ // #3013's review: a thinned match kept cve_id, kev_listed, epss_score, fixed_in and
90
+ // fixed_in_reason, while the workload_triage prompt reads kev_listed, ransomware, epss_score,
91
+ // effective_score, score_assessed and fixed_in from a row's matches (its step 5 and ordering keys),
92
+ // so a client reading structuredContent lost ransomware and the score it ranks by. Each field a
93
+ // prompt reads from check_sbom's or scan_manifest's matches is kept (test/prompt-fields.test.mjs
94
+ // holds every prompt's to this list).
95
+ export const SBOM_ROW_THIN = [
96
+ "index",
97
+ "purl",
98
+ "verdict",
99
+ "not_assessed_reason",
100
+ "cve_ids",
101
+ "matched_package",
102
+ "matched_via",
103
+ ["matches", ["cve_id", "kev_listed", "ransomware", "epss_score", "effective_score", "score_assessed", "fixed_in", "fixed_in_reason"]],
104
+ ];
105
+ export function sbomData(sides, page) {
106
+ return {
107
+ steps: [
108
+ { lists: sides },
109
+ { lists: ["results"], thin: [SBOM_ROW_THIN], leaveOutFirst: { field: "verdict", values: ["not_affected", "not_assessed"], why: "data.summary counts every verdict" } },
110
+ ],
111
+ page: (t) => (t.results && t.results.returned < t.results.total ? page(t.results.returned) : ""),
112
+ };
113
+ }
114
+ const SBOM_DATA = sbomData(["not_sent_purls"], (k) => `To read every row in data, check ${k} or fewer purls per call.`);
77
115
  // The purls of a CycloneDX JSON document: every components[] entry, nested ones included.
78
116
  // metadata.component (the subject of the SBOM, not a dependency of it) is not checked.
79
117
  function fromCycloneDX(doc) {
@@ -264,11 +302,12 @@ export function registerCheckSbom(server, deps) {
264
302
  .union([z.string(), z.record(z.string(), z.unknown())])
265
303
  .optional()
266
304
  .describe("a CycloneDX JSON or SPDX JSON document, as JSON text or as an object; its purls are read by this MCP server and only they are sent to the API; over the hosted endpoint (mcp.echelongraph.io) the document is the request body"),
305
+ full: FULL_ARG,
267
306
  }),
268
307
  outputSchema: output,
269
308
  annotations: deps.annotations,
270
- }, async (a, ctx) => {
309
+ }, async (a, ctx) => answering(a.full, SBOM_DATA, async () => {
271
310
  const run = checkSbom({ ...deps, signal: ctx.mcpReq.signal }, a);
272
311
  return deps.checked(CHECK_SBOM, output, await (deps.hold ? deps.hold(run) : run));
273
- });
312
+ }));
274
313
  }