echelongraph-mcp 3.0.0 → 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.
@@ -73,9 +73,13 @@ 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
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.
@@ -84,7 +88,7 @@ const FIXED_BRANCHES_DESCRIPTION = 'fixed_branches lists each affected range of
84
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
85
89
  // states the walk FIXED_BRANCHES_DESCRIPTION and branchSentences state, in fewer characters than
86
90
  // the old rule (a range's bounds folded into the list of its fields), so the headroom holds.
87
- 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 (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}`;
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}`;
88
92
  // ── outputSchema ──
89
93
  const Rows = (shape) => z.array(z.looseObject(shape));
90
94
  const CountMap = z.record(z.string(), z.number());
@@ -178,6 +182,24 @@ function cweSentence(cve, cwes) {
178
182
  });
179
183
  return `${cve} is classified under ${listed(named)}.`;
180
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
+ }
181
203
  function exploitSentences(cve, d, rows) {
182
204
  const total = numAt(d, "exploits_total");
183
205
  const verified = numAt(d, "exploits_by_status", "verified");
@@ -191,7 +213,14 @@ function exploitSentences(cve, d, rows) {
191
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.`,
192
214
  ];
193
215
  }
194
- 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}`];
195
224
  }
196
225
  if (total === 0) {
197
226
  return [
@@ -200,7 +229,23 @@ function exploitSentences(cve, d, rows) {
200
229
  }
201
230
  const v = verified === undefined ? "" : `, ${verified} of them with verified_status verified`;
202
231
  const cut = capped === true ? ` exploits_capped is true: exploits lists ${rows.length} of the ${total}, verified first.` : "";
203
- 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
+ ];
204
249
  }
205
250
  function listSentence(name, rows) {
206
251
  const limit = API_ROW_LIMIT[name] ?? 0;
@@ -124,6 +124,10 @@ function schemas() {
124
124
  advisory_id: opt(z.string()),
125
125
  source: opt(z.string()),
126
126
  }))),
127
+ fixed_branches_truncated: z
128
+ .object({ total: z.number().int(), returned: z.number().int() })
129
+ .optional()
130
+ .describe("Present only where the structured result's cut (DATA CUT) kept this package's first ranges, not all: total, its ranges in the answer; returned, the ones data keeps. The same call with full: true returns every range."),
127
131
  }))),
128
132
  vendor_remediations: opt(z.array(VendorAdvisory)),
129
133
  fix_references: opt(z.array(z.looseObject({ url: opt(z.string()), tags: opt(z.array(z.string())), source: opt(z.string()) }))),
