echelongraph-mcp 2.7.2 → 3.0.2

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.
@@ -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();
@@ -343,13 +373,16 @@ async function checkAffected(kit, a) {
343
373
  return kit.crashed(TOOL, e);
344
374
  }
345
375
  }
376
+ // One output schema per process, not per request (3.0.2; index.ts envelopeSchema says why).
377
+ const OUTPUTS = new WeakMap();
346
378
  export function registerCheckAffected(server, kit) {
347
- const output = kit.envelopeSchema({ data: DATA, coverage: COVERAGE, freshness: null });
379
+ const output = OUTPUTS.get(kit.envelopeSchema) ?? kit.envelopeSchema({ data: DATA, coverage: COVERAGE, freshness: null });
380
+ OUTPUTS.set(kit.envelopeSchema, output);
348
381
  server.registerTool(TOOL, {
349
382
  title: CHECK_AFFECTED_TITLE,
350
383
  description: CHECK_AFFECTED_DESCRIPTION,
351
384
  inputSchema: CHECK_AFFECTED_INPUT,
352
385
  outputSchema: output,
353
386
  annotations: kit.annotations,
354
- }, async (a) => kit.checked(TOOL, output, await checkAffected(kit, a)));
387
+ }, async (a) => kit.checked(TOOL, output, await answering(a.full, CHECK_AFFECTED_DATA, () => checkAffected(kit, a))));
355
388
  }
