echelongraph-mcp 2.6.3 → 2.6.6

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
@@ -5,16 +5,19 @@
5
5
  // docs). No API request.
6
6
  // echelongraph://sources the feeds and what the API itself reports about their polling,
7
7
  // read from the API at request time and never hard-coded: the CVE
8
- // summary's poller block (/api/v1/public/cves/summary) and the KEV
8
+ // summary's poller block (/api/v1/public/cves/summary; its interval,
9
+ // last poll and two counters, the answering instance's) and the KEV
9
10
  // answer's catalog block and method (/api/v1/public/kev/recent).
10
11
  // What no endpoint reports is named as not reported.
11
12
  // cve://{cve_id} get_cve's structured result for one CVE, as JSON: the envelope
12
- // (state, measured_at, method, notes) with data, the API's record.
13
+ // (state, measured_at, method, notes) with data, the API's record,
14
+ // cut past 30,000 characters as get_cve's first text block is.
13
15
  //
14
16
  // A resource read either answers or is a JSON-RPC error; it never answers a failure as data that
15
17
  // reads like a record. sources is the exception by design: each feed it reads carries its own
16
18
  // state, and a feed that could not be read says so (state failed), never an empty block.
17
19
  import { ProtocolError, ProtocolErrorCode, ResourceNotFoundError, ResourceTemplate } from "@modelcontextprotocol/server";
20
+ import { DATA_TEXT_BUDGET, dataText } from "./textBudget.js";
18
21
  export const METHODOLOGY_URI = "echelongraph://methodology";
19
22
  export const SOURCES_URI = "echelongraph://sources";
20
23
  export const CVE_TEMPLATE = "cve://{cve_id}";