@@ -163,9 +167,44 @@ const CUT = {
163
167
  // Before any advisory leaves, every advisory's items are thinned: product_ids first, keeping each
164
168
  // text; then the texts, keeping each item's kind, source_category, url and fixed_build, so every
165
169
  // advisory stays named with its state, kinds and links (get_vendor_advisory returns one whole).
166
- // Only then do advisories leave from the end, then the patches, then any other list at the top of
167
- // data. fix_status and fixed_in (#2841) are not lists, and installed is an object, so none of them
168
- // is ever cut.
170
+ // fix_status and fixed_in (#2841) are not lists, and installed is an object, so none of them is
171
+ // ever cut.
172
+ //
173
+ // #3139: through 3.0.0 advisories then left from the end, before patches, and fixed_branches had no
174
+ // step, so CVE-2023-44487 (KEV-listed) kept 2 of its 20 advisories at 29,704 characters while
175
+ // fixed_branches (13,581 characters, 50 rows) and patches (7,471, 30) stayed whole, and the note
176
+ // named no tool that lists the other 18 (measured on hosted 3.0.0, 2026-10-11; also CVE-2018-12126
177
+ // 6/20, CVE-2019-11135 2/3, CVE-2018-3639 16/20). Now vendor_remediations is a keepAll step: every
178
+ // advisory stays, thinned, while patches (each to its vendor, patch_url and published_at: the
179
+ // erratum, which vendor_remediations names again for Red Hat) and then fixed_branches (each to its
180
+ // package, range, fixed_version and its ranges' introduced, fixed and last_affected, what the
181
+ // sbom_review prompt walks; the per-range source, advisory_id and dependents_count go) are thinned
182
+ // and then lose rows from the end, and then any other list at the top of data (fix_references).
183
+ // Only when all of that is cut and the result is still past the budget do advisories leave, from
184
+ // the end; whenever one does, the note names vendor_advisories_for_cve, which lists every vendor
185
+ // advisory naming the CVE, as where the rest are.
186
+ // #3139's review: that order dropped 2 of CVE-2023-44487's 30 patches while its 50 fixed_branches
187
+ // rows stayed whole (13,581 characters). Decided (2026-10-11): fixed_branches is thinned before any
188
+ // patch row leaves. The order is now:
189
+ // 1. vendor_remediations thinned, as above, every advisory kept (keepAll);
190
+ // 2. fixed_branches thinned (each package to its ecosystem, package_name, version_range,
191
+ // fixed_version and its ranges' introduced, fixed and last_affected: each range's source and
192
+ // advisory_id go, and the package's dependents_count and source), then each package's ranges
193
+ // capped at one number, the most that fit (at least 1), a package with more carrying
194
+ // fixed_branches_truncated with its total and returned; every package kept (keepAll);
195
+ // 3. patches thinned (vendor, patch_url and published_at), every patch kept (keepAll);
196
+ // 4. any other list at the top of data (fix_references), cut from the end;
197
+ // 5. then patch rows leave from the end, then fixed_branches rows, and only then advisories
198
+ // (textBudget.ts: the last keepAll step's entries leave first);
199
+ // 6. the room left goes back to each package's ranges, in the order of fixed_branches (giveBack):
200
+ // step 2 caps them before the patches are cut, and a cap that held while every patch was
201
+ // whole would otherwise stay after the patches are thinned.
202
+ // Round 2 replayed CVE-2023-44487 at 28 of 30 patches: fix_references (2,191 characters) were cut
203
+ // after patch rows left, and the 30 thinned were about 240 characters past the budget beside them.
204
+ // Round 3 (#3139's second review, 2026-10-11): patches is a keepAll step, so fix_references are cut
205
+ // first and CVE-2023-44487 keeps all 20 advisories, all 50 fixed_branches and all 30 patches, each
206
+ // thinned (test/cve_remediation.test.mjs); round 1 (patches cut before fixed_branches was thinned)
207
+ // kept 11 patches without fix_references.
169
208
  //
170
209
  // #3013's review: the second level leaves every item's text out of data, and the text is the
171
210
  // vendor's step itself (a workaround's or mitigation's wording), which the triage_cve and
@@ -175,6 +214,7 @@ const CUT = {
175
214
  // with full: true returns this data whole; the prompts send the model there when it is cut.
176
215
  const ADVISORY_FIELDS = ["vendor", "vendor_display_name", "vendor_advisory_id", "vendor_published_at", "withdrawn", "remediation_state", "remediation_kinds", "items_total"];
177
216
  const ITEM_TEXT_ELSEWHERE = "No item in data keeps its text, the vendor's own wording of the step (a workaround or mitigation among them): get_vendor_advisory, given an advisory's vendor and its vendor_advisory_id as advisory_id, returns it among that advisory's remediations, whole in its structuredContent.data with full: true.";
217
+ const FIXED_BRANCHES_THIN = ["ecosystem", "package_name", "version_range", "fixed_version", ["fixed_branches", ["introduced", "fixed", "last_affected"]]];
178
218
  const CVE_REMEDIATION_DATA = {
179
219
  steps: [
180
220
  {
@@ -183,15 +223,43 @@ const CVE_REMEDIATION_DATA = {
183
223
  [...ADVISORY_FIELDS, ["items", ["kind", "source_category", "source_subcategory", "text", "text_truncated", "text_chars", "url", "fixed_build", "product_count"]]],
184
224
  [...ADVISORY_FIELDS, ["items", ["kind", "source_category", "url", "fixed_build"]]],
185
225
  ],
226
+ keepAll: true,
227
+ },
228
+ {
229
+ lists: ["fixed_branches"],
230
+ thin: [
231
+ FIXED_BRANCHES_THIN,
232
+ {
233
+ keep: FIXED_BRANCHES_THIN,
234
+ capped: "fixed_branches",
235
+ least: 1,
236
+ mark: (row, cap) => {
237
+ const n = Array.isArray(row.fixed_branches) ? row.fixed_branches.length : 0;
238
+ return n > cap ? { fixed_branches_truncated: { total: n, returned: cap } } : {};
239
+ },
240
+ said: (cap) => `each package's fixed_branches keeping at most its first ${cap} ranges, a package with more carrying fixed_branches_truncated with its total and returned`,
241
+ },
242
+ ],
243
+ keepAll: true,
244
+ giveBack: true,
186
245
  },
187
- { lists: ["patches"] },
246
+ // #3139 round 3: every patch row stays, thinned (keepAll), while the step after it (any other
247
+ // list at the top of data: fix_references) is cut; patch rows leave only after that, and
248
+ // before any fixed_branches row or advisory (the last keepAll step's entries leave first).
249
+ { lists: ["patches"], thin: [["vendor", "patch_url", "published_at"]], keepAll: true },
188
250
  ],
189
- // Said where the advisories kept are thinned to a level whose items keep no text.
190
- page: (_t, _d, at) => {
251
+ // Said where the advisories kept are thinned to a level whose items keep no text, and (#3139)
252
+ // wherever an advisory is left out.
253
+ page: (t, _d, at) => {
191
254
  const items = at.keeps.vendor_remediations?.find((x) => typeof x !== "string" && x[0] === "items");
192
- return items && !items[1].includes("text") ? ITEM_TEXT_ELSEWHERE : "";
255
+ const v = t.vendor_remediations;
256
+ return [items && !items[1].includes("text") ? ITEM_TEXT_ELSEWHERE : "", v && v.returned < v.total ? advisoriesElsewhere(v.total - v.returned) : ""].filter(Boolean).join(" ");
193
257
  },
194
258
  };
259
+ // #3139: where the advisories a cut leaves out are read. vendor_advisories_for_cve lists every one
260
+ // naming the CVE, with its vendor and vendor_advisory_id (CVE-2023-44487's 20 at 15,505
261
+ // characters on hosted 3.0.0), and get_vendor_advisory then returns any one whole.
262
+ const advisoriesElsewhere = (n) => `vendor_advisories_for_cve, given this CVE, lists every vendor advisory naming it, the ${n} left out of data among them, each with its vendor and vendor_advisory_id.`;
195
263
  const listed = (xs) => (xs.length <= 1 ? (xs[0] ?? "") : `${xs.slice(0, -1).join(", ")} and ${xs.at(-1)}`);
196
264
  const arr = (v) => (Array.isArray(v) ? v : null);
197
265
  const strs = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === "string") : null);
@@ -250,7 +318,13 @@ function fixStatusSentence(cve, fs) {
250
318
  const unread = Array.isArray(field(fs, "unread_inputs")) ? field(fs, "unread_inputs").filter((x) => typeof x === "string") : [];
251
319
  const why = state === "none_confirmed" ? ", which is not a finding that no fix exists" : "";
252
320
  const notRead = unread.length ? `; it rests without ${listed(unread)}, which the API could not read (unread_inputs)` : "";
253
- return `fix_status for ${cve} is ${state}${basis ? ` (basis ${basis})` : ""}, the API's fix-status rule${why}${notRead}.`;
321
+ // #3076, third pass: what vendor_fix_released evidence is, so "available" is not read as more
322
+ // than it says. Relayed from the field the API set, nothing inferred.
323
+ const released = field(fs, "vendor_released");
324
+ const qualifier = released !== null && released !== undefined && typeof released === "object"
325
+ ? `; the vendor states a fix is released, but the CVE record names no fixed version (vendor_released), so the fixed release is the vendor advisory's to name`
326
+ : "";
327
+ return `fix_status for ${cve} is ${state}${basis ? ` (basis ${basis})` : ""}, the API's fix-status rule${why}${qualifier}${notRead}.`;
254
328
  }
255
329
  // The sentence on the installed version asked: what the API answered for it, relayed.
256
330
  function installedSentence(asked, d) {
@@ -208,8 +208,11 @@ export async function epssHistory(kit, cve_id) {
208
208
  return kit.crashed(TOOL, e);
209
209
  }
210
210
  }
211
+ // One output schema per process, not per request (3.0.2; index.ts envelopeSchema says why).
212
+ const OUTPUTS = new WeakMap();
211
213
  export function registerEPSSHistory(server, kit) {
212
- const output = epssHistoryOutput(kit);
214
+ const output = OUTPUTS.get(kit) ?? epssHistoryOutput(kit);
215
+ OUTPUTS.set(kit, output);
213
216
  server.registerTool(TOOL, {
214
217
  title: "EPSS change history for one CVE",
215
218
  description: EPSS_HISTORY_DESCRIPTION,
@@ -223,9 +223,12 @@ async function kevRecent(d, a) {
223
223
  return d.crashed(TOOL, e);
224
224
  }
225
225
  }
226
+ // One output schema per process, not per request (3.0.2; index.ts envelopeSchema says why).
227
+ const OUTPUTS = new WeakMap();
226
228
  // Registers the tool on a server built by index.ts's createServer.
227
229
  export function registerKevRecent(server, d) {
228
- const output = d.envelopeSchema({ data: Data, coverage: Coverage, freshness: Freshness });
230
+ const output = OUTPUTS.get(d.envelopeSchema) ?? d.envelopeSchema({ data: Data, coverage: Coverage, freshness: Freshness });
231
+ OUTPUTS.set(d.envelopeSchema, output);
229
232
  server.registerTool("kev_recent", {
230
233
  title: "Recent CISA KEV additions",
231
234
  description: KEV_RECENT_DESCRIPTION,
@@ -257,11 +257,16 @@ export async function sendBatches(deps, tool, purls) {
257
257
  // (purls.slice(answers.length * MAX_COMPONENTS)), which it can rebuild from its own input, given
258
258
  // the order they are counted in (`readOrder`), which the sentence says whenever it gives the
259
259
  // position, not only when the text cuts the list. #3013: the structured result's cut empties
260
- // data.not_sent_purls before any row leaves (it is rebuilt from the position), and gives it back
261
- // only the first entries the room the rows leave holds, so the sentence says it lists them whole
262
- // with full: true. `retry` is the tool and argument to send them to ("check_sbom
263
- // again with purls set to").
264
- export function sendingSentences(g, distinct, readOrder, retry) {
260
+ // data.not_sent_purls before any row leaves (it is rebuilt from the position). #3132's review:
261
+ // round 1 then gave it back the first entries the room the rows left held (3 of the rate-limited
262
+ // case's 800), a list a client would retry as though it were the whole; the cut now keeps it whole
263
+ // or not at all (check_sbom.ts sbomData), and the sentence gives the positions as coverage holds
264
+ // them. `retry` is the tool and argument to send them to ("check_sbom again with purls set to").
265
+ // #3132's second review: those positions index the purls this tool read, which a check_sbom
266
+ // client rebuilds from its own purl list (or SBOM), but a scan_manifest client cannot without
267
+ // reading the files as this tool does; with `positions` false (scan_manifest), the sentence sends
268
+ // it to the same call with full: true, which always returns the list, instead.
269
+ export function sendingSentences(g, distinct, readOrder, retry, positions = true) {
265
270
  const out = [];
266
271
  if (g.batches > 1) {
267
272
  out.push(`The ${distinct} distinct purls make ${g.batches} batches of at most ${MAX_COMPONENTS}, sent one after another; the API answered ${g.batches_sent} of them, and data.summary sums those answers.`);
@@ -273,7 +278,7 @@ export function sendingSentences(g, distinct, readOrder, retry) {
273
278
  if (notSent > 0) {
274
279
  const one = notSent === 1;
275
280
  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`;
276
- 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}, ${readOrder}, and data.not_sent_purls lists ${one ? "it" : "them"} whole with full: true (past the structured result's budget, that list can be cut by default): call ${retry} ${one ? "it" : "them"}${g.not_sent_reason === "request_failed" ? "" : " after a minute"}.`);
281
+ 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}, ${readOrder}, and data.not_sent_purls lists ${one ? "it" : "them"} (past the structured result's budget it is left out whole by default, never cut short${positions ? `, and ${one ? "that position is" : "those positions are"} coverage.sent + 1 to coverage.distinct_purls; with full: true it is always there` : ": the same call with full: true always returns it"}): call ${retry} ${one ? "it" : "them"}${g.not_sent_reason === "request_failed" ? "" : " after a minute"}.`);
277
282
  }
278
283
  return out;
279
284
  }
@@ -380,6 +385,17 @@ export const Row = z.looseObject({
380
385
  undetermined: opt(z.array(z.looseObject({ cve_id: opt(z.string()) }))),
381
386
  capped: opt(z.boolean()),
382
387
  candidates_capped: opt(z.boolean()),
388
+ // #3132: set by this server's structured cut (check_sbom.ts SBOM_ROW_LEVELS), never by the API.
389
+ matches_truncated: z
390
+ .strictObject({
391
+ total: z.number().int().min(0),
392
+ returned: z.number().int().min(0),
393
+ kev_listed_cve_ids: z.array(z.string()).optional(),
394
+ ransomware_cve_ids: z.array(z.string()).optional(),
395
+ fixed_in_versions: z.array(z.string()).optional(),
396
+ })
397
+ .optional()
398
+ .describe("Present only on a row whose matches the structured result's cut (DATA CUT) shortened or left out: total, the row's matches in the answer; returned, the ones data keeps (its first, highest effective_score first); and, while they fit, 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). The same call with full: true returns every match."),
383
399
  });
384
400
  // The batch route's answer, as data carries it: its fields, merged over the batches.
385
401
  export const batchDataShape = {
@@ -22,12 +22,20 @@ export const SCAN_MANIFEST_DESCRIPTION = "Check a project's lockfile or pinned m
22
22
  " Cut, each row keeps index, purl, verdict, not_assessed_reason and cve_ids at least; not_checked keeps its first 20.";
23
23
  // The text's cut: check_sbom's rows and levels, and data.not_checked beside them, cut to its
24
24
  // first TEXT_NOT_CHECKED once the first level does not fit (structuredContent.data keeps it whole).
25
+ // #3132's second review: check_sbom's sentence on not_sent_purls says a second call can send them
26
+ // without reading the list, from their positions in the input; here those positions index purls
27
+ // read from the files, which a client cannot rebuild, so the sentence names full: true instead.
25
28
  export function manifestText(sent) {
26
29
  const sbom = sbomText(sent);
27
30
  return {
28
31
  ...sbom,
29
32
  sides: [
30
- ...(sbom.sides ?? []),
33
+ ...(sbom.sides ?? []).map((side) => side.list === "not_sent_purls"
34
+ ? {
35
+ ...side,
36
+ said: (_inText, total) => `The ${total} purls not sent are the purls this tool read from position ${sent + 1} on, counted in the order the note gives for them; structuredContent.data.not_sent_purls lists every one with full: true.`,
37
+ }
38
+ : side),
31
39
  {
32
40
  list: "not_checked",
33
41
  cap: TEXT_NOT_CHECKED,
@@ -126,7 +134,7 @@ function noteFor(head, x, d, g) {
126
134
  // counts above are coverage.not_checked_by_reason's, which no cut touches.
127
135
  out.push(`${plural(nc, "entry was", "entries were")} NOT checked and ${nc === 1 ? "is" : "are"} not clean (${parts.join(", ")}): data.not_checked lists each with its file, line and reason, whole with full: true (past the structured result's budget, that list can be cut by default), and none was sent to the API.`);
128
136
  }
129
- out.push(...sendingSentences(g, x.purls.length, READ_ORDER, "check_sbom with purls set to"));
137
+ out.push(...sendingSentences(g, x.purls.length, READ_ORDER, "check_sbom with purls set to", false));
130
138
  if (d === null)
131
139
  return { note: out.join(" "), assessed: 0 };
132
140
  const v = verdictSentences(d, g);
@@ -243,11 +251,15 @@ export function scanManifestOutput(envelopeSchema) {
243
251
  freshness: null,
244
252
  });
245
253
  }
254
+ // One output schema per process, not per request (3.0.2; index.ts envelopeSchema says why).
255
+ const OUTPUTS = new WeakMap();
246
256
  // A literal server.registerTool(name, { title, description, … }) call with constant strings:
247
257
  // marketing-site lib/mcpToolClaims.test.ts reads it here (it follows index.ts's call to this
248
258
  // function) and holds the /pulse/mcp row to this title and description.
249
259
  export function registerScanManifest(server, deps) {
250
- const output = scanManifestOutput(deps.envelopeSchema);
260
+ const envelope = deps.envelopeSchema;
261
+ const output = OUTPUTS.get(envelope) ?? scanManifestOutput(envelope);
262
+ OUTPUTS.set(envelope, output);
251
263
  server.registerTool(SCAN_MANIFEST, {
252
264
  title: SCAN_MANIFEST_TITLE,
253
265
  description: SCAN_MANIFEST_DESCRIPTION,
@@ -352,8 +352,11 @@ const SEARCH_DATA = {
352
352
  },
353
353
  };
354
354
  const ROW_CUT_DESCRIPTION = " Cut, each row keeps vendor, vendor_advisory_id, title, severity and cve_ids at least, its strings and lists shortened";
355
- export function registerVendorAdvisoryTools(server, kit) {
356
- const FOR_CVE_OUTPUT = kit.envelopeSchema({
355
+ // The three tools' output schemas, one each per process, not per request (3.0.2; index.ts
356
+ // envelopeSchema says why).
357
+ const OUTPUTS = new WeakMap();
358
+ function outputsOf(envelopeSchema) {
359
+ const FOR_CVE_OUTPUT = envelopeSchema({
357
360
  data: z.looseObject({
358
361
  cve_id: opt(z.string()),
359
362
  advisories: opt(z.array(AdvisoryRow)),
@@ -373,7 +376,7 @@ export function registerVendorAdvisoryTools(server, kit) {
373
376
  }),
374
377
  freshness: null,
375
378
  });
376
- const DETAIL_OUTPUT = kit.envelopeSchema({
379
+ const DETAIL_OUTPUT = envelopeSchema({
377
380
  data: AdvisoryRow.extend({
378
381
  description: opt(z.string()),
379
382
  known_cve_ids: opt(z.array(z.string())).describe("The CVE IDs of cve_ids that have a record in EchelonGraph's CVE feed."),
@@ -402,7 +405,7 @@ export function registerVendorAdvisoryTools(server, kit) {
402
405
  }),
403
406
  freshness: null,
404
407
  });
405
- const SEARCH_OUTPUT = kit.envelopeSchema({
408
+ const SEARCH_OUTPUT = envelopeSchema({
406
409
  data: z.looseObject({
407
410
  advisories: opt(z.array(AdvisoryRow)),
408
411
  total: opt(z.number()).describe("The matches; when total_capped is true, a lower bound: 1,000 or more."),
@@ -424,6 +427,12 @@ export function registerVendorAdvisoryTools(server, kit) {
424
427
  }),
425
428
  freshness: null,
426
429
  });
430
+ return { FOR_CVE_OUTPUT, DETAIL_OUTPUT, SEARCH_OUTPUT };
431
+ }
432
+ export function registerVendorAdvisoryTools(server, kit) {
433
+ const outputs = OUTPUTS.get(kit.envelopeSchema) ?? outputsOf(kit.envelopeSchema);
434
+ OUTPUTS.set(kit.envelopeSchema, outputs);
435
+ const { FOR_CVE_OUTPUT, DETAIL_OUTPUT, SEARCH_OUTPUT } = outputs;
427
436
  // The vendors' windows, read beside a tool's own request. Never a failure of the tool.
428
437
  async function readWindows() {
429
438
  const r = await kit.api(VENDOR_COVERAGE_PATH);
package/docs/tools.md CHANGED
@@ -62,8 +62,8 @@ Both tools relay the API's JSON as it was sent, and the note says what the score
62
62
 
63
63
  ## What `patch_available` says
64
64
 
65
- `patch_available` is `true` when EchelonGraph holds evidence of a fix for the CVE, and
66
- `patch_evidence` lists the sources of that evidence, strongest first: `vendor_patch` (a vendor
65
+ `patch_available` is `true` when EchelonGraph holds, or once held, evidence of a fix for the CVE,
66
+ and `patch_evidence` lists the sources it holds now, strongest first: `vendor_patch` (a vendor
67
67
  patch on record, from Ubuntu or Red Hat), `fixed_version` (a fixed version recorded for an
68
68
  affected package, or the fixed bound of an affected version range), `release_reference` (a
69
69
  reference that EchelonGraph confirmed through the GitHub API is a published release containing
@@ -74,11 +74,15 @@ CSAF `vendor_fix`, Microsoft's Vendor Fix), `nvd_patch_reference` (an NVD refere
74
74
  (a sentence of the CVE record's solutions, written by its CNA, that names a fixed version) and
75
75
  `description_fix` (a sentence of the CVE description that names a fixed version). A reference page
76
76
  that only mentions a patch, and a CVE's presence in CISA's KEV catalog, are not evidence.
77
- `get_cve` relays the CNA's own sentence as `cna_solution`, its date for when the vulnerability was
78
- made public as `date_public` (with `date_public_source` `cna`), and the CNA as `cna`, each only
79
- where the CVE record has it. `false` means no fix evidence is on record, which is **not** a
77
+ `get_cve` relays the CNA's own sentence as `cna_solution`, which section of the record it is as
78
+ `cna_solution_section` (`solution` or `workaround`: a workaround's sentence is the record's
79
+ workaround, never its solution), its date for when the vulnerability was made public as
80
+ `date_public` (with `date_public_source` `cna`), and the CNA as `cna`, each only where the CVE
81
+ record has it. `false` means no fix evidence is on record, which is **not** a
80
82
  finding that no fix exists. Once `true`, `patch_available` is not set back to `false`, so it can be
81
- `true` beside an empty `patch_evidence`. An answer with no `patch_evidence` (an API older than that
83
+ `true` beside an empty `patch_evidence`: evidence of a fix once held has since been withdrawn, and
84
+ `remediation.fix_status` in the CVE's record is the current reading, not `patch_available` (since
85
+ 3.0.2 the field's outputSchema text says so). An answer with no `patch_evidence` (an API older than that
82
86
  field) does not name the evidence, and a `false` there is not a finding that no fix exists either.
83
87
  Both tools relay the two fields as the API sends them.
84
88
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "echelongraph-mcp",
3
- "version": "3.0.0",
3
+ "version": "3.0.2",
4
4
  "mcpName": "io.echelongraph/echelongraph-mcp",
5
5
  "description": "MCP server for CVE lookups: EchelonGraph scores, CISA KEV, EPSS, CWE, exploits, version and SBOM checks, vendor advisories, and per-CVE exposure derived from Shodan data. Shodan data is owned by Shodan, which holds its copyright (© Shodan). Keyless.",
6
6
  "type": "module",
package/server.json CHANGED
@@ -17,7 +17,7 @@
17
17
  ]
18
18
  }
19
19
  ],
20
- "version": "3.0.0",
20
+ "version": "3.0.2",
21
21
  "remotes": [
22
22
  {
23
23
  "type": "streamable-http",
@@ -28,7 +28,7 @@
28
28
  {
29
29
  "registryType": "npm",
30
30
  "identifier": "echelongraph-mcp",
31
- "version": "3.0.0",
31
+ "version": "3.0.2",
32
32
  "transport": {
33
33
  "type": "stdio"
34
34
  },