@@ -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,155 @@ 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
+ "matched_version_from",
104
+ "purl_name",
105
+ ["matches", ["cve_id", "kev_listed", "ransomware", "epss_score", "effective_score", "score_assessed", "fixed_in", "fixed_in_reason"]],
106
+ ];
107
+ // #3132: past SBOM_ROW_THIN, each row is cut on its own terms before any row leaves. Through 3.0.0
108
+ // SBOM_ROW_THIN was the only level, and it keeps every match object, so one row still large once
109
+ // thinned left data, and every row after it with it (rows leave from the end): measured on hosted
110
+ // 3.0.0 (2026-10-11), five Debian 12 purls, all affected, kept 1 row (libssl3; linux-libc-dev
111
+ // 6.1.55-1's 200 matches are 42,417 characters thinned); Juice Shop 11.1.2 with
112
+ // magento/community-edition 2.3.0 inserted at index 2 kept 2 of 51 rows (19 affected); that purl
113
+ // alone kept none, and the note said "check 0 or fewer purls per call". Now, in order:
114
+ // 1. SBOM_ROW_THIN, every row, every match (as before);
115
+ // 2. the same, with every row's matches capped at one number m, the most that fit: a row with
116
+ // more than m keeps its first m (the API's order: highest effective_score first) and carries
117
+ // matches_truncated, its own total and returned, and the CVE IDs among all its matches that
118
+ // are KEV-listed (kev_listed_cve_ids) or used in ransomware (ransomware_cve_ids), and the
119
+ // distinct fixed_in versions they name (fixed_in_versions), so what a triage ranks by first
120
+ // is still said of the matches cut; rows with m or fewer matches are untouched;
121
+ // 3. SBOM_ROW_COMPACT, each match cut to cve_id, kev_listed, ransomware, fixed_in and
122
+ // effective_score, the row keeping matched_package, matched_via and an upstream row's
123
+ // matched_version_from and purl_name, matches capped at one number m as in 2 (any m, 0
124
+ // included), with the same matches_truncated;
125
+ // 4. SBOM_ROW_MIN, the text's leanest level (index, purl, verdict, not_assessed_reason and
126
+ // cve_ids), with that matches_truncated on each row that had matches;
127
+ // 5. SBOM_ROW_MIN with matches_truncated cut to total and returned;
128
+ // and only then do rows leave (clean, then not assessed, then the last of the rest).
129
+ // #3132's review: round 1 had no level 3, and level 2 was taken at m = 0. The production
130
+ // synthetic's 201-purl probe (synthetic/probes.mjs SBOM_PROBE_PURLS, recorded on hosted 3.0.0 with
131
+ // full: true, 2026-10-11: 201 affected rows, 415 matches, 710,724 characters) then came back at
132
+ // level 5 with 132 rows, every match, KEV flag and fixed_in gone, and the probe's EXPECT (log4j-core
133
+ // 2.14.1's CVE-2021-44228 match, fixed_in 2.15.0; the upstream row's matched_via) failed, where
134
+ // 3.0.0 kept 43 rows thinned and passed. So now:
135
+ // - level 2 is taken only at m >= 1 (least): at m = 0 it holds less than level 3;
136
+ // - when not even level 5 keeps every row, rows leave at level 3 with matches capped at
137
+ // SBOM_LEAVING_CAP (leaveAt), not at level 5: a page of the size the note names then comes back
138
+ // with each row's KEV flags and fixed_in, which at level 5 no page size did;
139
+ // - once the cut is settled, the rows kept take back detail in input order while the result fits
140
+ // (giveBack): each the richest level above its own that fits, its matches at the compact level
141
+ // at the most that fit, stopping at the first row that takes back none.
142
+ // Measured on that recording (test/check_sbom-large-rows.test.mjs): 54 of 201 rows, each compact
143
+ // with every match (none has more than 7), the log4j-core row with its CVE-2021-44228 match and the
144
+ // upstream row with matched_via, at 29,959 characters.
145
+ // cve_ids is what the API caps at 200 a row, about 3,400 characters, so every row survives level 5
146
+ // unless the rows are many (the probe's 201, Juice Shop's full 840 purls).
147
+ export const SBOM_ROW_COMPACT = [
148
+ "index",
149
+ "purl",
150
+ "verdict",
151
+ "not_assessed_reason",
152
+ "cve_ids",
153
+ "matched_package",
154
+ "matched_via",
155
+ "matched_version_from",
156
+ "purl_name",
157
+ ["matches", ["cve_id", "kev_listed", "ransomware", "fixed_in", "effective_score"]],
158
+ ];
159
+ export const SBOM_ROW_MIN = ["index", "purl", "verdict", "not_assessed_reason", "cve_ids"];
160
+ // The most matches a row keeps, compact, when rows leave (the API's order, highest effective_score
161
+ // first); the rest are in its cve_ids and matches_truncated.
162
+ export const SBOM_LEAVING_CAP = 10;
163
+ // matches_truncated for a row whose matches data keeps `kept` of (none at levels 3 and 4); with
164
+ // `summary`, the three lists drawn from all of the row's matches. Absent on a row with no match
165
+ // cut, and on one with no matches.
166
+ function matchesTruncated(row, kept, summary) {
167
+ const ms = Array.isArray(row.matches) ? row.matches.filter(isObj) : [];
168
+ if (ms.length <= kept)
169
+ return {};
170
+ const ids = (f) => [...new Set(ms.filter((m) => m[f] === true && typeof m.cve_id === "string").map((m) => m.cve_id))];
171
+ const fixed = [...new Set(ms.map((m) => m.fixed_in).filter((v) => typeof v === "string"))];
172
+ return {
173
+ matches_truncated: {
174
+ total: ms.length,
175
+ returned: Math.min(kept, ms.length),
176
+ ...(summary ? { kev_listed_cve_ids: ids("kev_listed"), ransomware_cve_ids: ids("ransomware"), fixed_in_versions: fixed } : {}),
177
+ },
178
+ };
179
+ }
180
+ const MATCHES_TRUNCATED_SAID = "carrying matches_truncated, which gives its own total and returned, and, of all its matches, the CVE IDs KEV-listed (kev_listed_cve_ids) or used in ransomware (ransomware_cve_ids) and the distinct fixed_in versions (fixed_in_versions)";
181
+ const cappedSaid = (cap) => `each row's matches keeping at most its first ${cap} (the API's order, highest effective_score first), a row with more than that ${MATCHES_TRUNCATED_SAID}`;
182
+ export const SBOM_ROW_LEVELS = [
183
+ SBOM_ROW_THIN,
184
+ { keep: SBOM_ROW_THIN, capped: "matches", least: 1, mark: (row, cap) => matchesTruncated(row, cap, true), said: cappedSaid },
185
+ { keep: SBOM_ROW_COMPACT, capped: "matches", mark: (row, cap) => matchesTruncated(row, cap, true), said: cappedSaid },
186
+ { keep: SBOM_ROW_MIN, mark: (row) => matchesTruncated(row, 0, true), said: () => `a row that had matches ${MATCHES_TRUNCATED_SAID}` },
187
+ { keep: SBOM_ROW_MIN, mark: (row) => matchesTruncated(row, 0, false), said: () => "a row that had matches carrying matches_truncated with its total and returned 0" },
188
+ ];
189
+ const SBOM_LEAVE_AT = { level: 2, cap: SBOM_LEAVING_CAP };
190
+ // The page sentence (#3132): never "0 or fewer". Rows leave only once no level keeps them all, so a
191
+ // call of as many purls as data kept rows keeps each of its rows; when not one row fits, no page
192
+ // size does, and the note says where a row is read instead.
193
+ // #3132's review: the lists beside the rows go first, scan_manifest's not_checked before
194
+ // not_sent_purls, which keeps every purl or none (whole): those purls are the list a client
195
+ // retries, and round 1 kept 3 of the rate-limited case's 800. With none kept, the note gives them
196
+ // exactly as positions in the input (sendingSentences: coverage.sent + 1 to
197
+ // coverage.distinct_purls), and the room the rows leave goes back to not_sent_purls before
198
+ // not_checked.
199
+ export function sbomData(sides, page) {
200
+ const others = sides.filter((x) => x !== "not_sent_purls");
201
+ return {
202
+ steps: [
203
+ ...(others.length ? [{ lists: others }] : []),
204
+ ...(sides.includes("not_sent_purls") ? [{ lists: ["not_sent_purls"], whole: true }] : []),
205
+ {
206
+ lists: ["results"],
207
+ thin: SBOM_ROW_LEVELS,
208
+ leaveOutFirst: { field: "verdict", values: ["not_affected", "not_assessed"], why: "data.summary counts every verdict" },
209
+ leaveAt: SBOM_LEAVE_AT,
210
+ giveBack: true,
211
+ },
212
+ ],
213
+ page: (t) => t.results && t.results.returned < t.results.total
214
+ ? t.results.returned > 0
215
+ ? page(t.results.returned)
216
+ : `Not one row fits data even cut to ${SBOM_ROW_COMPACT.map((x) => (typeof x === "string" ? x : x[0])).join(", ")}, so no number of purls per call does: the same call with full: true returns every row whole.`
217
+ : "",
218
+ };
219
+ }
220
+ const SBOM_DATA = sbomData(["not_sent_purls"], (k) => `To read every row in data, check ${k} or fewer purls per call.`);
77
221
  // The purls of a CycloneDX JSON document: every components[] entry, nested ones included.