@@ -22,7 +25,7 @@ export const METHODOLOGY = `# How EchelonGraph MCP results are measured
22
25
 
23
26
  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
27
 
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.
28
+ 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. The resource ${CVE_TEMPLATE} cuts its data the same way, and its notes say so (DATA CUT).
26
29
 
27
30
  ## Envelope fields
28
31
 
@@ -34,7 +37,7 @@ A success's first text block is the API's JSON, cut to fit 30,000 characters whe
34
37
  | coverage | What the answer covers, where the tool can say: for example in_scope for cve_exposure, the list's account of its own count for search_cves, assessed and not_assessed_reason first for check_affected. |
35
38
  | freshness | The producing radar's or fetcher's last completed check, where the API serves one; null where it serves none. |
36
39
  | notes | The caveats, one sentence each. |
37
- | data | On a success: the API's JSON (exposure_radar and cve_intel relay a labelled selection). |
40
+ | data | On a success: the API's JSON (exposure_radar and cve_intel relay a labelled selection, and cve_summary leaves out a poller field of another JSON type, named in the note). |
38
41
  | error | On a failure: kind, path, status and message. |
39
42
 
40
43
  ## state
@@ -70,9 +73,17 @@ const KEV_PATH = "/api/v1/public/kev/recent";
70
73
  const obj = (v) => (v !== null && typeof v === "object" && !Array.isArray(v) ? v : undefined);
71
74
  const pick = (o, keys) => Object.fromEntries(keys.filter((k) => o !== undefined && o[k] !== undefined).map((k) => [k, o[k]]));
72
75
  const failure = (r) => ({ state: "failed", error: { kind: r.kind, path: r.path, status: r.status ?? null, message: r.detail } });
73
- // What /api/v1/public/cves/summary reports about the NVD poller: its interval and last poll, of
74
- // the server instance that answered (core-backend cve/poller.go Stats, in memory).
76
+ // A JSON value's type in words, for a value that is not the JSON object read here.
77
+ const jsonKind = (v) => Array.isArray(v) ? "an array" : typeof v === "number" ? "a number" : typeof v === "boolean" ? "a boolean" : typeof v === "string" ? "a string" : typeof v;
78
+ // What /api/v1/public/cves/summary reports about the NVD poller: its interval and last poll, and
79
+ // its poll and failed-poll counts, of the server instance that answered (core-backend
80
+ // cve/poller.go Stats, in memory).
75
81
  const POLLER_FIELDS = ["interval", "last_poll_at", "poll_count", "poll_errors"];
82
+ // #2770: poll_count and poll_errors are that instance's counters since it last started, zeroed on
83
+ // every restart (cve_summary's summaryPollerNote, #2647, says the same of the whole block). 2.6.3
84
+ // relayed them here with a note on interval and last_poll_at alone, so poll_errors 0 over
85
+ // poll_count 7 read as the feed's track record. Whenever reported carries either, notes says so.
86
+ const SOURCES_POLLER_COUNTERS_NOTE = "poll_count and poll_errors, where reported carries them, are that one instance's counters since it last started, zeroed on every restart: they count that instance's polls and failed polls, never the feed's reliability.";
76
87
  const CATALOG_FIELDS = ["source", "feed_url", "last_successful_fetch_at", "catalog_version", "date_released", "catalog_count"];
77
88
  export async function readSources(kit) {
78
89
  const [summary, kev] = await Promise.all([kit.api(SUMMARY_PATH), kit.api(KEV_PATH, { headers: { "X-EG-Limit": "1" } })]);
@@ -80,16 +91,29 @@ export async function readSources(kit) {
80
91
  if (!summary.ok)
81
92
  Object.assign(nvd, failure(summary));
82
93
  else {
83
- const poller = obj(obj(summary.data)?.poller);
84
- if (poller === undefined) {
94
+ const sent = obj(summary.data)?.poller;
95
+ const poller = obj(sent);
96
+ if (poller === undefined && sent !== undefined && sent !== null) {
97
+ // #2770's review: a poller of another JSON type (cve_summary leaves it out and names it,
98
+ // #2771) is one the answer carries, so it is never described as missing.
99
+ Object.assign(nvd, {
100
+ state: "not_reported",
101
+ reported: null,
102
+ notes: [`The answer carries poller as ${jsonKind(sent)}, where a JSON object was expected, so it reports neither a poll interval nor a last poll time.`],
103
+ });
104
+ }
105
+ else if (poller === undefined) {
85
106
  Object.assign(nvd, { state: "not_reported", reported: null, notes: ["The answer carries no poller block, so it reports neither a poll interval nor a last poll time."] });
86
107
  }
87
108
  else {
109
+ const reported = pick(poller, POLLER_FIELDS);
110
+ const counted = reported.poll_count !== undefined || reported.poll_errors !== undefined;
88
111
  Object.assign(nvd, {
89
112
  state: "reported",
90
- reported: pick(poller, POLLER_FIELDS),
113
+ reported,
91
114
  notes: [
92
115
  "poller is the NVD poller of the server instance that answered, held in its memory: interval is its configured interval (each cycle adds a start-up jitter), and last_poll_at is that instance's last poll, not a fleet-wide time.",
116
+ ...(counted ? [SOURCES_POLLER_COUNTERS_NOTE] : []),
93
117
  ],
94
118
  });
95
119
  }
@@ -121,6 +145,31 @@ export async function readSources(kit) {
121
145
  };
122
146
  }
123
147
  const json = (uri, v) => ({ contents: [{ uri, mimeType: "application/json", text: JSON.stringify(v, null, 2) }] });
148
+ // cve://{cve_id}'s text (#2801). Through 2.6.3 it was get_cve's structuredContent pretty-printed
149
+ // whole: 86,559 characters for CVE-2021-44228 on the hosted endpoint (2026-10-04), past #2467's
150
+ // ceiling of 60,000 (test/text-bound.mjs), its notes ending with get_cve's TEXT CUT sentences, which
151
+ // describe a first text block the resource does not have ("Every list in it keeps at most its first
152
+ // 64 entries: cpe_match (396)…" beside all 396 of them). A resource is one text, with no
153
+ // structuredContent beside it, so its data is now cut as get_cve's first text block is (dataText:
154
+ // the same answer, cut the same way, laid out as that block lays it out) after the envelope,
155
+ // pretty-printed as before; its notes leave out get_cve's sentences about its first text block and,
156
+ // when data is cut, end with the resource's own, starting DATA CUT, which names get_cve's
157
+ // structuredContent.data as the record whole. A record that fits is the structured result
158
+ // pretty-printed, character for character as before.
159
+ const CVE_RESOURCE_CUT = {
160
+ opens: (chars) => `DATA CUT: the API's answer is ${chars} characters of JSON, more than the ${DATA_TEXT_BUDGET} this resource's data holds, so data here is cut, and get_cve's structuredContent.data carries the answer whole.`,
161
+ };
162
+ export function cveResourceText(structured, sentences) {
163
+ const { data, ...envelope } = structured;
164
+ if (data === null || typeof data !== "object" || Array.isArray(data))
165
+ return JSON.stringify(structured, null, 2);
166
+ const firstBlock = new Set(sentences(dataText(data).said));
167
+ const own = dataText(data, undefined, CVE_RESOURCE_CUT);
168
+ const notes = Array.isArray(envelope.notes) ? [...envelope.notes.filter((n) => !firstBlock.has(n)), ...sentences(own.said)] : envelope.notes;
169
+ const head = JSON.stringify({ ...envelope, ...(notes === undefined ? {} : { notes }) }, null, 2);
170
+ const body = own.text.replace(/\n/g, "\n ");
171
+ return head === "{}" ? `{\n "data": ${body}\n}` : `${head.slice(0, -2)},\n "data": ${body}\n}`;
172
+ }
124
173
  export function registerResources(server, kit) {
125
174
  server.registerResource("methodology", METHODOLOGY_URI, {
126
175
  title: "How results are measured",
@@ -129,12 +178,12 @@ export function registerResources(server, kit) {
129
178
  }, async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: METHODOLOGY }] }));
