echelongraph-mcp 2.6.2 → 2.6.3

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/dist/resources.js CHANGED
@@ -22,6 +22,8 @@ export const METHODOLOGY = `# How EchelonGraph MCP results are measured
22
22
 
23
23
  Every tool result carries an evidence envelope in its structuredContent, and repeats it in its last text block (less what an earlier text block already carries verbatim), so a client that passes text blocks alone still sees it.
24
24
 
25
+ A success's first text block is the API's JSON, cut to fit 30,000 characters when it is longer: the note then says what the cut leaves out and how to read it (TEXT CUT), and structuredContent's data holds the answer whole.
26
+
25
27
  ## Envelope fields
26
28
 
27
29
  | Field | What it holds |
@@ -0,0 +1,247 @@
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
+ //
7
+ // What a success costs in the channel a model reads is mostly its first text block, and through
8
+ // 2.6.2 that block was the API's JSON pretty-printed, whatever its size. Measured on production
9
+ // through https://mcp.echelongraph.io/mcp (2.6.2, 2026-10-04): search_cves {search: "openssl"} at
10
+ // its default limit was 133,300 characters of text, get_cve CVE-2021-44228 80,897, check_affected
11
+ // openssl 3.0.0 156,058 and linux_kernel 5.10.0 364,408, kev_recent at limit 200 105,925 and
12
+ // search_vendor_advisories "log4j" at limit 50 103,147, against #2467's ceiling of 60,000
13
+ // (mcp-server/test/text-bound.mjs). Claude Code warns past 10,000 tokens of tool output and past
14
+ // 25,000 saves the result to a file and hands the model its path instead; Anthropic's guidance to
15
+ // tool authors ("Writing effective tools for agents") is pagination, filtering and truncation with
16
+ // sensible defaults, steering the agent when a response is cut. The MCP spec (2025-06-18 and
17
+ // 2025-11-25, Tools, Structured Content) asks a tool that returns structuredContent to return it
18
+ // serialized in a text block too, as a SHOULD, for backwards compatibility, and SEP-1624 proposes
19
+ // content as the model-facing, token-efficient form and structuredContent as the machine-facing one.
20
+ //
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:
23
+ // 1. pretty-printed, as since 1.x, when that fits;
24
+ // 2. else the same JSON with each element of a list on a line of its own, which parses to the
25
+ // same value, when that fits;
26
+ // 3. else, for a tool that names its list of rows (TextCut), each row cut as the tool's first
27
+ // level says (the fields it keeps, every string in it to its first `clip` characters, ending
28
+ // in "…", every list in it to its first `cap` entries); then, for a tool that names the
29
+ // lists beside its rows (`sides`: check_affected's excluded sample, check_sbom's
30
+ // not_sent_purls), the same level with those lists cut as it says; then each next level with
31
+ // them cut; and when no level fits, rows are left out of the text, those the tool names
32
+ // first, then from the end. A list beside the rows is cut before any row field the first
33
+ // level keeps, and before any row leaves the text;
34
+ // 4. else every list keeps its first n entries, n halving, and then strings are cut too.
35
+ // 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,
37
+ // and how to read the rest in the text: the tool that returns one row whole, and the page size or
38
+ // offset at which a page fits. The cut is deterministic: the same answer is cut the same way.
39
+ export const DATA_TEXT_BUDGET = 30_000;
40
+ // What every tool description says about it, one constant string (marketing-site
41
+ // lib/mcpToolClaims.test.ts folds only constant strings); the suite holds its number to
42
+ // DATA_TEXT_BUDGET's. It uses none of the words that check reviews as the page's own prose (how,
43
+ // when, does, have, on and the rest of its PROSE list), so no reviewed entry goes stale.
44
+ 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.";
45
+ const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
46
+ const field = (o, k) => (isPlainObject(o) ? o[k] : undefined);
47
+ const listed = (xs) => (xs.length < 2 ? xs.join("") : `${xs.slice(0, -1).join(", ")} and ${xs[xs.length - 1]}`);
48
+ // The JSON with each element of a list of objects, at the top or one level down, on a line of its
49
+ // own: the same value as JSON.stringify(data), without the indentation of every nested key.
50
+ function lined(v) {
51
+ const entry = (x) => Array.isArray(x) && x.some((e) => e !== null && typeof e === "object") ? `[\n${x.map((e) => ` ${JSON.stringify(e)}`).join(",\n")}\n ]` : JSON.stringify(x);
52
+ return `{\n${Object.entries(v)
53
+ .map(([k, x]) => ` ${JSON.stringify(k)}: ${entry(x)}`)
54
+ .join(",\n")}\n}`;
55
+ }
56
+ // A string's first n characters and "…", never splitting a surrogate pair.
57
+ const clipString = (s, n) => {
58
+ const end = n > 0 && /[\uD800-\uDBFF]/.test(s[n - 1]) ? n - 1 : n;
59
+ return `${s.slice(0, end)}…`;
60
+ };
61
+ function cutValue(v, clip, cap, s) {
62
+ if (typeof v === "string") {
63
+ if (v.length <= clip)
64
+ return v;
65
+ s.clipped++;
66
+ return clipString(v, clip);
67
+ }
68
+ if (Array.isArray(v)) {
69
+ if (v.length > cap)
70
+ s.capped++;
71
+ return v.slice(0, cap).map((x) => cutValue(x, clip, cap, s));
72
+ }
73
+ if (isPlainObject(v))
74
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, cutValue(x, clip, cap, s)]));
75
+ return v;
76
+ }
77
+ function cutRow(row, keep, level, s, prefix = "") {
78
+ const wanted = new Map(keep.map((k) => (typeof k === "string" ? [k, null] : [k[0], k[1]])));
79
+ const cap = level.cap ?? Number.POSITIVE_INFINITY;
80
+ const out = {};
81
+ for (const [k, v] of Object.entries(row)) {
82
+ if (!wanted.has(k)) {
83
+ s.left.add(`${prefix}${k}`);
84
+ continue;
85
+ }
86
+ const sub = wanted.get(k);
87
+ if (sub && Array.isArray(v)) {
88
+ if (v.length > cap)
89
+ s.capped++;
90
+ out[k] = v.slice(0, cap).map((e) => (isPlainObject(e) ? cutRow(e, sub, level, s, `${prefix}${k}[].`) : cutValue(e, level.clip, cap, s)));
91
+ }
92
+ else {
93
+ out[k] = cutValue(v, level.clip, cap, s);
94
+ }
95
+ }
96
+ return out;
97
+ }
98
+ // Step 4: every list to its first n entries (and, with clip, every string to its first clip
99
+ // characters), recording the longest list cut at each path.
100
+ function capAll(v, n, clip, caps, s, path) {
101
+ if (Array.isArray(v)) {
102
+ if (v.length > n)
103
+ caps.set(path, Math.max(caps.get(path) ?? 0, v.length));
104
+ return v.slice(0, n).map((x) => capAll(x, n, clip, caps, s, `${path}[]`));
105
+ }
106
+ if (isPlainObject(v))
107
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, capAll(x, n, clip, caps, s, path ? `${path}.${k}` : k)]));
108
+ return cutValue(v, clip, Number.POSITIVE_INFINITY, s);
109
+ }
110
+ const CAP_LEVELS = [
111
+ ...[128, 64, 32, 16, 8, 4, 2, 1].map((n) => ({ n, clip: Number.POSITIVE_INFINITY })),
112
+ { n: 1, clip: 200 },
113
+ { n: 0, clip: 50 },
114
+ ];
115
+ // The sentence for a level of a TextCut: what each row leaves out.
116
+ function levelSaid(rows, n, level, s) {
117
+ const parts = [];
118
+ if (s.left.size)
119
+ parts.push(`each of the ${n} rows of ${rows} in it leaves out ${listed([...s.left])}`);
120
+ if (s.clipped)
121
+ parts.push(`every string in a row longer than ${level.clip} characters keeps its first ${level.clip}, ending in "…"`);
122
+ if (s.capped && level.cap !== undefined)
123
+ parts.push(`every list in a row longer than ${level.cap} entries keeps its first ${level.cap}`);
124
+ if (!parts.length)
125
+ return "";
126
+ const said = parts.join("; ");
127
+ return `${said[0].toUpperCase()}${said.slice(1)}.`;
128
+ }
129
+ // The lists beside the rows, each cut as its TextSide says, and the sentences saying how: what its
130
+ // entries leave out, and how many of them the text keeps, then the tool's own sentence.
131
+ function cutSides(data, sides) {
132
+ const fields = {};
133
+ const said = [];
134
+ for (const side of sides) {
135
+ const v = data[side.list];
136
+ if (!Array.isArray(v) || v.length === 0)
137
+ continue;
138
+ const clip = side.clip ?? Number.POSITIVE_INFINITY;
139
+ const cap = side.cap ?? v.length;
140
+ const s = { left: new Set(), clipped: 0, capped: 0 };
141
+ const level = { keep: side.keep ?? [], clip };
142
+ const kept = v.slice(0, cap).map((e) => (side.keep && isPlainObject(e) ? cutRow(e, side.keep, level, s) : cutValue(e, clip, Number.POSITIVE_INFINITY, s)));
143
+ fields[side.list] = kept;
144
+ const parts = [];
145
+ if (kept.length < v.length)
146
+ parts.push(`in it, ${side.list} keeps its first ${kept.length} of ${v.length} entries`);
147
+ if (s.left.size)
148
+ parts.push(`each entry of ${side.list} in it leaves out ${listed([...s.left])}`);
149
+ if (s.clipped)
150
+ parts.push(`every string in ${side.list} longer than ${clip} characters keeps its first ${clip}, ending in "…"`);
151
+ if (!parts.length)
152
+ continue;
153
+ const one = parts.join("; ");
154
+ said.push(`${one[0].toUpperCase()}${one.slice(1)}.`);
155
+ const own = side.said?.(kept.length, v.length, data);
156
+ if (own)
157
+ said.push(own);
158
+ }
159
+ return { fields, said };
160
+ }
161
+ /** The first text block for `data` within DATA_TEXT_BUDGET, and the TEXT CUT sentence when it is cut. */
162
+ export function dataText(data, cut) {
163
+ const pretty = JSON.stringify(data, null, 2);
164
+ if (pretty.length <= DATA_TEXT_BUDGET)
165
+ return { text: pretty, said: "" };
166
+ const layout = (v) => (isPlainObject(v) ? lined(v) : JSON.stringify(v));
167
+ const all = layout(data);
168
+ if (all.length <= DATA_TEXT_BUDGET)
169
+ return { text: all, said: "" };
170
+ const head = `TEXT CUT: the API's answer is ${pretty.length} 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.`;
171
+ const say = (...xs) => [head, ...xs].filter((x) => Boolean(x)).join(" ");
172
+ let base = data;
173
+ let rowsSaid = "";
174
+ let sidesSaid = [];
175
+ const rows = cut && isPlainObject(data) ? data[cut.rows] : undefined;
176
+ if (cut && isPlainObject(data) && Array.isArray(rows) && rows.length > 0 && cut.levels.length > 0) {
177
+ const n = rows.length;
178
+ // The lists beside the rows, cut once; tried with the first level after it fails with them
179
+ // whole, and with every later level.
180
+ const sides = cut.sides?.length ? cutSides(data, cut.sides) : { fields: {}, said: [] };
181
+ const sided = sides.said.length > 0;
182
+ const attempts = cut.levels.flatMap((level, i) => (i === 0 && sided ? [{ level, sided: false }, { level, sided: true }] : [{ level, sided }]));
183
+ let cutRows = [];
184
+ let beside = data;
185
+ for (const attempt of attempts) {
186
+ const { level } = attempt;
187
+ const s = { left: new Set(), clipped: 0, capped: 0 };
188
+ const cap = level.cap ?? Number.POSITIVE_INFINITY;
189
+ cutRows = rows.map((r) => (isPlainObject(r) ? cutRow(r, level.keep, level, s) : cutValue(r, level.clip, cap, s)));
190
+ rowsSaid = levelSaid(cut.rows, n, level, s);
191
+ beside = attempt.sided ? { ...data, ...sides.fields } : data;
192
+ sidesSaid = attempt.sided ? sides.said : [];
193
+ const shown = lined({ ...beside, [cut.rows]: cutRows });
194
+ if (shown.length <= DATA_TEXT_BUDGET)
195
+ return { text: shown, said: say(rowsSaid, ...sidesSaid, cut.whole) };
196
+ }
197
+ // No level fits: rows leave the text, those the tool names first (by value, in its order,
198
+ // each from the end), then the rest from the end, until it fits.
199
+ const lens = cutRows.map((r) => 4 + JSON.stringify(r).length);
200
+ const emptyLen = lined({ ...beside, [cut.rows]: [] }).length;
201
+ const textLen = (kept, sum) => (kept === 0 ? emptyLen : emptyLen + 2 + sum + 2 * kept);
202
+ const firstOut = cut.leaveOutFirst;
203
+ const order = [];
204
+ for (const value of firstOut?.values ?? []) {
205
+ for (let i = n - 1; i >= 0; i--)
206
+ if (field(rows[i], firstOut?.field ?? "") === value)
207
+ order.push(i);
208
+ }
209
+ const queued = new Set(order);
210
+ for (let i = n - 1; i >= 0; i--)
211
+ if (!queued.has(i))
212
+ order.push(i);
213
+ const out = new Set();
214
+ let sum = lens.reduce((t, x) => t + x, 0);
215
+ for (const i of order) {
216
+ if (textLen(n - out.size, sum) <= DATA_TEXT_BUDGET)
217
+ break;
218
+ out.add(i);
219
+ sum -= lens[i];
220
+ }
221
+ const kept = cutRows.filter((_, i) => !out.has(i));
222
+ const shown = lined({ ...beside, [cut.rows]: kept });
223
+ if (kept.length > 0 && shown.length <= DATA_TEXT_BUDGET) {
224
+ const outFirst = firstOut ? [...out].filter((i) => firstOut.values.includes(String(field(rows[i], firstOut.field)))).length : 0;
225
+ const which = outFirst > 0
226
+ ? `: the ${outFirst} whose ${firstOut?.field} is ${(firstOut?.values ?? []).join(" or ")} (${firstOut?.why})${out.size > outFirst ? `, and the last ${out.size - outFirst} of the rest` : ""}`
227
+ : `, the last ${out.size} in order`;
228
+ 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}.`;
229
+ return { text: shown, said: say(rowsSaid, ...sidesSaid, left, cut.whole, cut.page?.(kept.length, n, data)) };
230
+ }
231
+ base = { ...beside, [cut.rows]: cutRows };
232
+ }
233
+ // Step 4: lists, then strings.
234
+ let last = { text: all, caps: new Map(), s: { left: new Set(), clipped: 0, capped: 0 }, n: 0, clip: 0 };
235
+ for (const { n, clip } of CAP_LEVELS) {
236
+ const caps = new Map();
237
+ const s = { left: new Set(), clipped: 0, capped: 0 };
238
+ last = { text: layout(capAll(base, n, clip, caps, s, "")), caps, s, n, clip };
239
+ if (last.text.length <= DATA_TEXT_BUDGET)
240
+ break;
241
+ }
242
+ const named = [...last.caps].slice(0, 8).map(([p, len]) => `${p || "the answer"} (${len})`);
243
+ const more = last.caps.size > 8 ? `, and ${last.caps.size - 8} more` : "";
244
+ 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.` : "";
245
+ const strings = last.s.clipped ? `Every string in it longer than ${last.clip} characters keeps its first ${last.clip}, ending in "…".` : "";
246
+ return { text: last.text, said: say(rowsSaid, ...sidesSaid, lists, strings, cut?.whole) };
247
+ }
@@ -27,6 +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 { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
30
31
  const TOOL = "check_affected";
31
32
  export const MATCH_PATH = "/api/v1/public/cves/match";
32
33
  // handler.go, the four request headers the endpoint reads before the query string.
@@ -125,7 +126,10 @@ const COVERAGE = z.strictObject({
125
126
  // Constant strings, so marketing-site lib/mcpToolClaims.test.ts can fold them should it read
126
127
  // this file.
127
128
  export const CHECK_AFFECTED_TITLE = "Am I affected? (product or package at a version)";
128
- 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. Two lookup paths. The CPE path takes product (the NVD CPE product token, such as openssl or nginx) and version, and returns the CVEs whose NVD CPE match criteria name that product with a version range that includes the version; it matches the token across vendors, so each match names the vendor NVD asserts (cpe_vendor) and whether that vendor 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 record EchelonGraph holds for that package as affected, not affected or undetermined. Read assessed before count: assessed false means the lookup did not evaluate this component, not_assessed_reason says why (product_not_in_cpe_corpus, package_not_cpe_nameable, candidate_load_pending, candidate_window_truncated, package_not_in_advisory_corpus, no_decidable_advisory), the structured result's state is not_assessed, and a count of 0 there must never be reported as not affected. An advisory whose version range cannot be decided at this version is reported as undetermined (undetermined_count, and up to 50 of them in undetermined), never as safe. The answer also carries match_layer (which path answered), capped (the match list stopped at its cap), candidates_capped (not every candidate CVE was loaded), excluded_count (CPE candidates suppressed by the vendor or platform gate), not_affected_count (advisories decided in your favour) and degraded (the lookup ran out of time). Each match carries cve_id, kev_listed, ransomware, epss_score, effective_score, effective_severity and score_assessed (false: EchelonGraph has not scored the CVE yet, so its echelongraph_score is withheld). Product, version, ecosystem and package travel in request headers, never in the URL. Its structured result carries state (measured only when the answer says assessed true), measured_at (null), method (which matcher answered), coverage (assessed and not_assessed_reason first, then the lookup path and the counts above), freshness (null) and notes, with data equal to the API's JSON.";
129
+ 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. Two lookup paths. The CPE path takes product (the NVD CPE product token, such as openssl or nginx) and version, and returns the CVEs whose NVD CPE match criteria name that product with a version range that includes the version; it matches the token across vendors, so each match names the vendor NVD asserts (cpe_vendor) and whether that vendor 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 record EchelonGraph holds for that package as affected, not affected or undetermined. Read assessed before count: assessed false means the lookup did not evaluate this component, not_assessed_reason says why (product_not_in_cpe_corpus, package_not_cpe_nameable, candidate_load_pending, candidate_window_truncated, package_not_in_advisory_corpus, no_decidable_advisory), the structured result's state is not_assessed, and a count of 0 there must never be reported as not affected. An advisory whose version range cannot be decided at this version is reported as undetermined (undetermined_count, and up to 50 of them in undetermined), never as safe. The answer also carries match_layer (which path answered), capped (the match list stopped at its cap), candidates_capped (not every candidate CVE was loaded), excluded_count (CPE candidates suppressed by the vendor or platform gate), not_affected_count (advisories decided in your favour) and degraded (the lookup ran out of time). Each match carries cve_id, kev_listed, ransomware, epss_score, effective_score, effective_severity and score_assessed (false: EchelonGraph has not scored the CVE yet, so its echelongraph_score is withheld). Product, version, ecosystem and package travel in request headers, never in the URL. Its structured result carries state (measured only when the answer says assessed true), measured_at (null), method (which matcher answered), coverage (assessed and not_assessed_reason first, then the lookup path and the counts above), freshness (null) and notes, with data equal to the API's JSON." +
130
+ " " +
131
+ TEXT_BUDGET_DESCRIPTION +
132
+ " Cut, each entry of the excluded and undetermined samples keeps its cve_id and reason, cve_ids keeps its first 10 (each match carries its cve_id), and each match keeps fewer fields, cve_id, kev_listed, effective_score, effective_severity and score_assessed at least, so that every match stays in the text.";
129
133
  export const CHECK_AFFECTED_INPUT = z.object({
130
134
  product: z.string().optional().describe("CPE path: the NVD CPE product token, such as openssl, nginx or linux_kernel. Leave out for the registry path."),
131
135
  ecosystem: z.string().optional().describe("Registry path: the package's ecosystem, such as npm, PyPI or Maven. Give with package, not with product."),
@@ -164,6 +168,55 @@ function matchesNote(matches) {
164
168
  }
165
169
  return out;
166
170
  }
171
+ // #2783: a production match is about 2,100 characters as pretty JSON, most of it the CVE's
172
+ // description and match_reason, so openssl 3.0.0 (71 matches) was 156,058 characters of text and
173
+ // linux_kernel 5.10.0 (200, the API's cap) 364,408. Past DATA_TEXT_BUDGET each match in the first
174
+ // text block keeps its scores, flags, vendor attribution and match_reason (for a registry match,
175
+ // the advisory interval its version falls inside, which names the fixed version) with the
176
+ // description cut to 200 characters; then the same without the description; then only the fields
177
+ // the description promises; and last, for a list at the cap, cve_id, the effective score and
178
+ // band, kev_listed and score_assessed. The lists beside the matches, the excluded sample (up to 50
179
+ // CPE candidates the vendor or platform gate suppressed, each with a sentence of detail, 13,861
180
+ // characters for flash_player 10.0.0) and the undetermined sample (up to 50), keep each entry's
181
+ // cve_id and reason once the first level does not fit with them whole, before any match field
182
+ // that level keeps goes, and the registry path's cve_ids, which is each match's cve_id in the
183
+ // order of matches (core-backend cve/handler.go), keeps its first 10, so that every match stays
184
+ // in the text, 200 at the cap with 50 undetermined beside them: this tool has no page to read
185
+ // more from. structuredContent.data keeps every list whole.
186
+ const CHECK_AFFECTED_TEXT = {
187
+ rows: "matches",
188
+ levels: [
189
+ {
190
+ keep: [
191
+ "cve_id", "severity", "cvss_v3_score", "description", "kev_listed", "ransomware", "epss_score", "echelongraph_score", "echelongraph_severity", "effective_score", "effective_severity",
192
+ "score_assessed", "score_unassessed_reason", "matched_criteria", "cpe_vendor", "vendor_unknown", "match_confidence", "match_reason", "version_evidence",
193
+ ],
194
+ clip: 200,
195
+ },
196
+ {
197
+ keep: [
198
+ "cve_id", "severity", "cvss_v3_score", "kev_listed", "ransomware", "epss_score", "echelongraph_score", "effective_score", "effective_severity", "score_assessed", "matched_criteria",
199
+ "cpe_vendor", "vendor_unknown", "match_reason",
200
+ ],
201
+ clip: 200,
202
+ },
203
+ {
204
+ keep: [
205
+ "cve_id", "severity", "cvss_v3_score", "kev_listed", "ransomware", "epss_score", "echelongraph_score", "effective_score", "effective_severity", "score_assessed", "matched_criteria",
206
+ "cpe_vendor", "vendor_unknown",
207
+ ],
208
+ clip: 100,
209
+ },
210
+ { keep: ["cve_id", "kev_listed", "ransomware", "epss_score", "effective_score", "effective_severity", "score_assessed", "cpe_vendor", "vendor_unknown"], clip: 100 },
211
+ { keep: ["cve_id", "kev_listed", "effective_score", "effective_severity", "score_assessed"], clip: 100 },
212
+ ],
213
+ sides: [
214
+ { list: "cve_ids", cap: 10, said: () => "cve_ids lists each match's cve_id, in the order of matches." },
215
+ { list: "excluded", keep: ["cve_id", "reason"], clip: 100 },
216
+ { list: "undetermined", keep: ["cve_id", "reason"], clip: 100 },
217
+ ],
218
+ whole: "get_cve returns any one of these CVEs' records whole, and cve_intel a CVE's affected packages and fixed versions.",
219
+ };
167
220
  async function checkAffected(kit, a) {
168
221
  const product = (a.product ?? "").trim();
169
222
  const ecosystem = (a.ecosystem ?? "").trim();
@@ -251,7 +304,7 @@ async function checkAffected(kit, a) {
251
304
  },
252
305
  freshness: null,
253
306
  notes: [NO_TIME, NO_FRESHNESS],
254
- });
307
+ }, CHECK_AFFECTED_TEXT);
255
308
  }