78
222
  // metadata.component (the subject of the SBOM, not a dependency of it) is not checked.
79
223
  function fromCycloneDX(doc) {
@@ -250,11 +394,15 @@ export function checkSbomOutput(envelopeSchema) {
250
394
  freshness: null,
251
395
  });
252
396
  }
397
+ // One output schema per process, not per request (3.0.2; index.ts envelopeSchema says why).
398
+ const OUTPUTS = new WeakMap();
253
399
  // A literal server.registerTool(name, { title, description, … }) call with constant strings:
254
400
  // marketing-site lib/mcpToolClaims.test.ts reads it here (it follows index.ts's call to this
255
401
  // function) and holds the /pulse/mcp row to this title and description.
256
402
  export function registerCheckSbom(server, deps) {
257
- const output = checkSbomOutput(deps.envelopeSchema);
403
+ const envelope = deps.envelopeSchema;
404
+ const output = OUTPUTS.get(envelope) ?? checkSbomOutput(envelope);
405
+ OUTPUTS.set(envelope, output);
258
406
  server.registerTool(CHECK_SBOM, {
259
407
  title: CHECK_SBOM_TITLE,
260
408
  description: CHECK_SBOM_DESCRIPTION,
@@ -264,11 +412,12 @@ export function registerCheckSbom(server, deps) {
264
412
  .union([z.string(), z.record(z.string(), z.unknown())])
265
413
  .optional()
266
414
  .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"),
415
+ full: FULL_ARG,
267
416
  }),
268
417
  outputSchema: output,
269
418
  annotations: deps.annotations,
270
- }, async (a, ctx) => {
419
+ }, async (a, ctx) => answering(a.full, SBOM_DATA, async () => {
271
420
  const run = checkSbom({ ...deps, signal: ctx.mcpReq.signal }, a);
272
421
  return deps.checked(CHECK_SBOM, output, await (deps.hold ? deps.hold(run) : run));
273
- });
422
+ }));
274
423
  }