130
179
  server.registerResource("sources", SOURCES_URI, {
131
180
  title: "Feed sources and polling",
132
- description: "The feeds and what the API reports about their polling, read from the API when the resource is read: the NVD poller's interval and last poll (of the instance that answered) and the CISA KEV catalog's last successful fetch and method. What no endpoint reports is named as not reported.",
181
+ description: "The feeds and what the API reports about their polling, read from the API when the resource is read: the NVD poller's interval, last poll, poll_count and poll_errors, all of the one instance that answered (the two counts since that instance last started, zeroed on every restart, never the feed's reliability), and the CISA KEV catalog's last successful fetch and method. What no endpoint reports is named as not reported.",
133
182
  mimeType: "application/json",
134
183
  }, async (uri) => json(uri.href, await readSources(kit)));
135
184
  server.registerResource("cve", new ResourceTemplate(CVE_TEMPLATE, { list: undefined }), {
136
185
  title: "CVE record",
137
- description: "One CVE's record as get_cve returns it, as JSON: the evidence envelope (state, measured_at, method, notes) with data, the API's record. cve_id is a CVE ID such as CVE-2024-3400.",
186
+ description: "One CVE's record as get_cve returns it, as JSON: the evidence envelope (state, measured_at, method, notes) with data, the API's record. Past 30,000 characters of JSON, data is cut as get_cve's first text block is, and a note starting DATA CUT says what the cut leaves out; get_cve's structured result holds the record whole. cve_id is a CVE ID such as CVE-2024-3400.",
138
187
  mimeType: "application/json",
139
188
  }, async (uri, vars) => {
140
189
  const raw = Array.isArray(vars.cve_id) ? vars.cve_id[0] : vars.cve_id;
@@ -150,6 +199,6 @@ export function registerResources(server, kit) {
150
199
  throw new ResourceNotFoundError(uri.href);
151
200
  throw new ProtocolError(ProtocolErrorCode.InternalError, msg, { uri: uri.href, state: r.structuredContent.state ?? null, error: err ?? null });
152
201
  }
153
- return json(uri.href, r.structuredContent);
202
+ return { contents: [{ uri: uri.href, mimeType: "application/json", text: cveResourceText(r.structuredContent, kit.sentences) }] };
154
203
  });
155
204
  }
package/dist/runtime.js CHANGED
@@ -18,12 +18,34 @@ export function markHttpEntrypoint() {
18
18
  export function isHttpEntrypoint() {
19
19
  return httpEntrypoint;
20
20
  }
21
- // The client a tool call is being answered for. http.ts runs each request inside
22
- // withClient(), and the SDK dispatches the tool handler inside that same async context, so
23
- // api() can read it without the tool module passing it through every function.
24
21
  const clientStore = new AsyncLocalStorage();
25
22
  export function withClient(clientIp, fn, opts = {}) {
26
- return clientStore.run({ clientIp, synthetic: opts.synthetic === true }, fn);
23
+ return clientStore.run({ clientIp, synthetic: opts.synthetic === true, signal: opts.signal, work: opts.work }, fn);
24
+ }
25
+ // #2775: a hosted client can go before its answer is ready (its own timeout, a closed tab, a
26
+ // retry). Nobody will read that answer, so the request's API calls are cancelled (index.ts api()
27
+ // reads this signal) and check_sbom sends no further batch: they would spend the client's API
28
+ // budget and hold the instance's memory for nothing. On stdio there is none: the SDK's own
29
+ // cancellation (notifications/cancelled) reaches check_sbom through its handler's signal.
30
+ export function requestSignal() {
31
+ return httpEntrypoint ? clientStore.getStore()?.signal : undefined;
32
+ }
33
+ /** Registers `p` as the hosted request's work, so its admission and large-body slot are held until p settles; on stdio, p untouched. */
34
+ export function holdRequest(p) {
35
+ const work = httpEntrypoint ? clientStore.getStore()?.work : undefined;
36
+ if (!work)
37
+ return p;
38
+ work.add(p);
39
+ const drop = () => {
40
+ work.delete(p);
41
+ };
42
+ p.then(drop, drop);
43
+ return p;
44
+ }
45
+ /** Resolves once everything held for a request (holdRequest) has settled, including what is held while it waits. */
46
+ export async function requestSettled(work) {
47
+ while (work.size > 0)
48
+ await Promise.allSettled([...work]);
27
49
  }
28
50
  // #2737: the production MCP synthetic probes this endpoint too, and its calls must reach the API
29
51
  // under its own family, never as hosted MCP adoption. The synthetic names itself to this service
@@ -34,9 +34,13 @@
34
34
  // 4. else every list keeps its first n entries, n halving, and then strings are cut too.
35
35
  // Whenever the text is not the JSON whole, the note ends with one sentence, starting TEXT CUT,
36
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.
37
+ // and how to read the rest in the text: the tool that returns one row, and what of it, and the page
38
+ // size or offset at which a page fits. The cut is deterministic: the same answer is cut the same way.
39
39
  export const DATA_TEXT_BUDGET = 30_000;
40
+ // What a note may say of reading one CVE's record through get_cve (#2802): get_cve's own first text
41
+ // block is cut too past the budget (each list to its first entries, every other field kept), so
42
+ // only its structuredContent.data is the record whole. A row tool's `whole` sentence starts with it.
43
+ export const GET_CVE_WHOLE = "get_cve returns any one of these CVEs' records, whole in its structuredContent.data, and in its first text block with every field (past 30,000 characters, each list cut to its first entries)";
40
44
  // What every tool description says about it, one constant string (marketing-site
41
45
  // lib/mcpToolClaims.test.ts folds only constant strings); the suite holds its number to
42
46
  // DATA_TEXT_BUDGET's. It uses none of the words that check reviews as the page's own prose (how,
@@ -158,8 +162,11 @@ function cutSides(data, sides) {
158
162
  }
159
163
  return { fields, said };
160
164
  }
