echelongraph-mcp 2.6.5 → 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/CHANGELOG.md CHANGED
@@ -6,6 +6,114 @@ with a provenance attestation by the release workflow of
6
6
  [github.com/echelongraph/echelongraph-mcp](https://github.com/echelongraph/echelongraph-mcp),
7
7
  from the commit tagged `v<version>`.
8
8
 
9
+ ## 2.6.6 — 2026-10-05
10
+
11
+ - Correction to 2.6.5 (#2775). Its entry says: "On the hosted endpoint it is the client going away
12
+ (its own timeout, a closed tab): every API call of that request is cancelled". That was not true
13
+ of the hosted endpoint. Cloud Run does not pass a client's disconnect on to a container it
14
+ speaks HTTP/1.1 to ("When you use HTTP/1.1 on Cloud Run, client disconnect events are not
15
+ propagated to the Cloud Run container",
16
+ <https://docs.cloud.google.com/run/docs/troubleshooting>). In production on 2026-10-05, four
17
+ `check_sbom` calls had their client abort after 0.4 s or 1.5 s. Each was logged `status` 200
18
+ with `client_closed` false and ran to its end, 2.0 to 5.3 s. Together they held both large-body
19
+ slots, so a large body sent 1 s later was answered 503. The same page says HTTP/2 does pass the
20
+ disconnect on. So `dist/http.js` now serves HTTP/2 cleartext (h2c) when `MCP_H2C=1`, and the
21
+ hosted deployment sets it together with Cloud Run's `--use-http2`. With that flag, Cloud Run
22
+ speaks only h2c to the container. The server acts on a reset stream the moment it arrives, with
23
+ any error code including `NO_ERROR`: the API calls are cancelled, `check_sbom` sends no further
24
+ batch and ends a `Retry-After` wait, the large-body slot is freed, and the `mcp_request` line
25
+ says `status` 499 with `client_closed` true. What Cloud Run forwards is only partly measured.
26
+ On test deployments with `--use-http2` on 2026-10-05, ordinary calls, sessions and `/health`
27
+ were all answered over h2c. A client that stopped reading a streamed answer had its stream reset
28
+ by Cloud Run 15 s in (`rst_code` 2), and that was logged 499. A client that aborts mid-call
29
+ before any answer was not observed, because every probe call was answered first, so whether
30
+ Cloud Run forwards that reset at once is not yet shown. Until it is, an abandoned call may still
31
+ run to its end, a `check_sbom` call for at most its 50-second budget, holding its slot. A new line, `mcp_client_gone`,
32
+ records when the server learned the client went: `after_ms` from the request's start, whether
33
+ the answer had started (`answer_started`), and an HTTP/2 stream's `rst_code`. It holds nothing
34
+ of the request. Run yourself, `dist/http.js` speaks HTTP/1.1 as before. With `MCP_H2C=1` it
35
+ speaks h2c with prior knowledge only, and an HTTP/1.1 request gets no answer.
36
+ - README, "Privacy: what is sent where": the hosted endpoint's paragraph now also says what the API logs
37
+ of the calls it makes for you (#2797): each under your address, with its URL path (for a CVE, CWE or
38
+ advisory lookup, the ID), and not its query string, the header-carried terms or which purls. The
39
+ privacy policy's new Section 11, https://echelongraph.io/privacy#hosted-mcp-endpoint , says the same.
40
+ - `exposure_radar` relays and labels `mcp_servers.withheld_opted_out` (#2822): how many hostnames
41
+ whose owner opted out of scanning the API left out of every other `mcp_servers` number. Such a
42
+ hostname is not checked again while the opt-out stands, so its last verdict cannot be re-checked;
43
+ the API now withholds it from the MCP-server counts as its unparameterised AI-exposure answer
44
+ already did. An answer without the field, from an API that predates it, counted those hostnames,
45
+ and the note says so instead of reading the absence as 0; a value that is not a whole number is
46
+ left out and named.
47
+ - Claude Desktop in one click (#2815). From this release on, every GitHub release of
48
+ [github.com/echelongraph/echelongraph-mcp](https://github.com/echelongraph/echelongraph-mcp/releases)
49
+ carries `echelongraph-mcp.mcpb`, a Claude Desktop extension: open it and choose Install. It needs
50
+ no Node.js on your machine, since Claude Desktop runs it with the Node.js it ships, and no JSON to edit.
51
+ The README's Claude Desktop section links it as "Install in Claude Desktop", ahead of the custom
52
+ connector and the `npx` setup. The release workflow builds it from the release tag, the same tag
53
+ the npm package is published from: `listings/mcpb/build.sh` validates the manifest with the pinned
54
+ MCPB CLI and packs. Then, on a separate read-only runner that never ran the MCPB CLI,
55
+ `listings/mcpb/check-bundle.mjs` refuses a bundle that holds a top-level file `build.sh` does not
56
+ stage, a symlink, or a file where `build.sh` stages a directory; that lacks the icon or entry point
57
+ its manifest names, or `node_modules/`; whose `package.json`, `LICENSE`, `README.md` or `dist/` is not the tested npm
58
+ tarball's, byte for byte (a file changed, missing or added); whose `node_modules/` adds or changes
59
+ a file a production install from the lockfile does not have; that is at another version; or whose
60
+ own server, started with `PATH`, `HOME` and `ECHELONGRAPH_API_BASE` only (none of the caller's
61
+ `NODE_PATH`, `NODE_OPTIONS` or other variables), does not list the
62
+ manifest's 14 tools with their titles. npm publishes only after that check passes, so a manifest
63
+ or bundle defect stops the release before npm has the version. A separate job, which runs no
64
+ dependency code, signs the checked bundle with a SLSA build-provenance attestation (Sigstore,
65
+ keyless), attaches it and its Sigstore bundle to the release, each on its own, and checks the
66
+ file the release serves with `gh attestation verify`. To check a download yourself:
67
+ `gh attestation verify echelongraph-mcp.mcpb --repo echelongraph/echelongraph-mcp`.
68
+ Releases up to 2.6.5 carry no bundle.
69
+ - Repository only, not in the package: `listings/mcpb/check-bundle.mjs` and
70
+ `test/mcpb-release.test.mjs`; `test/mcp-stdio-client.mjs` takes a `cwd`.
71
+ - `sbom_review` reads the advisory interval in `check_sbom`'s `match_reason` by its closing
72
+ bracket, without the hedge of 2.6.5 and earlier. `[A, B)` is a fixed bound: B, the first version the advisory
73
+ records as not affected, is given as the fixed version (`[A, ∞)` names none). `[A, B] (B is the
74
+ last affected version, not a fix)` is an OSV `last_affected` bound: B is still affected and the
75
+ range records no fix, so neither B nor any version at or below it is given as a fixed version.
76
+ The bracket alone decides: a cut text clips `match_reason` to 200 characters, which can drop the
77
+ words after it for a long package name, and where it is clipped before the closing bracket
78
+ (`[A, B…`), no fixed version is taken from that `match_reason`.
79
+ Through 2.6.5 core-backend rendered both bounds as `[A, B)`, so the prompt gave B only as "the
80
+ end of that interval"; core-backend now renders a `last_affected` bound closed (#2830). This
81
+ wording assumes the API serves the new rendering: it is released after core-backend is deployed.
82
+ - `cve_intel` relays `fixed_branches` on each `affected_packages` row: every affected range of the
83
+ package on record, each with `introduced` and `fixed` (the range's fix) or, where no fix is on
84
+ record, `last_affected` (never in a field named fixed), and with `advisory_id` (the OSV record that
85
+ published the range) and `source` (the loader's label for that record), from core-backend's
86
+ `/api/v1/public/cves/:id/enrichment` since #2817. Ranges that name their record are in the order
87
+ the record lists them; ranges loaded before the record was stored (both null) are oldest first.
88
+ `fixed_branches` is null where a package's ranges were never loaded, and `[]` where no version
89
+ range is on record (none in the advisory, only commit ranges, or more than are stored), which is
90
+ not a finding that no fix exists. `fixed_version` is one range's fix per package,
91
+ kept for compatibility: for CVE-2021-44228 production served log4j-core 2.12.2 alone (2026-10-04),
92
+ the fix for 2.4 to 2.12.1, older than an affected 2.14.1, while the advisory
93
+ (GHSA-jfh8-c2jp-5v3q) gives three ranges, fixed in 2.3.1, 2.12.2 and 2.15.0. The description says
94
+ how to pick the range for an installed version, and the note counts the rows with more than one
95
+ range, and those with no range on record (`fixed_branches` null, or `[]` beside a `fixed_version`),
96
+ where `fixed_version` is all the answer holds. An API older than #2817 is said to be one (#2817).
97
+ - `triage_cve`'s patch line gives each affected range with its fix from `fixed_branches`, and
98
+ `fixed_version` only for a package without them; `sbom_review` gives a component the fix of the
99
+ `fixed_branches` range that holds its installed version (log4j-core 2.14.1: 2.15.0), after the
100
+ advisory interval in `check_sbom`'s `match_reason`, and `fixed_version` only for a package without
101
+ `fixed_branches` (#2817).
102
+ - `vendor_advisories_for_cve`, `search_vendor_advisories` and `get_vendor_advisory` relay each
103
+ vendor's `held_since`, which the coverage API now serves: the start of the window EchelonGraph has
104
+ read the vendor's advisories from, before which an advisory may not be held.
105
+ `vendors_not_fully_held` reads it, not the earliest held advisory. For a vendor with no history read
106
+ the earliest held advisory is not where its window begins: Cisco's poller first read the advisories
107
+ Cisco had changed in the last 90 days, Red Hat's and GitHub's the last 7, so an older advisory the
108
+ vendor revised later is held while the ones published beside it are not. Cisco's earliest held
109
+ advisory is dated 2024-10-23 and Red Hat's 2009-02-04, so CVE-2025-20188 left out Cisco and
110
+ CVE-2014-0160 left out Red Hat, and their empty answers read as the vendor having published nothing;
111
+ both are listed now. GitHub is now listed too for any CVE ID year that begins before the start of
112
+ its first poll's 7-day window (`held_since`), not only for years up to that of its earliest held
113
+ advisory (2017). Against an API without `held_since`, a vendor is taken as held from its earliest
114
+ advisory only when its history read is complete, and otherwise listed, its window written "not known
115
+ to be without a gap" (#2803).
116
+
9
117
  ## 2.6.5 — 2026-10-05
10
118
 
11
119
  2.6.4 was tagged and never published: its Release run failed `test/http-memory.test.mjs` on Node 24,
package/README.md CHANGED
@@ -34,21 +34,32 @@ API call a tool needs to answer.
34
34
  ## Quick start
35
35
 
36
36
  The local server runs through `npx` and needs Node.js 20 or later; nothing is installed
37
- globally. Add one of the blocks below to your client, restart it, then ask: *"Is
37
+ globally. Claude Desktop's one-click extension, below, needs neither. Add one of the blocks below to your client, restart it, then ask: *"Is
38
38
  CVE-2023-44487 actively exploited, and how many exposed services does EchelonGraph's radar
39
39
  have on record for it?"*
40
40
 
41
41
  ### Claude Desktop
42
42
 
43
- Two ways in. The first needs nothing installed; the second runs the server on your machine.
43
+ Three ways in. The first two need no Node.js: a one-click extension that runs the server on your
44
+ machine, and a custom connector to the hosted endpoint. The third runs the server through `npx`.
44
45
 
45
- **1. Custom connector (no install).** Claude Desktop uses your claude.ai account's connectors:
46
+ **1. [Install in Claude Desktop](https://github.com/echelongraph/echelongraph-mcp/releases/latest/download/echelongraph-mcp.mcpb)
47
+ (one click, no Node.js).** The link downloads `echelongraph-mcp.mcpb`, a Claude Desktop extension.
48
+ Open it, or in Claude Desktop choose Settings → Extensions → Advanced settings → Install Extension
49
+ and pick it, then choose **Install**. Claude Desktop runs the server with the Node.js it ships and
50
+ writes its own configuration: no PATH and no JSON to edit. To update, install the next release's
51
+ bundle the same way. Every new release on GitHub carries the bundle (releases up to 2.6.5 do not),
52
+ built from the release's tag by the workflow that publishes the npm package, and signed with a SLSA
53
+ build-provenance attestation. To check a download:
54
+ `gh attestation verify echelongraph-mcp.mcpb --repo echelongraph/echelongraph-mcp`.
55
+
56
+ **2. Custom connector (no install).** Claude Desktop uses your claude.ai account's connectors:
46
57
  Customize → Connectors → **+ Add** → **Add custom connector**. Name it `EchelonGraph`, paste
47
58
  `https://mcp.echelongraph.io/mcp`, choose **No sign in**, and add it. No Node.js, no PATH and no
48
59
  JSON to edit. A connector added on claude.ai shows up in Claude Desktop too, and the plan rules
49
60
  are claude.ai's (see "Remote (no install)" below).
50
61
 
51
- **2. Local server through `npx`.**
62
+ **3. Local server through `npx`.**
52
63
 
53
64
  1. **Check Node.js.** In Terminal (macOS) or PowerShell (Windows), run `node -v`. It must print
54
65
  `v20` or later. If it prints an older version or `command not found`, install the LTS release
@@ -228,7 +239,7 @@ The example questions are ones a client can answer with that tool alone.
228
239
  | `epss_history` | EPSS change history for one CVE | How one CVE's EPSS score has changed, as EchelonGraph recorded it: one point per recorded change (`series_kind` `change_only`), never a daily series, with the value now and `series_starts_at`, when recording began; before it a missing point means not recorded, not unchanged. | *How has CVE-2023-44487's EPSS score changed, as EchelonGraph recorded it?* |
229
240
  | `check_affected` | Am I affected? (product or package at a version) | Whether a product (its NVD CPE product token) or a registry package (`ecosystem` and `package`) at a given version is affected by known CVEs, from the matcher behind echelongraph.io/am-i-affected: `assessed` first (false: not evaluated, with `not_assessed_reason`, and a count of 0 then is not "not affected"), the matching CVEs with `kev_listed`, `ransomware`, `epss_score`, `effective_score` and `score_assessed`, and advisories it cannot decide counted as `undetermined_count`, never as safe. What you look up travels in request headers, never in the URL. | *Is openssl 3.0.0 affected by known CVEs? Is lodash 4.17.15 on npm?* |
230
241
  | `check_sbom` | Check an SBOM against the advisory corpus | A dependency list checked against EchelonGraph's advisory corpus (OSV.dev records), one verdict per component: `affected`, `not_affected`, `undetermined` or `not_assessed`, each with its `not_assessed_reason`. Pass up to 2,000 distinct purls, or a CycloneDX JSON or SPDX JSON document: the purls are read from it by the MCP server and only they are sent to the API, in POST bodies of at most 200 each, one after another; the document itself is not sent on. Run from npm, the server is on your machine; over the hosted endpoint (`mcp.echelongraph.io`) it is EchelonGraph's, and the document is the request body. When the API's per-component rate limit answers 429, the tool waits the `Retry-After` it names, within 50 seconds per call; past that it answers what it has, with the purls not sent counted in `coverage.not_sent`, the reason in `coverage.not_sent_reason`, and the list in `data.not_sent_purls`, never dropped silently. A deb, apk or rpm purl without a `distro` qualifier naming its release is not assessed (`distro_release_unknown`): EchelonGraph does not guess a release. Only `not_affected` is clean. No ranking, no score. | *Check this CycloneDX SBOM against the advisory corpus.* |
231
- | `cve_intel` | CVE weakness, exploits and packages | Weakness, public exploit code, affected packages and fixed versions for one CVE, from EchelonGraph's per-CVE enrichment: `cwes`, `exploits` (at most 10, verified first) with `exploits_total`, `exploits_capped`, `exploits_by_kind` and `exploits_by_status`, `affected_packages`, `fixed_versions` and `timeline`. `verified_status` is the label stored with each reference, not a guarantee that the exploit works. An empty `exploits` list is not evidence that no public exploit exists; a section the API could not read is named in `coverage.sections_failed`, never relayed as an empty list. | *Is there public exploit code for CVE-2021-44228, and which versions fix it?* |
242
+ | `cve_intel` | CVE weakness, exploits and packages | Weakness, public exploit code, affected packages and fixed versions for one CVE, from EchelonGraph's per-CVE enrichment: `cwes`, `exploits` (at most 10, verified first) with `exploits_total`, `exploits_capped`, `exploits_by_kind` and `exploits_by_status`, `affected_packages`, `fixed_versions` and `timeline`. Each `affected_packages` row carries `fixed_branches`, every affected range of the package with its fix (`introduced`, and `fixed` or, where no fix is on record, `last_affected`), each naming the OSV record that published it (`advisory_id`, `source`) and in that record's order: an installed version's fix is the `fixed` of the range that holds it, compared in the ecosystem's version order. `fixed_version` is one range's fix, kept for compatibility, not the fix for every range (CVE-2021-44228's log4j-core: 2.12.2, the fix for 2.4 to 2.12.1, while 2.14.1's range is fixed in 2.15.0); `fixed_branches` is null where a package's ranges were never loaded, and `[]` where no version range is on record, which is not a finding that no fix exists. `verified_status` is the label stored with each reference, not a guarantee that the exploit works. An empty `exploits` list is not evidence that no public exploit exists; a section the API could not read is named in `coverage.sections_failed`, never relayed as an empty list. | *Is there public exploit code for CVE-2021-44228, and which versions fix it?* |
232
243
  | `get_cwe` | CWE and its CVEs | One CWE (weakness class) and the CVEs classified under it: `name` and `description` from the MITRE CWE catalog EchelonGraph embeds, `total`, and one page of 50 `cves`, ordered as `order` states (CISA-KEV-listed first, then EchelonGraph score). A `total` of 0 says that no CVE in EchelonGraph's feed is classified under that CWE, not that none exists. | *Which CVEs are classified under CWE-79, CISA-KEV-listed first?* |
233
244
  | `vendor_advisories_for_cve` | Vendor advisories for one CVE | The vendor-published advisories (Microsoft MSRC, Red Hat, Cisco, Palo Alto Networks, GitHub GHSA and the other feeds EchelonGraph polls) that name one CVE, newest first, at most 20. | *Which vendor advisories name CVE-2024-3400?* |
234
245
  | `get_vendor_advisory` | Vendor advisory detail | One vendor advisory in full: description, severity, `cve_ids` and the subset with a CVE record here (`known_cve_ids`), `affected_products`, `remediation` and `references`. | *Show one of those advisories in full: affected products, remediation and references.* |
@@ -238,6 +249,18 @@ The three vendor-advisory tools relay `vendor_published_at` (the vendor's date),
238
249
  (when EchelonGraph first recorded the advisory) and `withdrawn` (the vendor rescinded it; the note
239
250
  names each one, and the search leaves them out).
240
251
 
252
+ Each also relays every vendor's window in `coverage` (`vendor_windows`, or `vendor_window` for the
253
+ detail): the advisories held, the earliest and latest `vendor_published_at` among them,
254
+ `held_since` and `history_backfill`. `held_since` is the start of the window EchelonGraph has read the
255
+ vendor's advisories from: one published before it may not be held. For a vendor with no history read (`history_backfill`
256
+ `not_supported`) it can be years after the earliest advisory held: Cisco's poller first read the
257
+ advisories Cisco had changed in the last 90 days, Red Hat's and GitHub's the last 7, so an older
258
+ advisory the vendor revised later is held while the ones published beside it are not.
259
+ `vendor_advisories_for_cve` lists in `vendors_not_fully_held` each vendor with no advisory in the
260
+ answer of which EchelonGraph holds none, whose `held_since` is after 1 January of the CVE ID's year
261
+ or not known, or whose history is still being read: no advisory from such a vendor is not a finding
262
+ that it published none.
263
+
241
264
  Every figure is as fresh as the schedule that refreshes it. The CVE feed is polled from its
242
265
  sources on a schedule; each radar refreshes on its own.
243
266
 
@@ -443,8 +466,9 @@ feed, matched by hostname pattern; no Shodan data is used. Each is checked by Ec
443
466
  identified MCP probe: a `server/discover` request, and `initialize` only if that is refused. It
444
467
  never sends `tools/call`. The answer is counts and timestamps only, with no hostname, version or
445
468
  server name, and the tool repeats no string it carries but its timestamps. Each hostname is
446
- counted once, by its latest verdict on record, and EchelonGraph's own control servers are left
447
- out. The unit is a hostname, not an ip:port service.
469
+ counted once, by its latest verdict on record. EchelonGraph's own control servers are left
470
+ out, and so is every hostname whose owner opted out of scanning, since it is not checked again
471
+ while the opt-out stands and its last verdict cannot be re-checked. The unit is a hostname, not an ip:port service.
448
472
 
449
473
  | Field | What it counts |
450
474
  |---|---|
@@ -454,6 +478,7 @@ out. The unit is a hostname, not an ip:port service.
454
478
  | `mcp_servers.pending_readjudication` | Verdicts decided by a rule EchelonGraph has since replaced and not yet re-checked under the current rules. They are in neither `mcp_servers.protected` nor `mcp_servers.not_assessed`. |
455
479
  | `mcp_servers.not_assessed` | The rest, whose protection the radar could not assess. Not assessed does not mean unprotected. `mcp_servers.not_assessed_by_reason` puts each of them in exactly one bucket (below). |
456
480
  | `mcp_servers.own_controls_excluded` | EchelonGraph's own control servers, left out of every other `mcp_servers` number. |
481
+ | `mcp_servers.withheld_opted_out` | Hostnames whose owner opted out of scanning, left out of every other `mcp_servers` number. Such a hostname is not checked again while the opt-out stands, so its last verdict cannot be re-checked; the API's unparameterised answer withholds the same hostnames under the same name. An answer that does not carry this field (an API that predates it) counted them, and the note says so. |
457
482
  | `mcp_servers.window.from`, `mcp_servers.window.to` | Timestamps, not counts: when the oldest and the newest of the verdicts counted were last checked. The counts are each hostname's latest verdict, not one sweep at one time. With no verdict counted there is no window. |
458
483
  | `mcp_servers.last_run_at` | A timestamp, not a count: when the AI-exposure radar, which checks other AI services as well as MCP servers, last completed a check (a cycle whose reads succeeded and whose scan opt-out register answered). It is not the time of every verdict counted. |
459
484
  | `mcp_servers.enabled` | Whether the API reports the radar running: true when a check completed within 45 minutes of its answer. |
@@ -544,10 +569,10 @@ never "not affected", and each figure cites its `measured_at`. A prompt makes no
544
569
 
545
570
  | Prompt | Arguments | What it asks the model to do |
546
571
  |---|---|---|
547
- | `triage_cve` | `cve_id` | `get_cve` (with CISA's due date, `kev_due_date`, when the CVE is KEV-listed), `cve_intel`, `vendor_advisories_for_cve`, `epss_history` and `cve_exposure`, then a decision template: exploited, likelihood, reachable, patch, deadline, decision. The patch line gives `cve_intel`'s `fixed_version` as what it records for the package, one per package (the advisory's last range's), never as the fix for every affected branch, and points to `get_cve`'s references tagged Vendor Advisory or Patch for a branch's fix. |
572
+ | `triage_cve` | `cve_id` | `get_cve` (with CISA's due date, `kev_due_date`, when the CVE is KEV-listed), `cve_intel`, `vendor_advisories_for_cve`, `epss_history` and `cve_exposure`, then a decision template: exploited, likelihood, reachable, patch, deadline, decision. The patch line gives each affected range with its fix from `cve_intel`'s `fixed_branches`; where a package has none, it gives `fixed_version` as what `cve_intel` records for the package, one per package (the advisory's last range's as a rule), never as the fix for every affected branch, and points to `get_cve`'s references tagged Vendor Advisory or Patch for a branch's fix. |
548
573
  | `kev_weekly_brief` | `days` (1 to 365, default 7) | Work out the start date from `days`, page through `kev_recent` from it 50 rows a page, and write a brief grouped by vendor with ransomware flags and due dates. |
549
574
  | `am_i_affected` | `product`, or `ecosystem` and `package`; `version` | `check_affected`, read `assessed` before `count`, with the not-assessed wording. |
550
- | `sbom_review` | `sbom` (CycloneDX or SPDX JSON text, up to 5,000,000 characters) | Pass the document to `check_sbom` (and, once more, the purls a rate limit or the time budget left unsent: `data.not_sent_purls`, or, where the text cuts that list, the document's purls from the position and in the order its note gives), read each component's ecosystem, package and version from its purl, as `check_sbom` maps them (every purl type it maps, and `deb`, `apk`, `alpine` and `rpm` by a `distro` qualifier naming a Debian, Ubuntu or Alpine release; any other is unknown, and given no fixed version), look up each affected CVE with `get_cve` and `cve_intel`, and write a fix list ordered by CISA-KEV listing, then EPSS, then score, each key stated. A fixed version is given where it is above the component's installed version: the advisory interval in `check_sbom`'s `match_reason` first, else `cve_intel`'s `fixed_version`, which is one per package (the advisory's last range's) and need not be the fix on the component's branch; otherwise the line says the results give no fixed version for that branch and points to the CVE's advisory. |
575
+ | `sbom_review` | `sbom` (CycloneDX or SPDX JSON text, up to 5,000,000 characters) | Pass the document to `check_sbom` (and, once more, the purls a rate limit or the time budget left unsent: `data.not_sent_purls`, or, where the text cuts that list, the document's purls from the position and in the order its note gives), read each component's ecosystem, package and version from its purl, as `check_sbom` maps them (every purl type it maps, and `deb`, `apk`, `alpine` and `rpm` by a `distro` qualifier naming a Debian, Ubuntu or Alpine release; any other is unknown, and given no fixed version), look up each affected CVE with `get_cve` and `cve_intel`, and write a fix list ordered by CISA-KEV listing, then EPSS, then score, each key stated. A fixed version is given where it is above the component's installed version: the advisory interval in `check_sbom`'s `match_reason` first (B of `[A, B)`, a fixed bound; the B of `[A, B]`, read by its closing bracket alone, is the last affected version, still affected, and neither it nor any version at or below it is given; an interval clipped before its closing bracket gives none), else the `fixed` of the `cve_intel` `fixed_branches` range that holds the installed version, else, for a package without `fixed_branches`, `cve_intel`'s `fixed_version`, which is one per package (the advisory's last range's) and need not be the fix on the component's branch; otherwise the line says the results give no fixed version for that branch and points to the CVE's advisory. |
551
576
 
552
577
  ## Resources
553
578
 
@@ -631,7 +656,7 @@ pretty-printed and whole. The same answer is always cut the same way. The `cve:/
631
656
  resource cuts its `data` as `get_cve`'s first text block is cut, and says so in a note starting
632
657
  `DATA CUT`. The prompts ask only for what the text keeps: `kev_weekly_brief` reads `kev_recent`
633
658
  50 rows a page, which the text holds whole; `triage_cve` reads CISA's due date from `get_cve`;
634
- and `sbom_review` reads fixed versions from `cve_intel`.
659
+ and `sbom_review` reads fixed versions from `cve_intel`, the fix of each affected range in `fixed_branches`.
635
660
 
636
661
  ## Structured results
637
662
 
@@ -768,7 +793,11 @@ only those to the API, and neither logs nor keeps the document. To keep the docu
768
793
  machine, run the npm package, or pass `check_sbom` the purls. When a client passes the
769
794
  per-client limit it logs that client's address key. It passes your client's public address to
770
795
  the API, in a header the API trusts only with a token it checks, so the API's rate limit counts
771
- you and not the hosted service.
796
+ you and not the hosted service. The API's access line then records each of those calls under
797
+ your address (an IPv6 address by its /64) with its URL path: for a CVE, CWE or advisory lookup,
798
+ the ID you asked about. It does not record the query string or the header-carried terms above
799
+ (for `search_cves`, whether a term was given and its length, not the term), and of `check_sbom`'s
800
+ purls it records how many, not which.
772
801
 
773
802
  EchelonGraph's privacy policy: <https://echelongraph.io/privacy>.
774
803
 
@@ -808,6 +837,16 @@ instance serves at most 64 requests at once; the next waits up to 10 seconds for
808
837
  and is then answered the same way. A `subscriptions/listen` stream, which stays open until its
809
838
  client leaves, is not one of the 64.
810
839
 
840
+ A call whose client goes away (its own timeout, a closed tab) is stopped only once the server
841
+ learns of it. Its API calls are then cancelled, `check_sbom` sends no further batch, and its
842
+ large-body slot is freed. Over HTTP/1.1, Cloud Run does not pass a client's disconnect on to the
843
+ server at all (<https://docs.cloud.google.com/run/docs/troubleshooting>). The hosted endpoint
844
+ speaks HTTP/2 to Cloud Run, where the same page says a disconnect is passed on. Measured on
845
+ 2026-10-05, Cloud Run reset the stream of a client that stopped reading its answer 15 s in, and the
846
+ call was logged as a client that closed. Whether a client that aborts mid-call is passed on at once
847
+ has not been observed yet. Until it is, an abandoned `check_sbom` call may run for up to its
848
+ 50-second budget and hold its slot that long.
849
+
811
850
  ## Configuration
812
851
 
813
852
  The npm package (stdio):
@@ -835,6 +874,7 @@ It reads the three variables above and:
835
874
  | `MCP_LARGE_BODY_SLOTS` | `2` | How many bodies over `MCP_MAX_BODY_BYTES` it reads and serves at once; past that, 503 with `Retry-After`. |
836
875
  | `MCP_MAX_IN_FLIGHT` | `64` | How many requests it serves at once, not counting `subscriptions/listen` streams; the next, its body read, waits. |
837
876
  | `MCP_ADMISSION_WAIT_MS` | `10000` | How long a request waits to be served before it is answered 503 with `Retry-After`. |
877
+ | `MCP_H2C` | unset | `1`: serve HTTP/2 cleartext (h2c, prior knowledge) instead of HTTP/1.1, as Cloud Run's `--use-http2` requires; an HTTP/1.1 request then gets no answer. Over h2c, a stream the client resets stops its call. |
838
878
  | `ECHELONGRAPH_FORWARD_TOKEN`, `ECHELONGRAPH_FORWARD_HOST`, `MCP_REQUIRE_FORWARD_TOKEN` | unset | EchelonGraph's hosted deployment only: the token with which it names each client to the API. Leave them unset when you run it yourself; the API then counts every request as coming from your server. |
839
879
 
840
880
  ## Develop
package/dist/http.js CHANGED
@@ -13,6 +13,17 @@
13
13
  // GET /health liveness. Not /healthz: Cloud Run's front end reserves paths ending in "z"
14
14
  // and answers them itself, so a /healthz would never reach this process.
15
15
  //
16
+ // HTTP/1.1 by default; HTTP/2 cleartext (h2c, prior knowledge) ONLY when MCP_H2C=1 (#2775). Cloud
17
+ // Run tells a container that its client went away only over HTTP/2 or a WebSocket ("When you use
18
+ // HTTP/1.1 on Cloud Run, client disconnect events are not propagated to the Cloud Run container",
19
+ // https://docs.cloud.google.com/run/docs/troubleshooting): over HTTP/1.1 its front end reads the
20
+ // whole answer itself, so an abandoned call ran to its end, holding its large-body slot. With
21
+ // `--use-http2` Cloud Run sends the container h2c and nothing else, and a client that goes resets
22
+ // its stream, which this server acts on at once (serveMcp). But on a canary of 2026-10-05 that reset
23
+ // came only when the answer was written, 15 and 34 s in, not when the client went:
24
+ // mcp_client_gone (after_ms, answer_started) is how production shows which. The flag and
25
+ // MCP_H2C=1 go together (deploy-all.sh mcp-remote): either one alone answers no request at all.
26
+ //
16
27
  // No auth: the endpoint serves only public data (MCP makes authorization optional), and the
17
28
  // server's instructions say what it is. The edge rules — Origin policy, per-client limit, body
18
29
  // cap, what is logged — are in httpPolicy.ts, each with its reasoning.
@@ -22,6 +33,7 @@
22
33
  // waf/mcpforward.go, #2212). MCP_REQUIRE_FORWARD_TOKEN=1 (set by deploy-all.sh) makes a missing
23
34
  // token fatal at start-up, because without it every remote user would share one API budget.
24
35
  import http from "node:http";
36
+ import http2 from "node:http2";
25
37
  import v8 from "node:v8";
26
38
  import { createMcpHandler } from "@modelcontextprotocol/server";
27
39
  import { forwardToken, markHttpEntrypoint, requestSettled, SYNTHETIC_UA_FAMILY, withClient } from "./runtime.js";
@@ -49,6 +61,9 @@ const ADMISSION_WAIT_MS = positiveInt(process.env.MCP_ADMISSION_WAIT_MS, DEFAULT
49
61
  // A large body's wait for a slot is not queued: the client is told to come back. Nor is a
50
62
  // request that waited ADMISSION_WAIT_MS for admission.
51
63
  const BUSY_RETRY_AFTER_SEC = 5;
64
+ // #2775: serve h2c instead of HTTP/1.1 (see the top of this file). Exactly "1": anything else is
65
+ // HTTP/1.1, the default for a local run and for every test that does not ask.
66
+ const H2C = process.env.MCP_H2C === "1";
52
67
  const MCP_PATH = "/mcp";
53
68
  const HEALTH_PATH = "/health";
54
69
  // One JSON object per line on stdout: Cloud Logging reads `severity` and `message`.
@@ -87,8 +102,17 @@ const PREFLIGHT_HEADERS = {
87
102
  "Access-Control-Max-Age": "600",
88
103
  };
89
104
  const HOP_BY_HOP = new Set(["host", "connection", "content-length", "transfer-encoding", "keep-alive", "upgrade", "expect"]);
105
+ // HTTP/2 has no connection-specific headers (RFC 9113 8.2.2): Node drops them from an h2 answer
106
+ // with a warning, so they are never set on one. A refusal's "Connection: close" (the rest of the
107
+ // body is discarded unread) is the stream's own end there.
108
+ const CONNECTION_SPECIFIC = new Set(["connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade"]);
109
+ function forProtocol(res, headers) {
110
+ if (!(res instanceof http2.Http2ServerResponse))
111
+ return headers;
112
+ return Object.fromEntries(Object.entries(headers).filter(([k]) => !CONNECTION_SPECIFIC.has(k.toLowerCase())));
113
+ }
90
114
  function send(res, status, body, headers = {}) {
91
- res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store", ...headers });
115
+ res.writeHead(status, forProtocol(res, { "Content-Type": "application/json", "Cache-Control": "no-store", ...headers }));
92
116
  res.end(body);
93
117
  }
94
118
  function readBody(req) {
@@ -133,8 +157,14 @@ function readBody(req) {
133
157
  chunks.push(chunk);
134
158
  });
135
159
  req.on("end", () => {
160
+ if (done)
161
+ return;
162
+ done = true;
163
+ resolve({ body: Buffer.concat(chunks), slot });
164
+ });
165
+ req.on("close", () => {
136
166
  if (!done)
137
- resolve({ body: Buffer.concat(chunks), slot });
167
+ stop("gone");
138
168
  });
139
169
  req.on("error", (e) => {
140
170
  if (done)
@@ -179,11 +209,35 @@ async function serveMcp(req, res, pathname) {
179
209
  // #2775: the client has gone when the response closes before it has finished. That aborts the
180
210
  // request: its wait for admission, the SDK's handling of it (which cancels the tool's call), and
181
211
  // its API calls (runtime.ts requestSignal).
212
+ // Over h2c the response of a reset stream closes marked finished (node:http2's compatibility
213
+ // API ends it), so there the stream itself is watched, from the moment the reset arrives:
214
+ // 'aborted' (a reset before the answer was written, any error code) and a 'close' before this
215
+ // server ended its answer (a reset with NO_ERROR emits no 'aborted').
216
+ // mcp_client_gone says WHEN this server learned it: after_ms from the request's start, and
217
+ // whether its answer had started (answer_started). On 2026-10-05 a canary behind Cloud Run's
218
+ // --use-http2 logged its aborted calls 499 only when their answers were written, 15 and 34 s
219
+ // in: this line tells a reset that arrived then from one that arrived early and was not acted on
220
+ // (the access line's duration_ms is when the call actually stopped).
182
221
  const gone = new AbortController();
222
+ const t0 = Date.now();
223
+ const h2stream = res.stream;
224
+ const clientWent = () => {
225
+ if (gone.signal.aborted)
226
+ return;
227
+ gone.abort();
228
+ log("INFO", "mcp_client_gone", { after_ms: Date.now() - t0, answer_started: res.headersSent, rst_code: h2stream?.rstCode ?? null });
229
+ };
183
230
  res.on("close", () => {
184
- if (!res.writableFinished)
185
- gone.abort();
231
+ if (!res.writableFinished || h2stream?.aborted)
232
+ clientWent();
186
233
  });
234
+ if (h2stream) {
235
+ h2stream.once("aborted", clientWent);
236
+ h2stream.once("close", () => {
237
+ if (!res.writableEnded)
238
+ clientWent();
239
+ });
240
+ }
187
241
  // The body is read BEFORE the request waits for admission (#2773), so a client that uploads
188
242
  // slowly holds its socket, and a large body its large-body slot, as before admission existed,
189
243
  // never one of the MAX_IN_FLIGHT places every other request needs. What a waiting request holds
@@ -203,6 +257,8 @@ async function serveMcp(req, res, pathname) {
203
257
  return { status: 499, refused: "client_closed", clientClosed: true };
204
258
  throw e;
205
259
  }
260
+ if (read === "gone")
261
+ return { status: 499, refused: "client_closed", clientClosed: true };
206
262
  if (read === "too_large")
207
263
  return tooLarge(res, cors);
208
264
  if (read === "busy") {
@@ -259,7 +315,8 @@ async function serveMcp(req, res, pathname) {
259
315
  async function relay(req, res, pathname, method, cors, client, body, gone, work) {
260
316
  const headers = new Headers();
261
317
  for (const [k, v] of Object.entries(req.headers)) {
262
- if (v === undefined || HOP_BY_HOP.has(k))
318
+ // ":method", ":path", ":authority", ":scheme": an h2 request's pseudo-headers, which a Request cannot carry.
319
+ if (v === undefined || HOP_BY_HOP.has(k) || k.startsWith(":"))
263
320
  continue;
264
321
  for (const one of Array.isArray(v) ? v : [v])
265
322
  headers.append(k, one);
@@ -279,7 +336,7 @@ async function relay(req, res, pathname, method, cors, client, body, gone, work)
279
336
  response.headers.forEach((v, k) => {
280
337
  out[k] = v;
281
338
  });
282
- res.writeHead(response.status, out);
339
+ res.writeHead(response.status, forProtocol(res, out));
283
340
  if (response.body) {
284
341
  for await (const chunk of response.body)
285
342
  res.write(chunk);
@@ -290,7 +347,7 @@ async function relay(req, res, pathname, method, cors, client, body, gone, work)
290
347
  return { status: 499, body, clientPublic, clientClosed: true };
291
348
  return { status: response.status, body, clientPublic };
292
349
  }
293
- const server = http.createServer((req, res) => {
350
+ function onRequest(req, res) {
294
351
  const start = process.hrtime.bigint();
295
352
  // The path only: a query string is never read, kept or logged.
296
353
  let pathname = "/";
@@ -341,7 +398,15 @@ const server = http.createServer((req, res) => {
341
398
  res.end();
342
399
  finish({ status: 500 });
343
400
  });
344
- });
401
+ }
402
+ // #2775: with MCP_H2C=1, node:http2's compatibility API, whose request and response carry the
403
+ // IncomingMessage / ServerResponse members used above. Where they differ the code says so:
404
+ // pseudo-headers (relay), connection-specific headers (forProtocol), a stream reset mid-upload
405
+ // (readBody "gone"). A stream reset after that closes the response unfinished, which aborts
406
+ // `gone` in serveMcp exactly as a closed HTTP/1.1 socket does.
407
+ const server = H2C
408
+ ? http2.createServer((req, res) => onRequest(req, res))
409
+ : http.createServer(onRequest);
345
410
  // #2773: the heap V8 may grow to, and the memory the container allows (0 when none is known). A
346
411
  // heap allowed past the container's memory is collected only when the kernel kills the instance:
347
412
  // Dockerfile.http caps it, and this says when a process was started without that cap. It can say
@@ -362,6 +427,7 @@ server.listen(PORT, () => {
362
427
  const addr = server.address();
363
428
  log("INFO", "mcp_remote_listening", {
364
429
  port: typeof addr === "object" && addr ? addr.port : PORT,
430
+ protocol: H2C ? "h2c" : "http/1.1",
365
431
  name: NAME,
366
432
  version: VERSION,
367
433
  api_base: SHOWN_BASE,
package/dist/index.js CHANGED
@@ -1567,8 +1567,9 @@ function readShadowAI(sa) {
1567
1567
  //
1568
1568
  // core-backend/internal/aiexposure (handler.go MCPPublicCounts, store.go): the AI-exposure
1569
1569
  // radar's MCP rows, one per hostname, each counted once by its latest verdict on record, with
1570
- // EchelonGraph's own control servers left out and counted in own_controls_excluded. Counts,
1571
- // timestamps and one constant (service "mcp"); no hostname, version or server name.
1570
+ // EchelonGraph's own control servers left out and counted in own_controls_excluded, and every
1571
+ // hostname whose owner opted out of scanning left out and counted in withheld_opted_out (#2822).
1572
+ // Counts, timestamps and one constant (service "mcp"); no hostname, version or server name.
1572
1573
  // total protected + pending_readjudication + not_assessed
1573
1574
  // protected the /mcp endpoint answered 401/403 and its RFC 9728 protected-resource
1574
1575
  // metadata validated (prm.go); prm_via divides exactly these rows by
@@ -1581,6 +1582,12 @@ function readShadowAI(sa) {
1581
1582
  // (a challenge recorded before #2312), no_http_answer, not_identified_as_mcp
1582
1583
  // era, transport divide every counted row; a row written before #2314 recorded an era
1583
1584
  // is not_measured (era.go says what each value rests on)
1585
+ // withheld_opted_out hostnames a verified scan opt-out covered when the radar last reached
1586
+ // them (store.go opted_out_at, #2363): not re-checked while it stands, so
1587
+ // their last verdict is frozen, and in no other count (#2822). The unparameterised
1588
+ // answer withholds the same rows under the same key. An API older than
1589
+ // #2822 carries no such field and counted those rows; the note says so
1590
+ // rather than reading the absence as 0.
1584
1591
  // window from/to: the oldest and newest last-check time over the counted rows
1585
1592
  // enabled, last_run_at exactly as the unparameterised answer computes them: the AI-exposure
1586
1593
  // radar's fleet-wide liveness and last completed check, over every AI
@@ -1636,7 +1643,7 @@ const MCP_PARTITIONS = {
1636
1643
  };
1637
1644
  const MCP_PARTITION_NAMES = Object.keys(MCP_PARTITIONS);
1638
1645
  const MCP_INSTANTS = ["last_run_at", "counted_at"];
1639
- const MCP_KNOWN = new Set(["service", ...MCP_HEADLINE, ...MCP_PARTITION_NAMES, "own_controls_excluded", "window", "enabled", ...MCP_INSTANTS]);
1646
+ const MCP_KNOWN = new Set(["service", ...MCP_HEADLINE, ...MCP_PARTITION_NAMES, "own_controls_excluded", "withheld_opted_out", "window", "enabled", ...MCP_INSTANTS]);
1640
1647
  // What each challenge reason code says (prm.go prmReasonCodes), for the note.
1641
1648
  const MCP_CHALLENGE_MEANS = {
1642
1649
  resource_mismatch: "the document's resource is absent or not identical to the server's identifier",
@@ -1704,6 +1711,12 @@ function readMCPServers(src) {
1704
1711
  data.own_controls_excluded = src.own_controls_excluded;
1705
1712
  else
1706
1713
  unusable.push("own_controls_excluded");
1714
+ // An API older than #2822 carries no withheld_opted_out, and counted those rows (the note says
1715
+ // so); one that carries it in another shape has it left out and named.
1716
+ if (isWhole(src.withheld_opted_out))
1717
+ data.withheld_opted_out = src.withheld_opted_out;
1718
+ else if (src.withheld_opted_out !== undefined)
1719
+ unusable.push("withheld_opted_out");
1707
1720
  // window: from and to, both real instants, from not after to. With no row counted the API has
1708
1721
  // no window to give (null, or from and to null), and none is relayed.
1709
1722
  const w = src.window;
@@ -1752,6 +1765,12 @@ function readMCPServers(src) {
1752
1765
  if (data.own_controls_excluded !== undefined) {
1753
1766
  sentences.push(`mcp_servers.own_controls_excluded (${n("own_controls_excluded")}) counts EchelonGraph's own control servers, which are left out of every other mcp_servers number.`);
1754
1767
  }
1768
+ if (data.withheld_opted_out !== undefined) {
1769
+ sentences.push(`mcp_servers.withheld_opted_out (${n("withheld_opted_out")}) counts hostnames whose owner opted out of EchelonGraph's scanning: they are not checked again while the opt-out stands, so their last verdict cannot be re-checked, and they are left out of every other mcp_servers number.`);
1770
+ }
1771
+ else if (src.withheld_opted_out === undefined) {
1772
+ sentences.push("The answer does not say how many hostnames whose owner opted out of scanning it withheld (mcp_servers.withheld_opted_out), so its counts may include such a hostname's last verdict, which is not re-checked while the opt-out stands.");
1773
+ }
1755
1774
  if (data.window) {
1756
1775
  sentences.push(`mcp_servers.window says when the verdicts counted were last checked: the oldest at ${from} (mcp_servers.window.from) and the newest at ${to} (mcp_servers.window.to), so the counts are each hostname's latest verdict, not one sweep at one time.`);
1757
1776
  }
@@ -1781,7 +1800,7 @@ const EXPOSURE_RADAR_DESCRIPTION = `Aggregate totals from EchelonGraph's interne
1781
1800
  `exposed_databases: exposed_databases.distinct_hosts counts distinct ip:port services the check confirmed answering without authentication, and exposed_databases.engines the distinct engine types among them; exposed_databases.top_engines and exposed_databases.top_countries rank up to 15 engines and 10 countries by those services. exposed_databases.pii_likely and exposed_databases.pci_likely count services whose schema names (never record values) pass a high-confidence, precision-first gate for personal or payment-card data, so a service outside them is not shown to hold no such data. exposed_databases.window.from and exposed_databases.window.to are timestamps, not counts: when EchelonGraph's check last confirmed the oldest and the newest of the services counted, a span of checks made at different times; with a counted service that has no such time on record there is no window. ` +
1782
1801
  `leaked_credentials: leaked_credentials.total counts (repository, secret) pairs, not distinct secrets, so one secret in three repositories counts three times; leaked_credentials.distinct_secrets counts each secret once and leaked_credentials.distinct_repos counts repositories; leaked_credentials.top_providers and leaked_credentials.top_types rank up to 15 providers and secret types by those pairs. None is validated: each is a credential-shaped string that passed EchelonGraph's filters, at most structurally checked and never tested against its provider. leaked_credentials.window.from and leaked_credentials.window.to are timestamps, not counts: when EchelonGraph's detector last found the oldest and the newest of the pairs counted. Each of those three radars also carries generated_at, when the API computed its totals, and, when the API can tell, last_run_at. kev_exposure.last_run_at, exposed_databases.last_run_at and leaked_credentials.last_run_at are timestamps, not counts: each is when that radar last completed a check, a cycle whose reads succeeded, among them a Shodan search (for exposed_databases, or its LeakIX fallback) that answered at least one query, or for leaked_credentials a read of the public GitHub event stream. A cycle that read nothing does not move it, and it is not the time of every record a radar's numbers count, which cover everything still on record, not only what the last check found. A radar whose answer carries no last_run_at has none in the result, and the note says nothing about it. ` +
1783
1802
  `shadow_ai: the result is regrouped by what each number counts. shadow_ai.confirmed_exposed counts services EchelonGraph's probes found answering without an authentication gate (liveness active, or rechecking during a re-check): confirmed_exposed.total is the sum of confirmed_exposed.by_category, and confirmed_exposed.last_24h counts those first recorded in the last 24 h. Of the shadow_ai numbers, only confirmed_exposed counts exposed services. confirmed_exposed.window.from and confirmed_exposed.window.to are timestamps, not counts: when EchelonGraph's verifier ran the probe that last decided the oldest and the newest of those services, a span of probes made at different times. shadow_ai.observed counts every Certificate Transparency or Shodan observation on record, whatever its verification state: its numbers are observed, not exposed. They are observed.total; observed.by_category (the same observations by category); observed.last_24h (those first recorded in the last 24 h); observed.trend_30d (observations per UTC day over the last 30 days); and observed.top_products, observed.top_countries and observed.top_issuers (up to ten products, countries and issuers ranked by observations, where an issuer is the certificate's CA for a Certificate Transparency observation and the hosting operator Shodan reports for a Shodan one). shadow_ai.authentication counts observations by probe outcome: authentication.observed where a probe observed an authentication gate (a 401/403, a login page or an auth marker), and authentication.not_determined where the service answered but no probe could tell. Neither authentication count is part of confirmed_exposed, and observed.total minus confirmed_exposed.total is not a count of secured services. The shadow-AI poller block carries only running and last_run_at: last_run_at is when the radar's leader last completed a Certificate Transparency (crt.sh) cycle, and its running is true only when that was within ${SHADOW_AI_SILENT_AFTER} of the answer. ` +
1784
- `mcp_servers: counts and timestamps only, no hostname. mcp_servers.total counts hostnames the AI-exposure radar has checked for an MCP server, each once by its latest verdict on record, and not every one is an MCP server; EchelonGraph's own control servers are left out, and mcp_servers.own_controls_excluded counts them. mcp_servers.total is mcp_servers.protected plus mcp_servers.pending_readjudication plus mcp_servers.not_assessed. mcp_servers.protected counts hostnames whose /mcp endpoint asked for credentials and whose OAuth protected-resource metadata validated under RFC 9728; mcp_servers.prm_via divides them by where that document was found: mcp_servers.prm_via.header, mcp_servers.prm_via.wellknown_path and mcp_servers.prm_via.wellknown_root. mcp_servers.pending_readjudication counts verdicts of a rule since replaced, not yet re-checked. mcp_servers.not_assessed counts the rest, whose protection the radar could not assess (not assessed does not mean unprotected), and mcp_servers.not_assessed_by_reason puts each in one bucket. mcp_servers.not_assessed_by_reason.identified_no_challenge holds servers that identified themselves as MCP servers and did not ask for credentials at the handshake. That is normal in MCP: authorization is optional in the spec, and a server can enforce it at tools/call instead, which EchelonGraph never sends, so this bucket is not a finding of exposure. ${MCP_CHALLENGE_REASON_FIELDS} hold endpoints that asked for credentials but whose RFC 9728 metadata did not validate, by why, so none of them is shown to lack protection; mcp_servers.not_assessed_by_reason.challenge_unadjudicated holds endpoints that asked for credentials before that check existed, not yet re-checked. mcp_servers.not_assessed_by_reason.no_http_answer holds hostnames that gave no HTTP answer, and mcp_servers.not_assessed_by_reason.not_identified_as_mcp hostnames whose HTTP answer identified no MCP server. mcp_servers.era divides every counted hostname by protocol era: mcp_servers.era.legacy; mcp_servers.era.dual, a lower bound; mcp_servers.era.modern, not proven modern-only; mcp_servers.era.unknown; and mcp_servers.era.not_measured, recorded before the era probe and not re-checked since. mcp_servers.transport divides them by transport: mcp_servers.transport.streamable_http, mcp_servers.transport.legacy_sse, mcp_servers.transport.unknown and mcp_servers.transport.not_measured. mcp_servers.window.from and mcp_servers.window.to are when the oldest and the newest of the verdicts counted were last checked. mcp_servers.last_run_at is a timestamp, not a count: when the AI-exposure radar, which checks other AI services too, last completed a check; mcp_servers.enabled is whether one completed within ${MCP_ENABLED_WITHIN} of the answer, and mcp_servers.counted_at is when the API read the counts. A partition whose buckets do not add up to the count it divides is left out and named; an answer that does not say it is the MCP-server counts, or whose mcp_servers.total, mcp_servers.protected, mcp_servers.pending_readjudication and mcp_servers.not_assessed are missing or contradict each other, is a failure. ${SHODAN_OWNERSHIP}`;
1803
+ `mcp_servers: counts and timestamps only, no hostname. mcp_servers.total counts hostnames the AI-exposure radar has checked for an MCP server, each once by its latest verdict on record, and not every one is an MCP server; EchelonGraph's own control servers are left out, and mcp_servers.own_controls_excluded counts them; hostnames whose owner opted out of scanning are not checked again while the opt-out stands, so their last verdict is left out too, and mcp_servers.withheld_opted_out counts them. mcp_servers.total is mcp_servers.protected plus mcp_servers.pending_readjudication plus mcp_servers.not_assessed. mcp_servers.protected counts hostnames whose /mcp endpoint asked for credentials and whose OAuth protected-resource metadata validated under RFC 9728; mcp_servers.prm_via divides them by where that document was found: mcp_servers.prm_via.header, mcp_servers.prm_via.wellknown_path and mcp_servers.prm_via.wellknown_root. mcp_servers.pending_readjudication counts verdicts of a rule since replaced, not yet re-checked. mcp_servers.not_assessed counts the rest, whose protection the radar could not assess (not assessed does not mean unprotected), and mcp_servers.not_assessed_by_reason puts each in one bucket. mcp_servers.not_assessed_by_reason.identified_no_challenge holds servers that identified themselves as MCP servers and did not ask for credentials at the handshake. That is normal in MCP: authorization is optional in the spec, and a server can enforce it at tools/call instead, which EchelonGraph never sends, so this bucket is not a finding of exposure. ${MCP_CHALLENGE_REASON_FIELDS} hold endpoints that asked for credentials but whose RFC 9728 metadata did not validate, by why, so none of them is shown to lack protection; mcp_servers.not_assessed_by_reason.challenge_unadjudicated holds endpoints that asked for credentials before that check existed, not yet re-checked. mcp_servers.not_assessed_by_reason.no_http_answer holds hostnames that gave no HTTP answer, and mcp_servers.not_assessed_by_reason.not_identified_as_mcp hostnames whose HTTP answer identified no MCP server. mcp_servers.era divides every counted hostname by protocol era: mcp_servers.era.legacy; mcp_servers.era.dual, a lower bound; mcp_servers.era.modern, not proven modern-only; mcp_servers.era.unknown; and mcp_servers.era.not_measured, recorded before the era probe and not re-checked since. mcp_servers.transport divides them by transport: mcp_servers.transport.streamable_http, mcp_servers.transport.legacy_sse, mcp_servers.transport.unknown and mcp_servers.transport.not_measured. mcp_servers.window.from and mcp_servers.window.to are when the oldest and the newest of the verdicts counted were last checked. mcp_servers.last_run_at is a timestamp, not a count: when the AI-exposure radar, which checks other AI services too, last completed a check; mcp_servers.enabled is whether one completed within ${MCP_ENABLED_WITHIN} of the answer, and mcp_servers.counted_at is when the API read the counts. A partition whose buckets do not add up to the count it divides is left out and named; an answer that does not say it is the MCP-server counts, or whose mcp_servers.total, mcp_servers.protected, mcp_servers.pending_readjudication and mcp_servers.not_assessed are missing or contradict each other, is a failure. ${SHODAN_OWNERSHIP}`;
1785
1804
  // exposure_radar's envelope. Every radar answered, but no stats answer says when the services
1786
1805
  // or records it counts were observed: each count covers everything that radar still holds on
1787
1806
  // record, and the answer carries only generated_at (when the API computed the totals) and
@@ -2106,6 +2125,7 @@ const MCPServersData = z.strictObject({
2106
2125
  ...Object.fromEntries(MCP_HEADLINE.map((k) => [k, Whole])),
2107
2126
  ...Object.fromEntries(MCP_PARTITION_NAMES.map((k) => [k, z.strictObject(Object.fromEntries(MCP_PARTITIONS[k].buckets.map((b) => [b, Whole]))).optional()])),
2108
2127
  own_controls_excluded: Whole.optional(),
2128
+ withheld_opted_out: Whole.optional(),
2109
2129
  window: z.strictObject({ from: Instant, to: Instant }).optional(),
2110
2130
  enabled: z.boolean().optional(),
2111
2131
  ...Object.fromEntries(MCP_INSTANTS.map((k) => [k, Instant.optional()])),
package/dist/prompts.js CHANGED
@@ -36,14 +36,17 @@ const text = (t) => ({ messages: [{ role: "user", content: { type: "text", text:
36
36
  // Its Patch line (#2817): cve_intel's fixed_version is one value per (CVE, ecosystem, package), the
37
37
  // advisory's last range's (core-backend cve/advisory.go UpsertAffectedPackages), and triage_cve has
38
38
  // no installed version to hold it against: for CVE-2021-44228 production's cve_intel gives
39
- // log4j-core 2.12.2 (2026-10-04, 2.6.3), below an affected 2.14.1. So the line gives it as
40
- // what cve_intel records for the package, never as the fix for every affected branch, and sends
41
- // the reader to get_cve's references tagged Vendor Advisory or Patch for a branch's fix.
39
+ // log4j-core 2.12.2 (2026-10-04, 2.6.3), below an affected 2.14.1. Since #2817 each
40
+ // affected_packages row also carries fixed_branches, every range with its fix (core-backend
41
+ // cve/fixed_branches.go), so the line gives those, range by range, and gives fixed_version only
42
+ // where a row has no fixed_branches, as what cve_intel records for the package, never as the fix
43
+ // for every affected branch, sending the reader to get_cve's references tagged Vendor Advisory or
44
+ // Patch for a branch's fix.
42
45
  export function triageCveText(cve) {
43
46
  return [
44
47
  `Triage ${cve} using the EchelonGraph MCP tools. Call them in this order, each with cve_id ${cve}:`,
45
48
  "1. get_cve: the record. Read score_assessed before echelongraph_score (false: not yet scored, which is not a score of 0); read severity, cvss_v3_score, epss_score and epss_percentile, and the CISA-KEV fields kev_listed, kev_added_date, kev_due_date and kev_ransomware.",
46
- "2. cve_intel: weaknesses (cwes), public exploit code (exploits and exploits_total; verified_status is the label stored with each reference, not a guarantee that it works, and an empty list is not evidence that no exploit exists), affected_packages and fixed_versions.",
49
+ "2. cve_intel: weaknesses (cwes), public exploit code (exploits and exploits_total; verified_status is the label stored with each reference, not a guarantee that it works, and an empty list is not evidence that no exploit exists), affected_packages (each package's affected ranges with their fixes in fixed_branches) and fixed_versions.",
47
50
  "3. vendor_advisories_for_cve: the vendor advisories that name it. Call get_vendor_advisory for one whose remediation you need in full.",
48
51
  "4. epss_history: how its EPSS score changed, one point per recorded change (series_kind change_only): before series_starts_at a missing point means not recorded, not unchanged.",
49
52
  "5. cve_exposure: internet exposure on record. exposure_state says what the count is; exposed_hosts counts distinct ip:port services, not machines; the result is not_assessed with measured_at null, so present any count as undated and on record, and exposure_state not_assessed as not assessed.",
@@ -52,7 +55,7 @@ export function triageCveText(cve) {
52
55
  "- Exploited? CISA-KEV listing and its date, known ransomware use, and public exploit code on record.",
53
56
  "- Likelihood: EPSS score and percentile, and how it has moved.",
54
57
  "- Reachable? exposure_state and the count on record, or not assessed.",
55
- `- Patch: fixed versions and vendor advisories; where the results hold none, write "none on record", which is not a finding that no fix exists. cve_intel keeps one fixed_version per ecosystem and package, its advisory's last range's, which need not be the fix for every affected version of the package: give each as the fixed version cve_intel records for that package, never as the fix for every affected branch or the version every install should move to, and for the fix on a given branch point to get_cve's references tagged Vendor Advisory or Patch, where it has them, and to the vendor advisories.`,
58
+ `- Patch: fixed versions and vendor advisories; where the results hold none, write "none on record", which is not a finding that no fix exists. For each affected package whose cve_intel row carries fixed_branches, give every range with its fix: from introduced ("0" is the first version) up to its fixed, or up to and including last_affected, where no fix is on record for that range; never one range's fix as the fix for every affected branch or the version every install should move to. cve_intel keeps one fixed_version per ecosystem and package, its advisory's last range's as a rule, which need not be the fix for every affected version of the package: where a row has no fixed_branches (null or absent), give fixed_version as the fixed version cve_intel records for that package, never as the fix for every affected branch, and for the fix on a given branch point to get_cve's references tagged Vendor Advisory or Patch, where it has them, and to the vendor advisories.`,
56
59
  "- Deadline: get_cve's kev_due_date when the CVE is KEV-listed (CISA's due date for US federal civilian agencies); otherwise write that the results give no CISA deadline.",
57
60
  "- Decision: patch now, schedule, or monitor, with a one-line reason drawn from the lines above.",
58
61
  ].join("\n");
@@ -90,17 +93,25 @@ export function amIAffectedText(a) {
90
93
  // rebuilds them from the document, from the position check_sbom's note gives, in the order it
91
94
  // gives (check_sbom.ts READ_ORDER).
92
95
  //
93
- // Fixed versions (#2799 review, #2817). cve_intel's fixed_version is one value per (CVE, ecosystem,
96
+ // Fixed versions (#2799 review, #2817, #2830). cve_intel's fixed_version is one value per (CVE, ecosystem,
94
97
  // package), the advisory's last range's (core-backend cve/advisory.go UpsertAffectedPackages), so
95
98
  // it need not be the fix for the installed version's branch: for CVE-2021-44228, production's
96
99
  // cve_intel gives log4j-core 2.12.2 (2026-10-04, 2.6.3), older than an installed 2.14.1, and
97
100
  // check_sbom's matches for 2.14.1 say it "is listed verbatim in the advisory's affected versions",
98
101
  // which names no fix. So a fixed version is given only where it is strictly greater than the
99
102
  // installed version, and otherwise the line says the results give none for the component's branch
100
- // and points to the advisory. The advisory interval in a match's match_reason, "[A, B)" (core-backend
101
- // cve/pkgmatch.go evaluateRegistryRow), is preferred where the text keeps it (its first cut level;
102
- // a Juice Shop SBOM of 50 components is cut past it); B is the interval's fixed bound, or its
103
- // last_affected one, which the text does not tell apart, so B is given as the interval's end.
103
+ // and points to the advisory. The advisory interval in a match's match_reason (core-backend
104
+ // cve/pkgmatch.go renderInterval) is preferred where the text keeps it (its first cut level;
105
+ // a Juice Shop SBOM of 50 components is cut past it). Since #2830 its closing bracket carries the
106
+ // bound's meaning: "[A, B)" is a fixed bound, B the first version not affected, so B is given as
107
+ // the fix; "[A, B] (B is the last affected version, not a fix)" is an OSV last_affected bound,
108
+ // B still affected, so B (or any version at or below it) is never given as a fix; "[A, ∞)" has no
109
+ // end on record; a text clipped before the closing bracket ("[A, B…") gives no fix from it.
110
+ // Through 2.6.5 core-backend rendered a last_affected bound as "[A, B)" too, and the prompt
111
+ // hedged B as "the end of that interval". Since #2817 cve_intel's affected_packages rows carry
112
+ // fixed_branches, every range with its fix, so after the match_reason interval the rule reads the
113
+ // range that holds the installed version (2.14.1: [2.13.0, 2.15.0), so 2.15.0), and falls back to
114
+ // fixed_version only for a row without fixed_branches.
104
115
  // A cut text keeps index, purl, verdict, not_assessed_reason and cve_ids on a row, not its
105
116
  // ecosystem, package or version, so the model reads those from the purl, as core-backend's
106
117
  // matcher does (cve/matchbatch.go purlNameVersion, shared/pkg/purlosv): step 2 lists every type
@@ -113,11 +124,11 @@ export function sbomReviewText(sbom) {
113
124
  "Review the SBOM below for known vulnerabilities, using the EchelonGraph MCP tools.",
114
125
  `1. Call check_sbom with sbom set to the document below, as given. It reads the document's purls and checks up to ${MAX_PURLS} distinct purls per call, sending them in batches of ${MAX_COMPONENTS}; a document with more is refused, not truncated. If it is refused, say why from the result and do not guess at the components. If coverage.not_sent is above 0, the API's rate limit or the call's time budget stopped it early: say how many purls were not sent and coverage.not_sent_reason, and that they are not checked (never clean). After a minute, call check_sbom once more with purls set to the purls not sent, and merge what it returns. data.not_sent_purls lists them; where the note says the first text block cuts not_sent_purls to its first entries, rebuild the list from the document instead, as the note says: the document's distinct purls from the position the note gives on, counted in the order it gives.`,
115
126
  "2. Read each component's ecosystem, package and version from its purl, which every row of data.results in the text carries (the leanest cut of the text leaves out the row's own ecosystem, package and version), as check_sbom maps them. A purl is pkg:type/namespace/name@version?qualifiers, each part percent-decoded (%40 is @), its type and distro qualifier in any case. The version is what follows the last @, up to any ? or #. The ecosystem follows from the type: npm is npm, pypi PyPI, maven Maven, golang Go, cargo crates.io, gem RubyGems, nuget NuGet, composer Packagist, hex Hex, pub Pub, swift SwiftURL, hackage Hackage, cran CRAN, bitnami Bitnami, conan ConanCenter and githubact GitHub Actions. For deb, apk, alpine and rpm it is the release the distro qualifier names, which must be one of these three kinds: distro=debian-N is Debian:N, the major release alone (debian-12 and debian-12.5 are Debian:12); distro=ubuntu-V is Ubuntu:V as given (ubuntu-22.04 is Ubuntu:22.04); distro=alpine-X.Y is Alpine:vX.Y, cut to major.minor (alpine-3.20 and alpine-3.20.10 are Alpine:v3.20; alpine-3 names none). For any other type, and a deb, apk, alpine or rpm purl without such a qualifier, the ecosystem is unknown: check_sbom does not check the component (not_assessed, never clean), and the fix list gives no version for it. The package is namespace:name for maven (pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1 is the package org.apache.logging.log4j:log4j-core in Maven, at version 2.14.1), namespace/name for npm, golang, composer, swift and githubact, and the name alone for every other type (pkg:deb/debian/openssl@3.0.15-1~deb12u1?distro=debian-12 is openssl in Debian:12).",
116
- "3. For each distinct cve_id on a component whose verdict is affected, call get_cve, to read kev_listed, epss_score, score_assessed, echelongraph_score and cvss_v3_score, and cve_intel, to read fixed_version from its affected_packages and fixed_versions rows for the component's ecosystem and package (package_name). If there are more than 30 distinct CVEs, do this for 30 of them and list the rest as not looked up, unranked.",
127
+ "3. For each distinct cve_id on a component whose verdict is affected, call get_cve, to read kev_listed, epss_score, score_assessed, echelongraph_score and cvss_v3_score, and cve_intel, to read fixed_branches and fixed_version from its affected_packages rows, and fixed_version from its fixed_versions rows, for the component's ecosystem and package (package_name). If there are more than 30 distinct CVEs, do this for 30 of them and list the rest as not looked up, unranked.",
117
128
  ENVELOPE_RULES,
118
129
  "Read each component's verdict: not_affected is the one clean verdict; undetermined and not_assessed are not clean, and not_assessed_reason says why (for example distro_release_unknown: a deb, apk or rpm purl without a distro qualifier, which EchelonGraph does not guess).",
119
130
  "Then write a prioritised fix list of the affected components. State the ordering keys, in this order: 1. CISA-KEV listed (kev_listed true) first; 2. then EPSS score, highest first; 3. then echelongraph_score where score_assessed is true, else cvss_v3_score, highest first. A CVE with no value for a key sorts after those with one, and its line says which key was missing.",
120
- "For each line give the component (purl), its cve_ids, the key values used, and for each CVE a fixed version where the results support one, by this rule. Never give a fixed version that is not strictly greater than the component's installed version (the version in its purl), compared in the ecosystem's version order: release numbers part by part, as numbers, not as text (2.9.1 is below 2.12.2, and 2.12.2 is below 2.14.1); where you are not sure of the order (pre-releases, qualifiers and distro revisions have their own rules), say so and do not give it. Prefer the match's match_reason where the text keeps it: \"falls inside the advisory interval [A, B)\" says the advisory's range that holds the installed version ends at B, which match_reason does not say is a fixed version rather than the range's last affected version, so give B as the end of that interval, quoting it (∞: the range has no end on record). Otherwise give cve_intel's fixed_version for the component's ecosystem and package (or the recorded fix a match_reason names, the same value): cve_intel keeps one fixed_version per package, its advisory's last range's, which need not be the fix on the component's branch, so say so beside it. Where no version meets the rule (\"listed verbatim in the advisory's affected versions\" names none), write that the results give no fixed version for the component's branch, which is not a finding that no fix exists, and point to the CVE's advisory for it: get_cve's references tagged Vendor Advisory or Patch, where it has them.",
131
+ "For each line give the component (purl), its cve_ids, the key values used, and for each CVE a fixed version where the results support one, by this rule. Never give a fixed version that is not strictly greater than the component's installed version (the version in its purl), compared in the ecosystem's version order: release numbers part by part, as numbers, not as text (2.9.1 is below 2.12.2, and 2.12.2 is below 2.14.1); where you are not sure of the order (pre-releases, qualifiers and distro revisions have their own rules), say so and do not give it. Prefer the match's match_reason where the text keeps it: \"falls inside the advisory interval [A, B)\", closed by a parenthesis, says B is the advisory's fixed version for the range that holds the installed version, so give B as the fixed version, quoting it (\"[A, ∞)\": the range has no end on record and names no fixed version). \"falls inside the advisory interval [A, B]\", closed by a square bracket, says B is the range's last affected version, still affected, and the range records no fix (core-backend adds \"(B is the last affected version, not a fix)\", which a cut text can clip, so the bracket alone decides): never give B, or any version at or below it, as a fixed version. Where the text is cut before the interval's closing bracket (\"[A, B…\", neither \")\" nor \"]\"), it does not say which bound B is: give no fixed version from that match_reason. Otherwise, where cve_intel's affected_packages row for the component's ecosystem and package carries fixed_branches, give the fixed of the range that holds the installed version: at or above its introduced (\"0\" is the first version) and below its fixed, or at or below its last_affected, in the same version order; a range with last_affected and no fixed has no fix on record, and a version in no range is given no fixed version from them; where fixed_branches is there, fixed_version is not used. Where it is not (null or absent), give cve_intel's fixed_version for the component's ecosystem and package (or the recorded fix a match_reason names, the same value): cve_intel keeps one fixed_version per package, its advisory's last range's, which need not be the fix on the component's branch, so say so beside it. Where no version meets the rule (\"listed verbatim in the advisory's affected versions\" names none), write that the results give no fixed version for the component's branch, which is not a finding that no fix exists, and point to the CVE's advisory for it: get_cve's references tagged Vendor Advisory or Patch, where it has them.",
121
132
  "After the list, give data.summary's counts, and list the undetermined and not_assessed components with their reasons as not checked, never as clean.",
122
133
  "Where the note says that rows of data.results are left out of the first text block, list the components its rows carry, say how many affected components the text leaves out (data.summary.affected, less those listed), and give the undetermined and not_assessed components it leaves out as data.summary's counts, by not_assessed_reason, as not checked, never as clean.",
123
134
  "",
@@ -172,7 +172,8 @@ 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
178
  // the description promises; then, for a list at the cap, without cpe_vendor and vendor_unknown;
178
179
  // and last without effective_severity, the band of effective_score. kev_listed, ransomware,
@@ -63,7 +63,8 @@ export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGr
63
63
  // a CVE with its description, so the 50-component Juice Shop document was 112,820 characters of
64
64
  // text, and 2,000 purls about 1,200,000. Past DATA_TEXT_BUDGET each row in the first text block
65
65
  // keeps its verdict, counts and cve_ids, and each match its scores, flags and match_reason (the
66
- // 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
67
68
  // description; then the same without match_reason; then only index, purl, verdict,
68
69
  // not_assessed_reason and cve_ids. When rows must still go, the clean ones (not_affected) leave
69
70
  // first, then the not_assessed ones, both counted in data.summary and the note, so the affected
@@ -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");
@@ -36,11 +36,21 @@
36
36
  // CVE-2024-21412, which both vendors did publish advisories for. core-backend now reads those
37
37
  // histories back (vendoradv backfill.go) and serves each vendor's window at
38
38
  // /api/v1/public/vendor-advisories/coverage (coverage.go): advisories held, the earliest and latest
39
- // vendor_published_at among them, and history_backfill. All three tools read it beside their own
40
- // request and relay it in coverage, and the note says when a CVE's ID year begins before a
41
- // vendor's earliest held advisory, or a vendor's history is still being read, so an empty answer
42
- // is never read as "the vendor published none". The window is context, not the answer: when it
43
- // cannot be read (an API older than the route, a failure), the answer is relayed as before, with
39
+ // vendor_published_at among them, held_since and history_backfill. All three tools read it beside
40
+ // their own request and relay it in coverage, and the note says when a CVE's ID year begins before
41
+ // the date a vendor is held from, or a vendor's history is still being read, so an empty answer
42
+ // is never read as "the vendor published none".
43
+ //
44
+ // held_since, not the earliest held advisory, is where a vendor's window begins (#2803). A vendor
45
+ // with no history read (history_backfill not_supported) is held from its poller's first poll on,
46
+ // and Cisco's and Red Hat's first polls read only what the vendor had CHANGED lately: an old
47
+ // advisory the vendor revised since is held, so the earliest held advisory reached back to 2024
48
+ // for Cisco and 2009 for Red Hat while the advisories published beside it are not held, and
49
+ // CVE-2025-20188 read Cisco as fully held for 2025. coverage.go serves held_since as the start of
50
+ // the window EchelonGraph has read the vendor's advisories from; an API older than that field gives none, and a vendor is
51
+ // then taken as held from its earliest advisory only when its history read is complete.
52
+ //
53
+ // The window is context, not the answer: when it cannot be read (an API older than the route, a failure), the answer is relayed as before, with
44
54
  // the windows null and a note saying they are unknown, never failed. The coverage request carries
45
55
  // nothing typed: a fixed path, no query, no search header.
46
56
  //
@@ -113,6 +123,7 @@ function windowsOf(d) {
113
123
  advisories: num(r, "advisories") ?? null,
114
124
  earliest_vendor_published_at: instantOrNull(r, "earliest_vendor_published_at"),
115
125
  latest_vendor_published_at: instantOrNull(r, "latest_vendor_published_at"),
126
+ held_since: instantOrNull(r, "held_since"),
116
127
  history_backfill: str(r, "history_backfill") ?? null,
117
128
  },
118
129
  ];
@@ -120,30 +131,48 @@ function windowsOf(d) {
120
131
  }
121
132
  const day = (instant) => instant.slice(0, 10);
122
133
  const backfilling = (w) => w.history_backfill !== null && BACKFILLING.has(w.history_backfill);
134
+ // #2803: the start of the window a vendor's advisories have been read from: held_since as the API
135
+ // gives it; from an API without it, the earliest held advisory when the vendor's history read is
136
+ // complete (that is the archive's start), else unknown (null).
137
+ const heldFrom = (w) => w.held_since ?? (w.history_backfill === "complete" ? w.earliest_vendor_published_at : null);
138
+ // Whether a vendor's window starts on or before 1 January of a CVE ID's year.
139
+ const heldFromYear = (w, yearStart) => {
140
+ const from = heldFrom(w);
141
+ return from !== null && Date.parse(from) <= yearStart;
142
+ };
123
143
  // What a note says of one window: what is held, and the history read when it is not done.
124
144
  const windowItem = (w, heldOnly) => {
145
+ const from = heldFrom(w);
146
+ const to = w.latest_vendor_published_at ? ` to ${day(w.latest_vendor_published_at)}` : "";
125
147
  const held = w.earliest_vendor_published_at === null
126
148
  ? "none held"
127
- : `held ${heldOnly ? "only " : ""}from ${day(w.earliest_vendor_published_at)}${w.latest_vendor_published_at ? ` to ${day(w.latest_vendor_published_at)}` : ""}`;
149
+ : from === null
150
+ ? `held from ${day(w.earliest_vendor_published_at)}${to}, not known to be without a gap`
151
+ : `held ${heldOnly ? "only " : ""}from ${day(from)}${to}${day(w.earliest_vendor_published_at) < day(from) ? `, and before that only in part, the earliest held dated ${day(w.earliest_vendor_published_at)}` : ""}`;
128
152
  return `${w.vendor} (${held}${backfilling(w) ? `; history_backfill ${w.history_backfill}` : ""})`;
129
153
  };
130
154
  // The sentence for windows that could not be read: no "none here" is a finding about a vendor.
131
155
  const windowsUnknown = (why) => ` The vendors' coverage windows could not be read (${why}), so this answer cannot say from what date each vendor's advisories are held: no advisory here from a vendor is not a finding that it published none.`;
132
156
  // vendor_advisories_for_cve: the vendors with no advisory in the answer that may have published
133
- // one EchelonGraph does not hold: none held at all, the earliest held dated after 1 January of the
134
- // year in the CVE ID, or a history still being read.
157
+ // one EchelonGraph does not hold: none held at all, held (held_since) only from after 1 January of
158
+ // the year in the CVE ID or from a date not known, or a history still being read. Never the
159
+ // earliest held advisory: for a vendor with no history read it can be years before held_since
160
+ // (#2803).
135
161
  function notFullyHeld(windows, rows, year) {
136
162
  const answered = new Set(rows.map((r) => str(r, "vendor")).filter((v) => v !== undefined));
137
163
  const yearStart = Date.UTC(year, 0, 1);
138
- return windows.filter((w) => !answered.has(w.vendor) && (w.earliest_vendor_published_at === null || Date.parse(w.earliest_vendor_published_at) > yearStart || backfilling(w)));
164
+ return windows.filter((w) => !answered.has(w.vendor) && (w.earliest_vendor_published_at === null || !heldFromYear(w, yearStart) || backfilling(w)));
139
165
  }
140
166
  function notFullyHeldNote(cve, year, missing) {
141
167
  if (!missing.length)
142
168
  return "";
143
169
  const k = missing.length;
144
170
  const yearStart = Date.UTC(year, 0, 1);
145
- const items = missing.map((w) => windowItem(w, w.earliest_vendor_published_at !== null && Date.parse(w.earliest_vendor_published_at) > yearStart));
146
- return ` ${cve} is a ${year} CVE ID, and EchelonGraph may not hold every advisory ${plural(k, "1 vendor", `${k} vendors`)} published for it (vendors_not_fully_held): ${items.join("; ")}. An advisory a vendor published before the earliest one EchelonGraph holds is not held, and while history_backfill is in_progress or not_started the vendor's older advisories are still being read, so no advisory here from ${plural(k, "that vendor", "those vendors")} is not a finding that ${plural(k, "it", "they")} published none.`;
171
+ const items = missing.map((w) => {
172
+ const from = heldFrom(w);
173
+ return windowItem(w, from !== null && Date.parse(from) > yearStart);
174
+ });
175
+ return ` ${cve} is a ${year} CVE ID, and EchelonGraph may not hold every advisory ${plural(k, "1 vendor", `${k} vendors`)} published for it (vendors_not_fully_held): ${items.join("; ")}. An advisory a vendor published before the date EchelonGraph holds that vendor from (held_since) may not be held, even when an older one is, and while history_backfill is in_progress or not_started the vendor's older advisories are still being read, so no advisory here from ${plural(k, "that vendor", "those vendors")} is not a finding that ${plural(k, "it", "they")} published none.`;
147
176
  }
148
177
  // search_vendor_advisories and get_vendor_advisory: the vendors whose history is still being read.
149
178
  function backfillingNote(windows) {
@@ -179,8 +208,10 @@ const DATES_DESCRIPTION = "Each advisory carries vendor_published_at (the vendor
179
208
  const ENVELOPE_DESCRIPTION = "Its structured result carries state (measured), measured_at, method, coverage, freshness (null: the answer carries no poll-completion time) and notes, with data equal to the API's JSON; the result's last text block repeats it without data (the first text block) and without the note's sentences (the text block before it), with which notes ends.";
180
209
  // #2729: what a vendor's window is.
181
210
  const HISTORY_BACKFILL_DESCRIPTION = "history_backfill: complete (the vendor's published history has been read back as far as its source goes), in_progress or not_started (it is still being read, so the vendor's older advisories are not all held yet), or not_supported (EchelonGraph has no history read for that vendor, so what it holds is what the vendor's feed has carried)";
182
- const WINDOW_DESCRIPTION = `vendor, advisories (how many EchelonGraph holds from that vendor), earliest_vendor_published_at and latest_vendor_published_at (the earliest and latest vendor_published_at among them, null when none is held) and ${HISTORY_BACKFILL_DESCRIPTION}`;
183
- const NOT_HELD_DESCRIPTION = "An advisory a vendor published before its earliest_vendor_published_at is not held, so no advisory from a vendor is not a finding that it published none.";
211
+ // #2803: where a vendor's window begins.
212
+ const HELD_SINCE_DESCRIPTION = "held_since (the start of the window EchelonGraph has read that vendor's advisories from, before which an advisory may not be held; null when none is held or the API does not say; for a vendor with no history read it is when its feed was first read from, and can be years after earliest_vendor_published_at, since an older advisory is held when the vendor revised it later, not the ones published beside it)";
213
+ const WINDOW_DESCRIPTION = `vendor, advisories (how many EchelonGraph holds from that vendor), earliest_vendor_published_at and latest_vendor_published_at (the earliest and latest vendor_published_at among them, null when none is held), ${HELD_SINCE_DESCRIPTION} and ${HISTORY_BACKFILL_DESCRIPTION}`;
214
+ const NOT_HELD_DESCRIPTION = "An advisory a vendor published before its held_since may not be held, even when its earliest_vendor_published_at is older, so no advisory from a vendor is not a finding that it published none.";
184
215
  // #2728: how a search matches, and its capped total.
185
216
  const SEARCH_MATCH_DESCRIPTION = "A query of 3 or more characters is matched case-insensitively as a substring of each advisory's title, description, vendor name, vendor_advisory_id, affected_products and cve_ids (search_match substring). A query of 1 or 2 characters matches whole words only (search_match word): it must equal, ignoring case, a whole word (a run of letters and digits) of one of those, so xz finds xz-utils but not xzibit, and a 1- or 2-character query with any other character, such as c#, matches nothing.";
186
217
  const TOTAL_CAPPED_DESCRIPTION = "A search counts at most 1,000 matches: past that, total is 1000 and total_capped is true, which means 1,000 or more (the note writes 1,000+), never exactly 1,000; paging past it still works. Without a query, total is the exact count and total_capped is false.";
@@ -191,8 +222,12 @@ const VendorWindowSchema = z.strictObject({
191
222
  earliest_vendor_published_at: z
192
223
  .string()
193
224
  .nullable()
194
- .describe("The earliest vendor_published_at among them; null when none is held. An advisory this vendor published before it is not held."),
225
+ .describe("The earliest vendor_published_at among them; null when none is held. Not where the window begins: see held_since."),
195
226
  latest_vendor_published_at: z.string().nullable().describe("The latest vendor_published_at among them; null when none is held."),
227
+ held_since: z
228
+ .string()
229
+ .nullable()
230
+ .describe("The start of the window EchelonGraph has read this vendor's advisories from; null when none is held or the API does not say. An advisory this vendor published before it may not be held, even when earliest_vendor_published_at is older."),
196
231
  // The four values the API sends today, and any later one as a string: a new state must not turn
197
232
  // the answer into a failure.
198
233
  history_backfill: z
@@ -233,7 +268,7 @@ export function registerVendorAdvisoryTools(server, kit) {
233
268
  vendors_not_fully_held: z
234
269
  .array(z.string())
235
270
  .nullable()
236
- .describe("The vendors with no advisory in this answer that may have published one EchelonGraph does not hold: none held, the earliest held dated after 1 January of cve_year, or history_backfill in_progress or not_started. No advisory from them is not a finding that they published none. null when the windows could not be read."),
271
+ .describe("The vendors with no advisory in this answer that may have published one EchelonGraph does not hold: none held, held_since after 1 January of cve_year or not known, or history_backfill in_progress or not_started. No advisory from them is not a finding that they published none. null when the windows could not be read."),
237
272
  }),
238
273
  freshness: null,
239
274
  });
@@ -473,7 +508,7 @@ export function registerVendorAdvisoryTools(server, kit) {
473
508
  }
474
509
  server.registerTool("vendor_advisories_for_cve", {
475
510
  title: "Vendor advisories for one CVE",
476
- description: `The vendor-published advisories that name one CVE, newest first, at most 20: for each, vendor, vendor_display_name, vendor_advisory_id, title, severity and cvss_v3_score where the vendor gives them. Covers the vendor feeds EchelonGraph polls, for example Microsoft MSRC, Red Hat, Cisco, Palo Alto Networks and GitHub GHSA; an empty answer means that none of the advisories EchelonGraph holds from those feeds names the CVE, not that no vendor published one (see vendor_windows and vendors_not_fully_held). ${DATES_DESCRIPTION} Pass a CVE ID like CVE-2024-21412. coverage gives returned, cap and at_cap (true: the answer is full, so there may be more); cve_year, the year in the CVE ID; vendor_windows, each vendor's window in what EchelonGraph holds: ${WINDOW_DESCRIPTION}; and vendors_not_fully_held, the vendors with no advisory in the answer of which EchelonGraph holds none, whose earliest held advisory is dated after 1 January of cve_year, or whose history is still being read, which the note names. ${NOT_HELD_DESCRIPTION} vendor_windows and vendors_not_fully_held are null when the windows could not be read, and the note says so. measured_at is null. ${ENVELOPE_DESCRIPTION} ${TEXT_BUDGET_DESCRIPTION}${ROW_CUT_DESCRIPTION}.`,
511
+ description: `The vendor-published advisories that name one CVE, newest first, at most 20: for each, vendor, vendor_display_name, vendor_advisory_id, title, severity and cvss_v3_score where the vendor gives them. Covers the vendor feeds EchelonGraph polls, for example Microsoft MSRC, Red Hat, Cisco, Palo Alto Networks and GitHub GHSA; an empty answer means that none of the advisories EchelonGraph holds from those feeds names the CVE, not that no vendor published one (see vendor_windows and vendors_not_fully_held). ${DATES_DESCRIPTION} Pass a CVE ID like CVE-2024-21412. coverage gives returned, cap and at_cap (true: the answer is full, so there may be more); cve_year, the year in the CVE ID; vendor_windows, each vendor's window in what EchelonGraph holds: ${WINDOW_DESCRIPTION}; and vendors_not_fully_held, the vendors with no advisory in the answer of which EchelonGraph holds none, whose held_since is after 1 January of cve_year or not known, or whose history is still being read, which the note names. ${NOT_HELD_DESCRIPTION} vendor_windows and vendors_not_fully_held are null when the windows could not be read, and the note says so. measured_at is null. ${ENVELOPE_DESCRIPTION} ${TEXT_BUDGET_DESCRIPTION}${ROW_CUT_DESCRIPTION}.`,
477
512
  inputSchema: z.object({ cve_id: z.string().describe("a CVE ID, e.g. CVE-2024-21412") }),
478
513
  outputSchema: FOR_CVE_OUTPUT,
479
514
  annotations: kit.annotations,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "echelongraph-mcp",
3
- "version": "2.6.5",
3
+ "version": "2.6.6",
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": "2.6.5",
20
+ "version": "2.6.6",
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": "2.6.5",
31
+ "version": "2.6.6",
32
32
  "transport": {
33
33
  "type": "stdio"
34
34
  },