256
309
  catch (e) {
257
310
  return kit.crashed(TOOL, e);
@@ -1,4 +1,5 @@
1
1
  import * as z from "zod";
2
+ import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
2
3
  export const CHECK_SBOM = "check_sbom";
3
4
  export const BATCH_PATH = "/api/v1/public/cves/match/batch";
4
5
  // The backend's cap per request (cveBatchMaxComponents): the batch size. A longer list is sent
@@ -21,6 +22,10 @@ export const MAX_PURLS = 2000;
21
22
  // 60 s), and a call that outlives its client returns nothing at all — not even the partial answer
22
23
  // below. From a fresh budget, 1,200 purls (6 batches) go at once; a larger list answers partial
23
24
  // with the rest in data.not_sent_purls, for a call a minute later.
25
+ // The budget bounds the whole call, not only when a batch may start (#2756): each batch's request
26
+ // may take at most what is left of it (api()'s init.timeoutMs), so a batch started at 49.9 s is cut
27
+ // off at 50 s and its purls answered as not sent (time_budget), instead of running on for the
28
+ // request timeout (15 s) past the client's and the hosted endpoint's 60 s.
24
29
  export const TIME_BUDGET_MS = 50_000;
25
30
  // Retry-After 0 is waited this long; a 429 without Retry-After is not retried. At most MAX_WAITS
26
31
  // waits per call.
@@ -44,7 +49,48 @@ const CLEAN_NOTE = "Only not_affected is a clean verdict, and only for the advis
44
49
  const MEASURED_AT_NOTE = "measured_at is null: the corpus rows are read through a cache whose maximum age the answer states (summary.corpus_cache_max_age_ms), so the answer gives no single time at which they were read.";
45
50
  const FRESHNESS_NOTE = "freshness is null: the answer carries no time at which the advisory corpus was last refreshed.";
46
51
  export const CHECK_SBOM_TITLE = "Check an SBOM against the advisory corpus";
47
- export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGraph's advisory corpus, one verdict per component. Pass purls (package URLs, up to 2,000 distinct) or sbom (a CycloneDX JSON or SPDX JSON document, as JSON text or as an object, up to 5,000,000 characters). The purls are read from the document by this MCP server and only they are sent to the API, in POST bodies of at most 200 purls each, one after another, 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. A component without a purl is counted and not checked. Each purl is mapped to its OSV ecosystem, package name and version and matched against the affected version ranges EchelonGraph holds from OSV.dev advisory records; there is no ranking and no score. data.results holds one row per component sent, in order (index counts across batches), with verdict (affected, not_affected, undetermined or not_assessed), assessed, not_assessed_reason, cve_ids, matches, count, not_affected_count and undetermined_count; data.summary counts the verdicts, summed over the batches (partial is true when any batch's was). not_affected is the only clean verdict. undetermined: advisories name the package but at least one could not be decided at this version and none matched. not_assessed: no verdict at all, because the package is not in the corpus, the purl type has no OSV ecosystem, a deb, apk or rpm purl carries no distro qualifier naming its release (EchelonGraph does not guess one), the version is missing, the lookup failed, or the batch's time budget ran out first (time_budget). Neither undetermined nor not_assessed is clean, and the note gives their counts. The API allows 1,200 components a minute per caller; when it answers 429 with Retry-After, the tool waits as asked and sends the batch again, within 50 seconds per call. When the next wait would pass that, or a batch after the first fails, the tool stops and answers what it has: coverage.not_sent counts the purls not sent and coverage.not_sent_reason says why (time_budget, rate_limited or request_failed), data.not_sent_purls lists them for a later call, and they are not checked and not clean. A list or document with more than 2,000 distinct purls is refused, not truncated: split it. Its structured result carries state (measured when at least one component got a verdict, else not_assessed), measured_at (null: the corpus is read through a cache, so no single read time exists), method, coverage (what the input held, what was sent in how many batches, and what was not sent and why), freshness (null) and notes, with data equal to the API's JSON (for more than one batch, the batches' answers merged); the result's last text block repeats it without data (the first text block) and without the note's sentences (the text block before it), with which notes ends.";
52
+ export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGraph's advisory corpus, one verdict per component. Pass purls (package URLs, up to 2,000 distinct) or sbom (a CycloneDX JSON or SPDX JSON document, as JSON text or as an object, up to 5,000,000 characters). The purls are read from the document by this MCP server and only they are sent to the API, in POST bodies of at most 200 purls each, one after another, 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. A component without a purl is counted and not checked. Each purl is mapped to its OSV ecosystem, package name and version and matched against the affected version ranges EchelonGraph holds from OSV.dev advisory records; there is no ranking and no score. data.results holds one row per component sent, in order (index counts across batches), with verdict (affected, not_affected, undetermined or not_assessed), assessed, not_assessed_reason, cve_ids, matches, count, not_affected_count and undetermined_count; data.summary counts the verdicts, summed over the batches (partial is true when any batch's was). not_affected is the only clean verdict. undetermined: advisories name the package but at least one could not be decided at this version and none matched. not_assessed: no verdict at all, because the package is not in the corpus, the purl type has no OSV ecosystem, a deb, apk or rpm purl carries no distro qualifier naming its release (EchelonGraph does not guess one), the version is missing, the lookup failed, or the batch's time budget ran out first (time_budget). Neither undetermined nor not_assessed is clean, and the note gives their counts. The API allows 1,200 components a minute per caller; when it answers 429 with Retry-After, the tool waits as asked and sends the batch again, within 50 seconds per call. When the next wait would pass that, or a batch after the first fails, the tool stops and answers what it has: coverage.not_sent counts the purls not sent and coverage.not_sent_reason says why (time_budget, rate_limited or request_failed), data.not_sent_purls lists them for a later call, and they are not checked and not clean. A list or document with more than 2,000 distinct purls is refused, not truncated: split it. Its structured result carries state (measured when at least one component got a verdict, else not_assessed), measured_at (null: the corpus is read through a cache, so no single read time exists), method, coverage (what the input held, what was sent in how many batches, and what was not sent and why), freshness (null) and notes, with data equal to the API's JSON (for more than one batch, the batches' answers merged); the result's last text block repeats it without data (the first text block) and without the note's sentences (the text block before it), with which notes ends." +
53
+ " " +
54
+ TEXT_BUDGET_DESCRIPTION +
55
+ " Cut, each row keeps index, purl, verdict, not_assessed_reason and cve_ids at least, and the rows whose verdict is not_affected, then not_assessed, are left out of the text before any other; not_sent_purls keeps its first 10, and the note says from which position of the input the purls not sent run.";
56
+ // #2783: a production row is about 2,200 characters as pretty JSON, most of it its matches, each
57
+ // a CVE with its description, so the 50-component Juice Shop document was 112,820 characters of
58
+ // text, and 2,000 purls about 1,200,000. Past DATA_TEXT_BUDGET each row in the first text block
59
+ // keeps its verdict, counts and cve_ids, and each match its scores, flags and match_reason (the
60
+ // advisory interval the version falls inside, which names the fixed version) without the
61
+ // description; then the same without match_reason; then only index, purl, verdict,
62
+ // not_assessed_reason and cve_ids. When rows must still go, the clean ones (not_affected) leave
63
+ // first, then the not_assessed ones, both counted in data.summary and the note, so the affected
64
+ // and undetermined rows are the last to leave the text.
65
+ // A call that stops early (a 2,000-purl call from a fresh budget answers 1,200 and is refused the
66
+ // rest with a 429) lists the purls not sent in data.not_sent_purls: 800 of them are some 20,000
67
+ // to 60,000 characters, which the text cannot hold beside the rows. Once the first level does not
68
+ // fit with that list whole, the text keeps its first 10 and the note says which they are: the
69
+ // input's distinct purls from position `sent` + 1 on, in input order, which is how check_sbom read
70
+ // them (x.purls.slice(answers.length * MAX_COMPONENTS)), so a second call can send them without
71
+ // reading the list.
72
+ export function sbomText(sent) {
73
+ const matches = ["cve_id", "severity", "effective_severity", "effective_score", "kev_listed", "ransomware", "epss_score", "score_assessed"];
74
+ const row = ["index", "purl", "ecosystem", "package", "version", "verdict", "assessed", "not_assessed_reason", "count", "not_affected_count", "undetermined_count", "cve_ids"];
75
+ return {
76
+ rows: "results",
77
+ levels: [
78
+ { keep: [...row, ["matches", [...matches, "match_reason"]]], clip: 200 },
79
+ { keep: [...row, ["matches", matches]], clip: 200 },
80
+ { keep: ["index", "purl", "verdict", "not_assessed_reason", "cve_ids"], clip: 100 },
81
+ ],
82
+ sides: [
83
+ {
84
+ list: "not_sent_purls",
85
+ cap: 10,
86
+ said: (_inText, total) => `The ${total} purls not sent are the input's distinct purls from position ${sent + 1} on, in the order this tool read them (the order of purls, or of the document's components, nested ones after their parent, each purl at its first place), so a second call can send them without reading the list.`,
87
+ },
88
+ ],
89
+ leaveOutFirst: { field: "verdict", values: ["not_affected", "not_assessed"], why: "data.summary counts every verdict" },
90
+ whole: "check_affected, given a row's ecosystem, package and version, returns that component's matches whole, and cve_intel a CVE's affected packages and fixed versions.",
91
+ page: (shown) => `To read every row in the text, check ${shown} or fewer purls per call.`,
92
+ };
93
+ }
48
94
  const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
49
95
  function dedupe(all) {
50
96
  const seen = new Set();
@@ -317,17 +363,27 @@ export async function checkSbom(deps, a) {
317
363
  let waits = 0;
318
364
  let waitedMs = 0;
319
365
  let stop = null;
366
+ // The 429 last waited out, for the one way the budget can be gone before anything answered.
367
+ let waitedFor;
320
368
  // One batch at a time, in order; a 429 with Retry-After is waited out and the same batch sent
321
369
  // again, while the wait fits in the call's budget.
322
370
  while (answers.length < batches.length) {
323
- if (answers.length > 0 && now() - started >= TIME_BUDGET_MS) {
371
+ const left = TIME_BUDGET_MS - (now() - started);
372
+ if (left <= 0 && answers.length === 0 && waitedFor) {
373
+ // A wait that ended at the budget's end: no time is left to send the first batch again, and
374
+ // nothing was measured, so that 429 is the answer, not a request given no time at all.
375
+ return deps.failed(tool, waitedFor);
376
+ }
377
+ if (answers.length > 0 && left <= 0) {
324
378
  stop = { reason: "time_budget", detail: `the call's ${budgetS} s budget ran out after ${answers.length} of ${batches.length} batches` };
325
379
  break;
326
380
  }
381
+ // At most what is left of the budget (#2756): a slow batch is cut off at its end.
327
382
  const r = await deps.api(BATCH_PATH, {
328
383
  method: "POST",
329
384
  headers: { "Content-Type": "application/json" },
330
385
  body: JSON.stringify({ components: batches[answers.length].map((purl) => ({ purl })) }),
386
+ timeoutMs: Math.max(0, left),
331
387
  });
332
388
  if (r.ok) {
333
389
  answers.push(r.data);
@@ -347,11 +403,16 @@ export async function checkSbom(deps, a) {
347
403
  await sleep(waitMs);
348
404
  waits++;
349
405
  waitedMs += waitMs;
406
+ waitedFor = r;
350
407
  continue;
351
408
  }
352
409
  stop = { reason: "time_budget", detail: `the API answered 429 asking for a wait of ${r.retryAfter} s (Retry-After), which would pass the call's ${budgetS} s budget` };
353
410
  }
354
411
  }
412
+ else if (r.kind === "timeout" && now() - started >= TIME_BUDGET_MS) {
413
+ // Cut off by the budget, not by the API failing: the purls are not sent for want of time.
414
+ stop = { reason: "time_budget", detail: `the call's ${budgetS} s budget ran out while batch ${answers.length + 1} of ${batches.length} was unanswered, so it was cut off` };
415
+ }
355
416
  else {
356
417
  stop = { reason: "request_failed", detail: deps.describeFailure(r).replace(/\.$/, "") };
357
418
  }
@@ -402,7 +463,7 @@ export async function checkSbom(deps, a) {
402
463
  },
403
464
  freshness: null,
404
465
  notes: [DISTRO_NOTE, CLEAN_NOTE, MEASURED_AT_NOTE, FRESHNESS_NOTE],
405
- });
466
+ }, sbomText(g.sent));
406
467
  }