165
+ export const FIRST_TEXT_BLOCK = {
166
+ opens: (chars) => `TEXT CUT: the API's answer is ${chars} characters of JSON, more than the ${DATA_TEXT_BUDGET} the first text block holds, so that block is cut, and structuredContent.data carries the answer whole.`,
167
+ };
161
168
  /** 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) {
169
+ export function dataText(data, cut, place = FIRST_TEXT_BLOCK) {
163
170
  const pretty = JSON.stringify(data, null, 2);
164
171
  if (pretty.length <= DATA_TEXT_BUDGET)
165
172
  return { text: pretty, said: "" };
@@ -167,7 +174,7 @@ export function dataText(data, cut) {
167
174
  const all = layout(data);
168
175
  if (all.length <= DATA_TEXT_BUDGET)
169
176
  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.`;
177
+ const head = place.opens(pretty.length);
171
178
  const say = (...xs) => [head, ...xs].filter((x) => Boolean(x)).join(" ");
172
179
  let base = data;
173
180
  let rowsSaid = "";
@@ -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 { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
30
+ import { 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.
@@ -129,7 +129,7 @@ export const CHECK_AFFECTED_TITLE = "Am I affected? (product or package at a ver
129
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
130
  " " +
131
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.";
132
+ " Cut, the excluded and undetermined samples keep their first 10 entries, each 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, ransomware, epss_score, effective_score and score_assessed at least, so that every match stays in the text.";
133
133
  export const CHECK_AFFECTED_INPUT = z.object({
134
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."),
135
135
  ecosystem: z.string().optional().describe("Registry path: the package's ecosystem, such as npm, PyPI or Maven. Give with package, not with product."),
@@ -172,17 +172,26 @@ function matchesNote(matches) {
172
172
  // description and match_reason, so openssl 3.0.0 (71 matches) was 156,058 characters of text and
173
173
  // linux_kernel 5.10.0 (200, the API's cap) 364,408. Past DATA_TEXT_BUDGET each match in the first
174
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
175
+ // the advisory interval its version falls inside: "[A, B)" names the fixed version B, "[A, B]" the
176
+ // last affected one, #2830) with the
176
177
  // 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.
178
+ // the description promises; then, for a list at the cap, without cpe_vendor and vendor_unknown;
179
+ // and last without effective_severity, the band of effective_score. kev_listed, ransomware,
180
+ // epss_score, effective_score and score_assessed stay in every level (#2799): the am_i_affected
181
+ // prompt asks for each match's ransomware and epss_score, and through 2.6.3 the last level left
182
+ // them out of every match of a list at the cap (hosted linux_kernel 5.10.0, 200 matches, 1 with
183
+ // known ransomware use, named nowhere in the text). 200 matches with effective_severity beside
184
+ // them are 33,240 characters, past the budget alone, so the band goes first. The lists beside the
185
+ // matches, the excluded sample (up to 50 CPE candidates the vendor or platform gate suppressed,
186
+ // each with a sentence of detail, 13,861 characters for flash_player 10.0.0) and the undetermined
187
+ // sample (up to 50), keep their first 10 entries, each its cve_id and reason, once the first level
188
+ // does not fit with them whole, before any match field that level keeps goes (50 of them beside
189
+ // 200 matches left the last level 120 characters past the budget for flash_player 10.0.0, and
190
+ // 1,071 for a registry answer at the cap with 50 undetermined); and the registry path's cve_ids,
191
+ // which is each match's cve_id in the order of matches (core-backend cve/handler.go), keeps its
192
+ // first 10, so that every match stays in the text, 200 at the cap: this tool has no page to read
193
+ // more from. excluded_count and undetermined_count count every entry. structuredContent.data keeps
194
+ // every list whole.
186
195
  const CHECK_AFFECTED_TEXT = {
187
196
  rows: "matches",
188
197
  levels: [
@@ -208,14 +217,15 @@ const CHECK_AFFECTED_TEXT = {
208
217
  clip: 100,
209
218
  },
210
219
  { 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 },
220
+ { keep: ["cve_id", "kev_listed", "ransomware", "epss_score", "effective_score", "effective_severity", "score_assessed"], clip: 100 },
221
+ { keep: ["cve_id", "kev_listed", "ransomware", "epss_score", "effective_score", "score_assessed"], clip: 100 },
212
222
  ],
213
223
  sides: [
214
224
  { 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 },
225
+ { list: "excluded", keep: ["cve_id", "reason"], clip: 100, cap: 10 },
226
+ { list: "undetermined", keep: ["cve_id", "reason"], clip: 100, cap: 10 },
217
227
  ],
218
- whole: "get_cve returns any one of these CVEs' records whole, and cve_intel a CVE's affected packages and fixed versions.",
228
+ whole: `${GET_CVE_WHOLE}, and cve_intel a CVE's affected packages and fixed versions.`,
219
229
  };
220
230
  async function checkAffected(kit, a) {
221
231
  const product = (a.product ?? "").trim();
@@ -42,6 +42,12 @@ const NOTE_AFFECTED_MAX = 25;
42
42
  const NOTE_CVES_MAX = 5;
43
43
  // CycloneDX nests components; this bounds the walk.
44
44
  const MAX_DEPTH = 32;
45
+ // The order in which extract() reads and counts the distinct purls (#2799 review): purls as listed;
46
+ // a CycloneDX document's components depth first, each one's nested components right after it
47
+ // (fromCycloneDX); an SPDX document's packages as listed; each purl trimmed and kept at its first
48
+ // place (dedupe). The note says it beside the position from which the purls not sent run, so a
49
+ // second call can rebuild them from its own input whether or not the text cuts not_sent_purls.
50
+ const READ_ORDER = "counted in the order this tool read them (the order of purls, or of the document's components or packages, each component's nested components right after it and before its next sibling, each purl at its first place)";
45
51
  const METHOD = "EchelonGraph's registry advisory matcher (POST /api/v1/public/cves/match/batch): each component's 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, the same matcher GET /api/v1/public/cves/match uses when given an ecosystem. No CPE matching, no score, no ranking.";
46
52
  // Always in the envelope's notes, whatever the answer: the refusals a model must not read as clean.
47
53
  const DISTRO_NOTE = "A deb, apk or rpm purl without a distro qualifier naming its release (for example ?distro=debian-12 or ?distro=alpine-3.20) is not assessed (distro_release_unknown): the advisory corpus is keyed per distro release, and EchelonGraph does not guess one.";
@@ -57,7 +63,8 @@ export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGr
57
63
  // a CVE with its description, so the 50-component Juice Shop document was 112,820 characters of
58
64
  // text, and 2,000 purls about 1,200,000. Past DATA_TEXT_BUDGET each row in the first text block
59
65
  // 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
66
+ // advisory interval the version falls inside: "[A, B)" names the fixed version B, "[A, B]" the last
67
+ // affected one, #2830) without the
61
68
  // description; then the same without match_reason; then only index, purl, verdict,
62
69
  // not_assessed_reason and cve_ids. When rows must still go, the clean ones (not_affected) leave
63
70
  // first, then the not_assessed ones, both counted in data.summary and the note, so the affected
@@ -68,7 +75,8 @@ export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGr
68
75
  // fit with that list whole, the text keeps its first 10 and the note says which they are: the
69
76
  // input's distinct purls from position `sent` + 1 on, in input order, which is how check_sbom read
70
77
  // them (x.purls.slice(answers.length * MAX_COMPONENTS)), so a second call can send them without
71
- // reading the list.
78
+ // reading the list. noteFor gives that order (READ_ORDER) beside the position, so this sentence
79
+ // points to it.
72
80
  export function sbomText(sent) {
73
81
  const matches = ["cve_id", "severity", "effective_severity", "effective_score", "kev_listed", "ransomware", "epss_score", "score_assessed"];
74
82
  const row = ["index", "purl", "ecosystem", "package", "version", "verdict", "assessed", "not_assessed_reason", "count", "not_affected_count", "undetermined_count", "cve_ids"];
@@ -83,11 +91,13 @@ export function sbomText(sent) {
83
91
  {
84
92
  list: "not_sent_purls",
85
93
  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.`,
94
+ said: (_inText, total) => `The ${total} purls not sent are the input's distinct purls from position ${sent + 1} on, counted in the order the note gives for them, so a second call can send them without reading the list.`,
87
95
  },
88
96
  ],
89
97
  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.",
98
+ // check_affected cuts its text too past the budget (#2802): only its structuredContent.data is
99
+ // a component's matches whole.
100
+ 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.",
91
101
  page: (shown) => `To read every row in the text, check ${shown} or fewer purls per call.`,
92
102
  };
93
103
  }
@@ -236,9 +246,17 @@ function noteFor(head, x, d, g) {
236
246
  if (g.waits > 0) {
237
247
  out.push(`The API's component budget (${API_COMPONENTS_PER_MINUTE} components a minute) ran out ${g.waits === 1 ? "once" : `${g.waits} times`}: the tool waited ${Math.round(g.waited_ms / 1000)} s in all, as its Retry-After asked, and sent the batch again.`);
238
248
  }
249
+ // #2799: the position, not only the list. Past DATA_TEXT_BUDGET the first text block keeps the
250
+ // first 10 of data.not_sent_purls (sbomText), so a model that reads the text alone cannot send
251
+ // "that list"; the purls not sent are always the input's distinct purls from position sent + 1 on
252
+ // (x.purls.slice(answers.length * MAX_COMPONENTS)), which it can rebuild from its own input, given
253
+ // the order they are counted in (READ_ORDER), which this sentence says whenever it gives the
254
+ // position, not only when the text cuts the list.
239
255
  const notSent = g.not_sent_purls.length;
240
256
  if (notSent > 0) {
241
- out.push(`${plural(notSent, "purl was", "purls were")} NOT sent (not_sent_reason ${g.not_sent_reason}: ${g.not_sent_detail}), so ${notSent === 1 ? "it is" : "they are"} not checked and not clean; data.not_sent_purls lists ${notSent === 1 ? "it" : "them"}: call check_sbom again with purls set to that list${g.not_sent_reason === "request_failed" ? "" : " after a minute"}.`);
257
+ const one = notSent === 1;
258
+ const where = one ? `it is the input's distinct purl at position ${g.sent + 1}` : `they are the input's distinct purls from position ${g.sent + 1} on`;
259
+ out.push(`${plural(notSent, "purl was", "purls were")} NOT sent (not_sent_reason ${g.not_sent_reason}: ${g.not_sent_detail}), so ${one ? "it is" : "they are"} not checked and not clean; ${where}, ${READ_ORDER}, and data.not_sent_purls lists ${one ? "it" : "them"}: call check_sbom again with purls set to ${one ? "it" : "them"}${g.not_sent_reason === "request_failed" ? "" : " after a minute"}.`);
242
260
  }
243
261
  out.push(`Of the ${components} components checked: ${affected} affected, ${notAffected} not affected, ${undetermined} undetermined, ${notAssessed} not assessed.`);
244
262
  const by = field(s, "not_assessed_by_reason");
@@ -335,7 +353,17 @@ export function mergeAnswers(answers) {
335
353
  out.results = results;
336
354
  return out;
337
355
  }
338
- const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
356
+ const realSleep = (ms, signal) => new Promise((r) => {
357
+ if (signal?.aborted)
358
+ return r();
359
+ const done = () => {
360
+ clearTimeout(timer);
361
+ signal?.removeEventListener("abort", done);
362
+ r();
363
+ };
364
+ const timer = setTimeout(done, ms);
365
+ signal?.addEventListener("abort", done, { once: true });
366
+ });
339
367
  export async function checkSbom(deps, a) {
340
368
  const tool = CHECK_SBOM;
341
369
  const sleep = deps.sleep ?? realSleep;
@@ -384,12 +412,17 @@ export async function checkSbom(deps, a) {
384
412
  headers: { "Content-Type": "application/json" },
385
413
  body: JSON.stringify({ components: batches[answers.length].map((purl) => ({ purl })) }),
386
414
  timeoutMs: Math.max(0, left),
415
+ signal: deps.signal,
387
416
  });
388
417
  if (r.ok) {
389
418
  answers.push(r.data);
390
419
  status = r.status;
391
420
  continue;
392
421
  }
422
+ // Cancelled (#2775): this batch was cut off, or never sent after a wait the signal ended.
423
+ // Nothing more is sent, and the answer goes nowhere.
424
+ if (deps.signal?.aborted)
425
+ return deps.failed(tool, r);
393
426
  if (r.kind === "http" && r.status === 429) {
394
427
  if (r.retryAfter === undefined) {
395
428
  stop = { reason: "rate_limited", detail: "the API answered 429 without a Retry-After to wait for" };
@@ -400,7 +433,7 @@ export async function checkSbom(deps, a) {
400
433
  else {
401
434
  const waitMs = Math.max(MIN_WAIT_MS, r.retryAfter * 1000);
402
435
  if (now() - started + waitMs <= TIME_BUDGET_MS) {
403
- await sleep(waitMs);
436
+ await sleep(waitMs, deps.signal);
404
437
  waits++;
405
438
  waitedMs += waitMs;
406
439
  waitedFor = r;
@@ -567,9 +600,12 @@ export function registerCheckSbom(server, deps) {
567
600
  sbom: z
568
601
  .union([z.string(), z.record(z.string(), z.unknown())])
569
602
  .optional()
570
- .describe("a CycloneDX JSON or SPDX JSON document, as JSON text or as an object; its purls are read here and only they are sent"),
603
+ .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"),
571
604
  }),
572
605
  outputSchema: output,
573
606
  annotations: deps.annotations,
574
- }, async (a) => deps.checked(CHECK_SBOM, output, await checkSbom(deps, a)));
607
+ }, async (a, ctx) => {
608
+ const run = checkSbom({ ...deps, signal: ctx.mcpReq.signal }, a);
609
+ return deps.checked(CHECK_SBOM, output, await (deps.hold ? deps.hold(run) : run));
610
+ });
575
611
  }
@@ -24,6 +24,15 @@
24
24
  // EXPLOIT_NUCLEI_ENABLED, EXPLOIT_POCGITHUB_ENABLED; core-backend cmd/server/main.go). The answer
25
25
  // does not say which ran, so no text here says a source is polled: it names the sources the data
26
26
  // model holds, and says coverage depends on which pollers run.
27
+ //
28
+ // Fixed versions (#2817). /enrichment keeps one affected_packages row per (CVE, ecosystem,
29
+ // package), and its fixed_version is one range's fix: the last range's as the advisory was read
30
+ // (core-backend cve/backfill_osv_packages.go summariseAffected). For CVE-2021-44228 production
31
+ // served log4j-core 2.12.2 alone (2026-10-04), the fix for 2.4-2.12.1, below an affected 2.14.1.
32
+ // Since #2817 each row also carries fixed_branches, the fix for every range (core-backend
33
+ // cve/fixed_branches.go, from the version_intervals the matcher evaluates): this tool relays it as
34
+ // sent, its description says how to pick the range for an installed version, and the note counts
35
+ // the rows where fixed_version is not the whole answer.
27
36
  import * as z from "zod";
28
37
  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
38
  import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
@@ -60,7 +69,8 @@ const API_ROW_LIMIT = { exploits: 10, affected_packages: 50, fixed_versions: 200
60
69
  const TIMELINE_RELAYED = 20;
61
70
  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.";
62
71
  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.";
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}`;
72
+ 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.';
73
+ 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_branches), fixed_versions (ecosystem, package_name, vulnerable_range, fixed_version: one row per fixed version, with the vulnerable_range it fixes where the source gives one) and timeline (the newest enrichment-history rows, with timeline_total). ${FIXED_BRANCHES_DESCRIPTION} ${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}`;
64
74
  // ── outputSchema ──
65
75
  const Rows = (shape) => z.array(z.looseObject(shape));
66
76
  const CountMap = z.record(z.string(), z.number());
@@ -88,7 +98,18 @@ function schemas() {
88
98
  ecosystem: opt(z.string()),
89
99
  package_name: opt(z.string()),
90
100
  version_range: opt(z.string()),
91
- fixed_version: opt(z.string()),
101
+ fixed_version: opt(z.string()).describe("One range's fix, kept for compatibility; not the fix for every affected range: read fixed_branches."),
102
+ fixed_branches: z
103
+ .array(z.looseObject({
104
+ introduced: opt(z.string()).describe('The range\'s lower bound, inclusive; "0" is the first version.'),
105
+ fixed: opt(z.string()).describe("The range's fix: the first version past it. null where no fix is on record."),
106
+ last_affected: opt(z.string()).describe("The range's last affected version, inclusive, where no fix is on record; never a fix."),
107
+ advisory_id: opt(z.string()).describe("The OSV record that published the range (GHSA-…, PYSEC-…, CVE-…); null on a row loaded before it was stored."),
108
+ source: opt(z.string()).describe("The loader's label for that record (osv_bulk, osv); null on a row loaded before it was stored."),
109
+ }))
110
+ .nullable()
111
+ .optional()
112
+ .describe(FIXED_BRANCHES_DESCRIPTION),
92
113
  dependents_count: opt(z.number()),
93
114
  source: opt(z.string()),
94
115
  }).optional(),
@@ -170,6 +191,50 @@ function listSentence(name, rows) {
170
191
  const cut = rows.length >= limit ? `, the most the API returns per CVE, so there may be more` : "";
171
192
  return `${name} lists ${rows.length} row${rows.length === 1 ? "" : "s"}${cut}.`;
172
193
  }
194
+ // #2817: where fixed_version is not the whole answer. A row with more than one range in
195
+ // fixed_branches has a fix per range; a row with fixed_branches null has only fixed_version on
196
+ // record, and so does a row with fixed_branches [] and a fixed_version (no version range on
197
+ // record: none in the advisory, only commit ranges, or more than core-backend stores); an answer
198
+ // whose rows carry no fixed_branches is an API older than #2817.
199
+ function branchSentences(rows) {
200
+ if (rows.length === 0)
201
+ return [];
202
+ const named = (r, i) => {
203
+ const eco = strAt(r, "ecosystem");
204
+ const pkg = strAt(r, "package_name") ?? `affected_packages[${i}]`;
205
+ return eco ? `${pkg} (${eco})` : pkg;
206
+ };
207
+ const withField = rows.filter((r) => field(r, "fixed_branches") !== undefined);
208
+ if (withField.length === 0) {
209
+ return [
210
+ "No affected_packages row carries fixed_branches (an API older than that field): each row's fixed_version is one range's fix, which need not be the fix for a given installed version.",
211
+ ];
212
+ }
213
+ const out = [];
214
+ const multi = [];
215
+ const unknown = [];
216
+ const noRange = [];
217
+ rows.forEach((r, i) => {
218
+ const fb = field(r, "fixed_branches");
219
+ if (Array.isArray(fb) && fb.length > 1)
220
+ multi.push(named(r, i));
221
+ if (fb === null)
222
+ unknown.push(named(r, i));
223
+ if (Array.isArray(fb) && fb.length === 0 && strAt(r, "fixed_version"))
224
+ noRange.push(named(r, i));
225
+ });
226
+ const few = (xs) => (xs.length <= 3 ? listed(xs) : `${xs.slice(0, 3).join(", ")} and ${xs.length - 3} more`);
227
+ if (multi.length) {
228
+ 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.`);
229
+ }
230
+ if (unknown.length) {
231
+ 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.`);
232
+ }
233
+ if (noRange.length) {
234
+ out.push(`${noRange.length} affected_packages row${noRange.length === 1 ? " carries" : "s carry"} fixed_branches [] and a fixed_version (${few(noRange)}): no version range is on record there, which is not a finding that no fix exists, so fixed_version, one range's fix, is all the answer holds, and it need not be the fix for a given installed version.`);
235
+ }
236
+ return out;
237
+ }
173
238
  // ── The tool ──
174
239
  export async function cveIntel(cve_id) {
175
240
  const id = cve_id.trim();
@@ -218,6 +283,8 @@ export async function cveIntel(cve_id) {
218
283
  const xs = rows(k);
219
284
  if (xs)
220
285
  said.push(listSentence(k, xs));
286
+ if (xs && k === "affected_packages")
287
+ said.push(...branchSentences(xs));
221
288
  }
222
289
  const timeline = rows("timeline");
223
290
  const apiTimeline = field(d, "timeline");
@@ -13,7 +13,7 @@
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
+ import { GET_CVE_WHOLE, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
17
17
  const TOOL = "get_cwe";
18
18
  const CWE_ARG = /^(?:CWE-)?0*(\d{1,6})$/i;
19
19
  const MAX_PAGE = 200;
@@ -25,7 +25,7 @@ export const GET_CWE_DESCRIPTION = `One CWE (weakness class) and the CVEs classi
25
25
  const GET_CWE_TEXT = {
26
26
  rows: "cves",
27
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.",
28
+ whole: `${GET_CVE_WHOLE}.`,
29
29
  };
30
30
  function schemas() {
31
31
  const row = z.looseObject({
@@ -1,5 +1,5 @@
1
1
  import * as z from "zod";
2
- import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
2
+ import { GET_CVE_WHOLE, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
3
3
  const TOOL = "kev_recent";
4
4
  const PATH = "/api/v1/public/kev/recent";
5
5
  export const KEV_RECENT_MAX_LIMIT = 200;
@@ -140,7 +140,8 @@ function kevText(a) {
140
140
  { keep: ["cve_id", "kev_added_date", "kev_vendor", "kev_product", "kev_ransomware"], clip: 40 },
141
141
  { keep: ["cve_id", "kev_added_date", "kev_vendor", "kev_ransomware"], clip: 40 },
142
142
  ],
143
- whole: "get_cve returns any one of these CVEs' records whole.",
143
+ // A record carries the CISA fields a row leaves out (kev_due_date, kev_product, kev_vuln_name).
144
+ whole: `${GET_CVE_WHOLE}.`,
144
145
  page: (shown) => `next_cursor continues after the last row of this page, not after the last row in this text: to read the rows left out in the text, call kev_recent again with the same filters${a.cursor?.trim() ? " and cursor" : ""} and a limit of ${shown}, whose rows are the ones in this text, then with that page's next_cursor and a limit of ${shown} or fewer.`,
145
146
  };
146
147
  }