@@ -41,7 +41,7 @@
41
41
  // the rows where fixed_version is not the whole answer.
42
42
  import * as z from "zod";
43
43
  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";
44
- import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
44
+ import { answering, FULL_ARG, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
45
45
  const TOOL = "cve_intel";
46
46
  // The sections relayed, in the answer's order, and the keys each failed_sections name covers.
47
47
  const LISTS = ["cwes", "exploits", "affected_packages", "fixed_versions", "timeline"];
@@ -73,13 +73,22 @@ const LEFT_OUT = ["vendor_advisories", "patches", "ai", "trending", "historical_
73
73
  // Per-CVE row limits of the API (core-backend cve/enrichment_store.go): a list this long may be cut.
74
74
  const API_ROW_LIMIT = { exploits: 10, affected_packages: 50, fixed_versions: 200, timeline: 100 };
75
75
  const TIMELINE_RELAYED = 20;
76
- 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.";
76
+ 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: EchelonGraph's cve_exploits records, whose data model holds public exploit code, namely Metasploit modules (metasploit), Exploit-DB entries (exploit_db), GitHub proof-of-concept repositories (github_poc) and vendor proofs of concept (vendor_poc), and Nuclei detection templates (nuclei_template), which check whether a system is affected and are not necessarily exploit code, 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.";
77
77
  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.";
78
- const FIXED_BRANCHES_DESCRIPTION = 'fixed_branches lists each affected range of the package on record: introduced, and fixed (the range\'s fix) or last_affected (its last affected version, where no fix is on record), each null when absent, with advisory_id (the OSV record that published the range) and source (the loader\'s label for that record), both null on a row loaded before they were stored. Ranges with an advisory_id are in the order their record lists them (records in id order); ranges without one are oldest first. To pick the range for an installed version, compare in the ecosystem\'s version order (release numbers part by part, as numbers: 2.4 is below 2.13.0): the version is in a range when it is at or above introduced ("0" is the first version) and below fixed, or at or below last_affected; that range\'s fixed is the fix for it, and a version in no range is outside every range on record. fixed_branches is null where the package\'s ranges were never loaded, and [] where no version range is on record (the advisory gives none, only commit ranges, or more than are stored), which is not a finding that no fix exists: fixed_version can still name one. fixed_version is one range\'s fix, kept for compatibility (one per package: the range the loader kept, the advisory\'s last as a rule), not the fix for every affected range: for CVE-2021-44228, its advisory gives log4j-core three ranges, and fixed_version 2.12.2 is the fix for the one from 2.4, while the range holding 2.14.1 (from 2.13.0) is fixed in 2.15.0.';
78
+ const FIXED_BRANCHES_DESCRIPTION = 'fixed_branches lists each affected range of the package on record: introduced, and fixed (the range\'s fix) or last_affected (its last affected version, where no fix is on record), each null when absent, with advisory_id (the OSV record that published the range) and source (the loader\'s label for that record), both null on a row loaded before they were stored. Ranges with an advisory_id are in the order their record lists them (records in id order); ranges without one are oldest first. To pick the range for an installed version, compare in the ecosystem\'s version order (release numbers part by part, as numbers: 2.4 is below 2.13.0): the version is in a range when it is at or above introduced ("0" is the first version) and below fixed, or at or below last_affected; its fix is that range\'s fixed, or, while another range still holds that, the other range\'s fixed (none past a range without one); a version in no range is outside every range on record. fixed_branches is null where the package\'s ranges were never loaded, and [] where no version range is on record (the advisory gives none, only commit ranges, or more than are stored), which is not a finding that no fix exists: fixed_version can still name one. fixed_version is one range\'s fix, kept for compatibility (one per package: the range the loader kept, the advisory\'s last as a rule), not the fix for every affected range: for CVE-2021-44228, its advisory gives log4j-core three ranges, and fixed_version 2.12.2 is the fix for the one from 2.4, while the range holding 2.14.1 (from 2.13.0) is fixed in 2.15.0.';
79
+ // #3039: a Nuclei template is a detection check, not necessarily exploit code (core-backend
80
+ // egkev.ExploitCodeKinds, #3038; /pulse words it so since #3022). The head says "exploit code,
81
+ // detection templates" where it said "public exploit code"; the method and the note say what a
82
+ // template is, and count templates apart. The room: "the newest" and "per-CVE" (14 characters).
79
83
  // #2876: the exploit dates are named for what they are. Within the 100 characters of headroom
80
84
  // under the 2048-character client cut (prompts-resources.test.mjs), the room came from tighter
81
85
  // glosses and from the input hint, which inputSchema's cve_id description carries.
82
- 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 name and source), exploits (each with kind, source_name, source_url, source_published_at (the source's date, null if none), eg_first_seen_at (when EchelonGraph recorded it) and verified_status; at most 10, verified first) with exploits_total, exploits_capped, exploits_by_kind and exploits_by_status, affected_packages (ecosystem, package_name, version_range, fixed_version, fixed_branches), fixed_versions (each fixed version with the vulnerable_range it fixes) and timeline (the newest enrichment-history rows, with timeline_total). fixed_branches lists each affected range on record (introduced, and fixed or last_affected, with advisory_id (its OSV record) and source); in the ecosystem's version order, a version is in a range when it is at or above introduced ("0" is the first version) and below fixed, or at or below last_affected; that range's fixed is the fix for it. fixed_version is one range's fix, kept for compatibility, not the fix for every affected range. fixed_branches [] is not a finding that no fix exists. verified_status is the source's label, not a guarantee the exploit works on a given system. An empty exploits list is not evidence that no public exploit exists: it covers only the sources EchelonGraph ingests, and which 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. Its structured result carries notes, with data, which the first text block holds whole up to 30,000 characters; its coverage names the sections relayed, failed and left out. ${TEXT_BUDGET_DESCRIPTION}`;
86
+ // #3013's review: in #3074's first cut the fixed_branches sentence still said a range's fixed is
87
+ // the fix for a version it holds, the rule that gave lodash 4.17.15 under CVE-2026-4800 the
88
+ // still-affected 4.17.21 (ranges [0, 4.17.21) and [4.0.0, 4.18.0); the fix is 4.18.0). It now
89
+ // states the walk FIXED_BRANCHES_DESCRIPTION and branchSentences state, in fewer characters than
90
+ // the old rule (a range's bounds folded into the list of its fields), so the headroom holds.
91
+ export const CVE_INTEL_DESCRIPTION = `Weakness, exploit code, detection templates, affected packages and fixed versions for one CVE, from EchelonGraph's per-CVE enrichment. Returns cwes (each cwe_id with name and source), exploits (each with kind, source_name, source_url, source_published_at (the source's date, null if none), eg_first_seen_at (when EchelonGraph recorded it) and verified_status; at most 10, verified first) with exploits_total, exploits_capped, exploits_by_kind and exploits_by_status, affected_packages (ecosystem, package_name, version_range, fixed_version, fixed_branches), fixed_versions (each fixed version with the vulnerable_range it fixes) and timeline (newest enrichment-history rows, with timeline_total). fixed_branches lists each affected range on record (from introduced ("0" is the first version) to below fixed, or through last_affected, with advisory_id (its OSV record) and source); in the ecosystem's version order, an affected version's fix is the first fixed above it that no range of its row holds (none past a range with no fixed). fixed_version is one range's fix, kept for compatibility, not the fix for every affected range. fixed_branches [] is not a finding that no fix exists. verified_status is the source's label, not a guarantee the exploit works on a given system. An empty exploits list is not evidence that no public exploit exists: it covers only the sources EchelonGraph ingests, and which 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. Its structured result carries notes, with data, which the first text block holds whole up to 30,000 characters; its coverage names the sections relayed, failed and left out. ${TEXT_BUDGET_DESCRIPTION}`;
83
92
  // ── outputSchema ──
84
93
  const Rows = (shape) => z.array(z.looseObject(shape));
85
94
  const CountMap = z.record(z.string(), z.number());
@@ -173,6 +182,24 @@ function cweSentence(cve, cwes) {
173
182
  });
174
183
  return `${cve} is classified under ${listed(named)}.`;
175
184
  }
185
+ // #3039: exploit code and detection templates are counted apart. A Nuclei template
186
+ // (nuclei_template) checks whether a system is affected; it is not necessarily exploit code
187
+ // (core-backend egkev.ExploitCodeKinds, #3038), and until #3039 the note called a Nuclei-only CVE's
188
+ // one template "1 public exploit reference" (CVE-2026-42018 on the hosted endpoint, 2026-10-10;
189
+ // 2,800 production CVEs hold only Nuclei rows). The kind counts are exploits_by_kind's, and where
190
+ // the answer carries none, the listed rows' when they are every reference on record.
191
+ const DETECTION_KIND = "nuclei_template";
192
+ const DETECTION_NOTE = "a detection template checks whether a system is affected; it is not necessarily exploit code";
193
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
194
+ // The number of Nuclei detection templates among the references: exploits_by_kind's count where
195
+ // the answer carries the map, the listed rows' where they are all of them, undefined otherwise.
196
+ function templateCount(d, rows, total) {
197
+ const byKind = field(d, "exploits_by_kind");
198
+ if (byKind !== null && typeof byKind === "object" && !Array.isArray(byKind))
199
+ return numAt(d, "exploits_by_kind", DETECTION_KIND) ?? 0;
200
+ const listed = rows.filter((r) => strAt(r, "kind") === DETECTION_KIND).length;
201
+ return total === undefined || rows.length === total ? listed : undefined;
202
+ }
176
203
  function exploitSentences(cve, d, rows) {
177
204
  const total = numAt(d, "exploits_total");
178
205
  const verified = numAt(d, "exploits_by_status", "verified");
@@ -186,7 +213,14 @@ function exploitSentences(cve, d, rows) {
186
213
  `exploits is empty and the answer carries no exploits_total, so it does not count the references on record for ${cve}; an empty list is not evidence that no public exploit exists.`,
187
214
  ];
188
215
  }
189
- return [`exploits lists ${rows.length} public exploit reference${rows.length === 1 ? "" : "s"} for ${cve}, ${verifiedRows} with verified_status verified; the answer carries no exploits_total, so it does not say how many are on record.${cut}`];
216
+ const listedTemplates = rows.filter((r) => strAt(r, "kind") === DETECTION_KIND).length;
217
+ const code = rows.length - listedTemplates;
218
+ const what = listedTemplates === 0
219
+ ? `${plural(rows.length, "public exploit reference", "public exploit references")}`
220
+ : code === 0
221
+ ? `${plural(rows.length, "Nuclei detection template", "Nuclei detection templates")} and nothing else (${DETECTION_NOTE})`
222
+ : `${plural(rows.length, "reference", "references")}: ${plural(code, "public exploit reference", "public exploit references")} and ${plural(listedTemplates, "Nuclei detection template", "Nuclei detection templates")} (${DETECTION_NOTE})`;
223
+ return [`exploits lists ${what} for ${cve}, ${verifiedRows} with verified_status verified; the answer carries no exploits_total, so it does not say how many are on record.${cut}`];
190
224
  }