407
468
  catch (e) {
408
469
  return deps.crashed(tool, e);
@@ -481,7 +542,7 @@ export function checkSbomOutput(envelopeSchema) {
481
542
  not_sent_reason: z
482
543
  .enum(["time_budget", "rate_limited", "request_failed"])
483
544
  .nullable()
484
- .describe("Why not_sent is above 0: time_budget (the call's 50 s budget ran out, or waiting out the API's Retry-After would pass it), rate_limited (a 429 without Retry-After, or a 429 after 10 waits), request_failed (a batch after the first failed; the note quotes how). null when every purl was sent."),
545
+ .describe("Why not_sent is above 0: time_budget (the call's 50 s budget ran out, before a batch or while one was unanswered, which is then cut off; or waiting out the API's Retry-After would pass it), rate_limited (a 429 without Retry-After, or a 429 after 10 waits), request_failed (a batch after the first failed; the note quotes how). null when every purl was sent."),
485
546
  rate_limit_waits: z.number().int().describe("How many times the API answered 429 and the tool waited its Retry-After before sending the batch again."),
486
547
  waited_ms: z.number().int().describe("Milliseconds spent in those waits."),
487
548
  not_assessed: z.number().int().nullable().describe("Of those sent, the components with no verdict, as the answer counts them."),
@@ -26,6 +26,7 @@
26
26
  // model holds, and says coverage depends on which pollers run.
27
27
  import * as z from "zod";
28
28
  import { ANNOTATIONS, CVE_ID, NOT_A_FINDING, SHOWN_BASE, api, badInput, boolAt, checked, crashed, envelopeSchema, failed, failure, field, numAt, okHead, opt, strAt, succeeded, } from "../index.js";
29
+ import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
29
30
  const TOOL = "cve_intel";
30
31
  // The sections relayed, in the answer's order, and the keys each failed_sections name covers.
31
32
  const LISTS = ["cwes", "exploits", "affected_packages", "fixed_versions", "timeline"];
@@ -59,7 +60,7 @@ const API_ROW_LIMIT = { exploits: 10, affected_packages: 50, fixed_versions: 200
59
60
  const TIMELINE_RELAYED = 20;
60
61
  const CVE_INTEL_METHOD = "EchelonGraph's per-CVE enrichment (GET /api/v1/public/cves/:id/enrichment). cwes: CWE classifications stored from NVD, GitHub (GHSA) and CVE.org records, each with the source that gave it, named from the MITRE CWE catalog EchelonGraph embeds. exploits: public exploit references from EchelonGraph's cve_exploits records, whose data model holds Metasploit modules (metasploit), Exploit-DB entries (exploit_db), nuclei templates (nuclei_template), GitHub proof-of-concept repositories (github_poc) and vendor proofs of concept (vendor_poc), filled by a curated seed and by one poller per automated source, each run only where it is enabled, so which sources are current depends on the deployment. affected_packages and fixed_versions: package-ecosystem advisory data, each row naming its source. timeline: EchelonGraph's enrichment history for the CVE, newest first.";
61
62
  const VERIFIED_STATUS_DESCRIPTION = "verified_status is the label stored with each reference: verified for a Metasploit module, for an Exploit-DB entry Exploit-DB marks verified, and for curated seed rows marked so; reported for a public artefact nothing has confirmed works (nuclei templates, GitHub proofs of concept, unverified Exploit-DB entries); unconfirmed where a curated row says so. It is a label from the source, not a guarantee that the exploit works against a given system.";
62
- export const CVE_INTEL_DESCRIPTION = `Weakness, public exploit code, affected packages and fixed versions for one CVE, from EchelonGraph's per-CVE enrichment. Returns cwes (each cwe_id with its name and source), exploits (each with kind, source_name, source_url, first_seen_at and verified_status; at most 10, verified first) with exploits_total (every reference on record), exploits_capped (true when exploits lists fewer than exploits_total), exploits_by_kind and exploits_by_status, affected_packages (ecosystem, package_name, version_range, fixed_version), fixed_versions (ecosystem, package_name, vulnerable_range, fixed_version) and timeline (the newest enrichment-history rows, with timeline_total). ${VERIFIED_STATUS_DESCRIPTION} An empty exploits list is not evidence that no public exploit exists: it covers only the sources EchelonGraph ingests, and which of them are polled depends on the deployment. A section the API could not read is named in coverage.sections_failed and left out of data, never relayed as an empty list. Vendor advisories, patches, generated summaries, trending signals and historical incidents are not relayed. Pass a CVE ID like CVE-2021-44228. Its structured result carries state (measured), measured_at (null: each row carries its own time), method, coverage (the sections relayed, failed and left out, and per-list counts), freshness (null) and notes, with data equal to the first text block; the result's last text block repeats it without data and without the note's sentences (the text block before it), with which notes ends.`;
63
+ export const CVE_INTEL_DESCRIPTION = `Weakness, public exploit code, affected packages and fixed versions for one CVE, from EchelonGraph's per-CVE enrichment. Returns cwes (each cwe_id with its name and source), exploits (each with kind, source_name, source_url, first_seen_at and verified_status; at most 10, verified first) with exploits_total (every reference on record), exploits_capped (true when exploits lists fewer than exploits_total), exploits_by_kind and exploits_by_status, affected_packages (ecosystem, package_name, version_range, fixed_version), fixed_versions (ecosystem, package_name, vulnerable_range, fixed_version) and timeline (the newest enrichment-history rows, with timeline_total). ${VERIFIED_STATUS_DESCRIPTION} An empty exploits list is not evidence that no public exploit exists: it covers only the sources EchelonGraph ingests, and which of them are polled depends on the deployment. A section the API could not read is named in coverage.sections_failed and left out of data, never relayed as an empty list. Vendor advisories, patches, generated summaries, trending signals and historical incidents are not relayed. Pass a CVE ID like CVE-2021-44228. Its structured result carries state (measured), measured_at (null: each row carries its own time), method, coverage (the sections relayed, failed and left out, and per-list counts), freshness (null) and notes, with data, which the first text block holds whole up to 30,000 characters; the result's last text block repeats it without data and without the note's sentences (the text block before it), with which notes ends. ${TEXT_BUDGET_DESCRIPTION}`;
63
64
  // ── outputSchema ──
64
65
  const Rows = (shape) => z.array(z.looseObject(shape));
65
66
  const CountMap = z.record(z.string(), z.number());
@@ -1,11 +1,12 @@
1
1
  import * as z from "zod";
2
+ import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
2
3
  const TOOL = "epss_history";
3
4
  const CVE_ID = /^CVE-\d{4}-\d{4,}$/i;
4
5
  export const EPSS_HISTORY_METHOD = "EchelonGraph's record of FIRST's EPSS scores: the nightly EPSS fetcher overwrites each CVE's epss_score and epss_percentile from FIRST's published file and, for every CVE whose value changed, records the new values with the time it wrote them; this series is those records, oldest first, with repeated records of one change collapsed into the first.";
5
6
  const CHANGE_ONLY = "series_kind is change_only: each point is a change EchelonGraph recorded, at the time it recorded it, and a day with no point is not a recorded value, so the series must not be read or redrawn as a daily series; no point here is interpolated.";
6
7
  const BEFORE_FIRST = "The value in force before a CVE's first point is not in the series.";
7
8
  const NO_FRESHNESS = "freshness is null: the answer does not carry the time of the EPSS fetcher's last completed run.";
8
- export const EPSS_HISTORY_DESCRIPTION = `How one CVE's EPSS score (FIRST's exploit-prediction probability, 0 to 1, with its percentile) has changed, as EchelonGraph recorded it: points, oldest first, each with at, epss_score and epss_percentile; current, the record's value now (epss_score, epss_percentile, epss_updated_at); series_kind, always change_only; series_starts_at, when EchelonGraph began recording EPSS changes for any CVE; history_rows, points_truncated and latest_point_matches_current. Pass a CVE ID like CVE-2023-44487. The series is change-only: a point is a recorded change, and a day without a point is not a recorded value. Never interpolate it into a daily series. Before series_starts_at nothing was recorded, so a missing point there means not recorded, not unchanged, and the value in force before a CVE's first point is not in the series. latest_point_matches_current false means a change is missing from the series. Its structured result carries state (measured), measured_at (current.epss_updated_at: when EchelonGraph last wrote a changed value, not FIRST's score date), method, coverage (series_kind, series_starts_at, points, points_truncated), freshness (null) and notes, with data equal to the API's JSON; the result's last text block repeats it without data (the first text block) and without the note's sentences (the text block before it), with which notes ends.`;
9
+ export const EPSS_HISTORY_DESCRIPTION = `How one CVE's EPSS score (FIRST's exploit-prediction probability, 0 to 1, with its percentile) has changed, as EchelonGraph recorded it: points, oldest first, each with at, epss_score and epss_percentile; current, the record's value now (epss_score, epss_percentile, epss_updated_at); series_kind, always change_only; series_starts_at, when EchelonGraph began recording EPSS changes for any CVE; history_rows, points_truncated and latest_point_matches_current. Pass a CVE ID like CVE-2023-44487. The series is change-only: a point is a recorded change, and a day without a point is not a recorded value. Never interpolate it into a daily series. Before series_starts_at nothing was recorded, so a missing point there means not recorded, not unchanged, and the value in force before a CVE's first point is not in the series. latest_point_matches_current false means a change is missing from the series. Its structured result carries state (measured), measured_at (current.epss_updated_at: when EchelonGraph last wrote a changed value, not FIRST's score date), method, coverage (series_kind, series_starts_at, points, points_truncated), freshness (null) and notes, with data equal to the API's JSON; the result's last text block repeats it without data (the first text block) and without the note's sentences (the text block before it), with which notes ends. ${TEXT_BUDGET_DESCRIPTION}`;
9
10
  // The API's JSON. points, series_kind and current are required: an answer without them is not
10
11
  // this series, and a series_kind other than change_only would make every note above wrong, so
11
12
  // either is a failure (checked), never relayed.
@@ -13,11 +13,20 @@
13
13
  // the note says it can lag the feed.
14
14
  import * as z from "zod";
15
15
  import { ANNOTATIONS, api, badInput, checked, crashed, envelopeSchema, failed, field, lenAt, numAt, okHead, opt, rowsScoreNote, strAt, succeeded, } from "../index.js";
16
+ import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
16
17
  const TOOL = "get_cwe";
17
18
  const CWE_ARG = /^(?:CWE-)?0*(\d{1,6})$/i;
18
19
  const MAX_PAGE = 200;
19
20
  const GET_CWE_METHOD = "EchelonGraph's per-CWE listing (GET /api/v1/public/cwes/:cwe_id): the CWE's name and description from the MITRE CWE catalog EchelonGraph embeds (catalog_version), and the active CVEs in EchelonGraph's CVE feed that an NVD, GitHub (GHSA) or CVE.org record classifies under it, rejected and reserved records left out, 50 to a page.";
20
- export const GET_CWE_DESCRIPTION = `One CWE (weakness class) and the CVEs classified under it. Returns cwe_id, name and description (from the MITRE CWE catalog EchelonGraph embeds, version catalog_version), total (the active CVEs in EchelonGraph's feed that an NVD, GitHub or CVE.org record classifies under it, rejected and reserved records left out), and cves, one page of 50: each with cve_id, severity, cvss_v3_score, echelongraph_score, echelongraph_severity, score_assessed, kev_listed, published and a shortened description. order says how the rows are sorted (CISA-KEV-listed first, then EchelonGraph score); an answer without order does not state its order, and the note says so. page is the page served, page_size its size and max_page the last page the API serves: a later page is answered as max_page, so past max_page pages total counts CVEs no page lists. echelongraph_score is EchelonGraph's score only when score_assessed is true; the note labels each row that is NOT YET SCORED. A total of 0 says that no CVE in EchelonGraph's feed is classified under that CWE, not that none exists. Pass cwe_id like CWE-79 (or 79) and an optional page (1-${MAX_PAGE}). Its structured result carries state (measured), measured_at (null), method, coverage (total, returned, page, page_requested, page_size, max_page), freshness (null) and notes, with data equal to the API's JSON; the result's last text block repeats it without data (the first text block) and without the note's sentences (the text block before it), with which notes ends.`;
21
+ export const GET_CWE_DESCRIPTION = `One CWE (weakness class) and the CVEs classified under it. Returns cwe_id, name and description (from the MITRE CWE catalog EchelonGraph embeds, version catalog_version), total (the active CVEs in EchelonGraph's feed that an NVD, GitHub or CVE.org record classifies under it, rejected and reserved records left out), and cves, one page of 50: each with cve_id, severity, cvss_v3_score, echelongraph_score, echelongraph_severity, score_assessed, kev_listed, published and a shortened description. order says how the rows are sorted (CISA-KEV-listed first, then EchelonGraph score); an answer without order does not state its order, and the note says so. page is the page served, page_size its size and max_page the last page the API serves: a later page is answered as max_page, so past max_page pages total counts CVEs no page lists. echelongraph_score is EchelonGraph's score only when score_assessed is true; the note labels each row that is NOT YET SCORED. A total of 0 says that no CVE in EchelonGraph's feed is classified under that CWE, not that none exists. Pass cwe_id like CWE-79 (or 79) and an optional page (1-${MAX_PAGE}). Its structured result carries state (measured), measured_at (null), method, coverage (total, returned, page, page_requested, page_size, max_page), freshness (null) and notes, with data equal to the API's JSON; the result's last text block repeats it without data (the first text block) and without the note's sentences (the text block before it), with which notes ends. ${TEXT_BUDGET_DESCRIPTION}`;
22
+ // #2783: a page of 50 production rows is about 27,000 characters as pretty JSON (CWE-79, page 1),
23
+ // under the budget once each row is on a line of its own. Past it, each row keeps the fields the
24
+ // description names with its description cut to 120 characters, then rows are left out.
25
+ const GET_CWE_TEXT = {
26
+ rows: "cves",
27
+ levels: [{ keep: ["cve_id", "severity", "cvss_v3_score", "echelongraph_score", "echelongraph_severity", "score_assessed", "score_unassessed_reason", "kev_listed", "published", "description"], clip: 120 }],
28
+ whole: "get_cve returns any one of these CVEs' records whole.",
29
+ };
21
30
  function schemas() {
22
31
  const row = z.looseObject({
23
32
  cve_id: opt(z.string()),
@@ -127,7 +136,7 @@ export async function getCwe(cwe_id, page) {
127
136
  "measured_at is null: a page of CVEs has no single observation time; each row carries its own published date.",
128
137
  "freshness is null: the answer does not say when its cached page was computed.",
129
138
  ],
130
- });
139
+ }, GET_CWE_TEXT);
131
140
  }
132
141
  catch (e) {
133
142
  return crashed(TOOL, e);