191
225
  if (total === 0) {
192
226
  return [
@@ -195,7 +229,23 @@ function exploitSentences(cve, d, rows) {
195
229
  }
196
230
  const v = verified === undefined ? "" : `, ${verified} of them with verified_status verified`;
197
231
  const cut = capped === true ? ` exploits_capped is true: exploits lists ${rows.length} of the ${total}, verified first.` : "";
198
- return [`EchelonGraph holds ${total} public exploit reference${total === 1 ? "" : "s"} for ${cve} (exploits_total)${v}.${cut}`];
232
+ const templates = templateCount(d, rows, total);
233
+ if (templates === undefined) {
234
+ return [
235
+ `EchelonGraph holds ${plural(total, "reference", "references")} for ${cve} (exploits_total)${v}; the answer carries no exploits_by_kind, so it does not say how many are exploit code and how many Nuclei detection templates (${DETECTION_NOTE}).${cut}`,
236
+ ];
237
+ }
238
+ if (templates === 0) {
239
+ return [`EchelonGraph holds ${plural(total, "public exploit reference", "public exploit references")} for ${cve} (exploits_total)${v}.${cut}`];
240
+ }
241
+ if (templates >= total) {
242
+ return [
243
+ `EchelonGraph holds ${plural(total, "Nuclei detection template", "Nuclei detection templates")} for ${cve} and nothing else in exploits (exploits_total ${total}): ${DETECTION_NOTE}. It holds no Metasploit module, Exploit-DB entry or proof of concept for ${cve}, which is not evidence that none exists.${cut}`,
244
+ ];
245
+ }
246
+ return [
247
+ `EchelonGraph holds ${plural(total, "reference", "references")} for ${cve} (exploits_total)${v}: ${plural(total - templates, "public exploit reference", "public exploit references")} and ${plural(templates, "Nuclei detection template", "Nuclei detection templates")}; ${DETECTION_NOTE}.${cut}`,
248
+ ];
199
249
  }
200
250
  function listSentence(name, rows) {
201
251
  const limit = API_ROW_LIMIT[name] ?? 0;
@@ -238,7 +288,7 @@ function branchSentences(rows) {
238
288
  });
239
289
  const few = (xs) => (xs.length <= 3 ? listed(xs) : `${xs.slice(0, 3).join(", ")} and ${xs.length - 3} more`);
240
290
  if (multi.length) {
241
- out.push(`${multi.length} affected_packages row${multi.length === 1 ? " lists" : "s list"} more than one affected range in fixed_branches (${few(multi)}): there, fixed_version is one range's fix, and an installed version's fix is the fixed of the range that holds it.`);
291
+ out.push(`${multi.length} affected_packages row${multi.length === 1 ? " lists" : "s list"} more than one affected range in fixed_branches (${few(multi)}): there, fixed_version is one range's fix, and an installed version's fix is the first fixed above it that no range of the row holds: the fixed of the range that holds it, or, while another range still holds that version, that range's fixed (none where a range on the way has no fixed).`);
242
292
  }
243
293
  if (unknown.length) {
244
294
  out.push(`${unknown.length} affected_packages row${unknown.length === 1 ? " carries" : "s carry"} fixed_branches null (${few(unknown)}): ${unknown.length === 1 ? "its" : "their"} ranges are not on record, so fixed_version, one range's fix, is all the answer holds, and it need not be the fix for a given installed version.`);
@@ -345,13 +395,17 @@ export async function cveIntel(cve_id) {
345
395
  return crashed(TOOL, e);
346
396
  }
347
397
  }
398
+ // #3013: the structured result's cut. CVE-2021-44228's is 12,877 characters, within the budget; past
399
+ // it the newest enrichment-history rows go first, then the affected packages and fixed versions,
400
+ // each to at most the same number of its first entries, then any other list at the top of data.
401
+ const CVE_INTEL_DATA = { steps: [{ lists: ["timeline"] }, { lists: ["affected_packages", "fixed_versions"] }] };
348
402
  export function registerCveIntel(server) {
349
403
  const { output } = schemasOnce();
350
404
  server.registerTool("cve_intel", {
351
405
  title: "CVE weakness, exploits and packages",
352
406
  description: CVE_INTEL_DESCRIPTION,
353
- inputSchema: z.object({ cve_id: z.string().describe("a CVE ID, e.g. CVE-2021-44228") }),
407
+ inputSchema: z.object({ cve_id: z.string().describe("a CVE ID, e.g. CVE-2021-44228"), full: FULL_ARG }),
354
408
  outputSchema: output,
355
409
  annotations: ANNOTATIONS,
356
- }, async ({ cve_id }) => checked(TOOL, output, await cveIntel(cve_id)));
410
+ }, async ({ cve_id, full }) => checked(TOOL, output, await answering(full, CVE_INTEL_DATA, () => cveIntel(cve_id))));
357
411
  }