echelongraph-mcp 3.0.0 → 3.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,101 @@ 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
+ ## 3.0.2 — 2026-10-17
10
+
11
+ 3.0.1 was tagged but never published to npm: its release run's memory test
12
+ (`test/http-memory.test.mjs`) failed. With two 2,000-purl documents and 64 other requests in
13
+ flight, the hosted endpoint needed the whole of its image's 128 MiB heap, and V8 aborted. 3.0.2 is
14
+ 3.0.1's changes with that fixed (the first entry).
15
+
16
+ - **The hosted image caps V8's old space at 104 MiB, not 128** (#3132, Dockerfile.http). With the
17
+ schemas built once, the memory test's live set fits in 80 MiB, and at 128 V8 let the burst's
18
+ garbage stand until the container peaked at 256-258 MB of its 268 MB. At 104 the peak is
19
+ 235-241 MB with about 24 MiB of heap to spare (3.0.0 at 128: 238-252 MB). The start-up line's
20
+ `heap_limit_mb` now reads 152 (104 plus the young generation), not 176.
21
+
22
+ - **The hosted endpoint builds each tool's output schema once, not once per request** (#3132).
23
+ It serves every request with a server of its own, and 3.0.1's closed copy of each success
24
+ branch, the check behind the open `outputSchema` (below), doubled the output schemas each one
25
+ built: 1,371 KB of heap a server, against 3.0.0's 1,168. The memory test's two SBOMs were still
26
+ at their first batches when V8 aborted, so it was not their cut that ran out. Each schema is now
27
+ built once per process, 611 KB a server, and the memory test passes with an 80 MiB heap, where
28
+ 3.0.0 needed 112 and 3.0.1 128. check_sbom's and scan_manifest's structured cut also measures a
29
+ thinned row and lets it go, and thins again only the rows it keeps: on that test's 2,000-row
30
+ answer it allocated 108 MB in 3.0.1, 18 MB in 3.0.0 and 24 MB now. No answer changes: every
31
+ call of the suite returns the same text and `structuredContent` as 3.0.1.
32
+ - **check_sbom and scan_manifest keep every row before any row leaves `data`** (#3132). In 3.0.0
33
+ the structured cut had one level for a row, which kept every match, so a row still large once
34
+ thinned left `data`, and every row after it went with it. Hosted 3.0.0 kept 1 of 5 rows for five
35
+ Debian 12 purls, all affected (linux-libc-dev 6.1.55-1 has 200 matches). It kept 2 of 51 rows for
36
+ Juice Shop 11.1.2 with `pkg:composer/magento/community-edition@2.3.0` at index 2. For that purl
37
+ alone it kept no row, and the note said "check 0 or fewer purls per call". Now, past the thinned
38
+ level, every row's matches are capped at the same number, the most that fit. A row with more
39
+ keeps its first matches (highest `effective_score` first) and carries `matches_truncated`: its
40
+ total, the number returned, and the KEV-listed and ransomware CVE IDs and the `fixed_in` versions
41
+ of all its matches. Next, each match is cut to `cve_id`, `kev_listed`, `ransomware`, `fixed_in`
42
+ and `effective_score`, the row keeping `matched_package`, `matched_via` and an upstream row's
43
+ `matched_version_from` and `purl_name`. Only when that cannot keep every row is each row cut to
44
+ `index`, `purl`, `verdict`, `not_assessed_reason` and `cve_ids`. Rows leave only when even that
45
+ cannot keep them all, clean rows first as before, and they leave at the compact match level, at
46
+ most 10 matches a row, so each row kept still says its KEV flags and `fixed_in`. Once the cut is
47
+ settled, the rows kept take back detail in input order while room is left. Replayed on those
48
+ answers, the three cases keep 5 of 5 rows, 51 of 51 and 1 of 1. On the production synthetic's
49
+ 201-purl probe, all affected, 3.0.0 kept 43 rows; this release keeps 54 compact ones, the
50
+ log4j-core 2.14.1 row with CVE-2021-44228 (`fixed_in` 2.15.0). The note no longer says
51
+ "0 or fewer".
52
+ - **`not_sent_purls` is kept whole or left out, never cut short** (#3132). It is the list a client
53
+ sends again. When a `DATA CUT` leaves it out, check_sbom's note gives the purls not sent as
54
+ positions in the input: `coverage.sent` + 1 to `coverage.distinct_purls`, in the order the note
55
+ states. scan_manifest's positions would count purls read from the files, which a client cannot
56
+ rebuild, so its note says the same call with `full: true` returns the list instead.
57
+ - **cve_remediation drops a vendor advisory only after `patches` and `fixed_branches`** (#3139).
58
+ CVE-2023-44487's default answer kept 2 of its 20 advisories, while `fixed_branches` (50 rows) and
59
+ `patches` (30) stayed whole. Now every advisory is kept, thinned. Then `fixed_branches` is thinned
60
+ (to the package, its range, `fixed_version` and each range's `introduced`, `fixed` and
61
+ `last_affected`; each range's `source` and `advisory_id` go), and each package's ranges are
62
+ capped at the most that fit, a package so cut carrying `fixed_branches_truncated` (total and
63
+ returned). Then `patches` are thinned (to `vendor`, `patch_url` and `published_at`), every one
64
+ kept, and `fix_references` is cut. Only then do `patches` rows leave, then `fixed_branches` rows.
65
+ Room left goes back to the capped ranges. Replayed, that answer keeps all 20 advisories, all 50
66
+ `fixed_branches` and all 30 `patches`, each thinned, and 17 of 20 `fix_references`, at 29,931
67
+ characters. When
68
+ advisories do leave, the `DATA CUT` note names `vendor_advisories_for_cve`, which lists every
69
+ one.
70
+ - **`patch_available`'s schema text says what `true` beside an empty `patch_evidence` means**
71
+ (#3138). It means evidence of a fix once held has since been withdrawn: the flag is never set
72
+ back to false. `remediation.fix_status` is then the current reading. The text used to say `true`
73
+ means EchelonGraph holds evidence of a fix, while hosted get_cve CVE-2026-1281 answered `true`
74
+ beside `[]` and `none_confirmed`. get_cve's and search_cves' descriptions now say `true` means
75
+ EchelonGraph holds, or once held, fix evidence for the CVE, and `patch_evidence` is what it holds
76
+ now.
77
+ - **A success's `outputSchema` admits top-level fields it does not name** (#3121). A client
78
+ validates answers against the tool list it read, and the hosted endpoint never tells it to read
79
+ again. In 3.0.0 a Claude Code session that listed the tools under 2.7.2 failed every tool's
80
+ call: "data must NOT have additional properties", from `data_truncated`, which 3.0.0 added to
81
+ every success. A field added at the top level in a minor release no longer breaks such a client.
82
+ The failure branch and each tool's `data` schema stay closed, so a field added inside `data`
83
+ still needs a reconnect, and the server still checks its own answers against the closed success
84
+ branch (`test/checked-strict.test.mjs`). If you see "does not match the tool's output schema" after an upgrade, reconnect
85
+ the client (README, Versioning).
86
+ - `test/text-bound-live.mjs --hosted` calls a 3.0.0 or later endpoint with `full: true`. It
87
+ refuses to replay a cut `data` as the API's answer (#3140). Without `full: true`, get_cve
88
+ CVE-2021-44228 replayed 77 of its 396 `cpe_match` entries. The vendor tools' `/coverage` is now
89
+ served from the case's fixture beside the replayed answer, not as that answer. The record
90
+ (`test/fixtures/text-bound-live.json`) was re-measured on hosted 3.0.0 and holds a structured size
91
+ for each of the 23 cases measured; scan_manifest's 64-package lock, cve_remediation and cve_intel
92
+ with `fixed_branches` are measured on production for the first time. Four fixtures were re-read
93
+ where production had moved more than 10% from them (search_cves, epss_history,
94
+ vendor_advisories_for_cve, and scan_manifest's lock, which now replays production's own rows).
95
+
96
+ - **The hosted endpoint's `mcp_handler_error` log names the cause of createMcpHandler's errors**
97
+ (#2923). 2.7.1's `error_class` matched only the legacy transport's messages, so all 78 production
98
+ lines read from 2026-10-06 were `other`. It now also matches the bundled SDK's "Rejected inbound
99
+ request (", "Rejected 2025-era request on a modern-only endpoint (", "Unsupported protocol
100
+ version" (code -32022, and the legacy transport's "Bad Request: " form) and "Missing required
101
+ client capabilities". As before, only the label and the numeric code are logged, never the
102
+ message.
103
+
9
104
  ## 3.0.0 — 2026-10-11
10
105
 
11
106
  A major version (README, Versioning): the structured result is now held to 30,000 characters by
package/README.md CHANGED
@@ -255,7 +255,7 @@ The example questions are ones a client can answer with that tool alone.
255
255
  | `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` (a registry match also `fixed_in`, the lowest version above it that no affected interval of the record holds, with `fixed_in_detail` when it moved past an overlapping interval, or null with `fixed_in_reason`, which is not a finding that no fix exists), 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?* |
256
256
  | `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`. Each row names the package its advisories were looked up by (`matched_package`) and how (`matched_via`: `upstream` where a deb, apk or alpine purl's upstream qualifier names the source package, which is how Debian and Alpine file advisories, else `purl_name`), and each match carries `fixed_in`, the lowest version above the component's that no affected interval of the record holds (`fixed_in_detail` names any overlapping interval it moved past), or null with `fixed_in_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` (whole with `full: true`; a `DATA CUT` can cut it), 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.* |
257
257
  | `scan_manifest` | Check a lockfile against the advisory corpus | A project's lockfiles or pinned manifests checked against the same advisory corpus, one verdict per dependency, through the same batch route, batches, 50-second budget and `Retry-After` waits as `check_sbom`. Pass `files`, 1 to 20 of `filename` and `content` (the file's text), up to 5,000,000 characters in all; the format is read from the filename, or set by `format`. Read: `requirements.txt` (only `==` and `===` pins; extras and markers stripped), `go.mod` (`require`, single-line and block, `// indirect` included, with `replace` and `exclude` applied), `package-lock.json` and `npm-shrinkwrap.json` (lockfileVersion 1 to 3), `Cargo.lock`, `Gemfile.lock` (platform suffixes stripped), `composer.lock` (a leading `v` stripped), `poetry.lock` and `gradle.lockfile`. `package.json`, `pyproject.toml`, `Pipfile`, `Gemfile`, `Cargo.toml`, `composer.json` and `build.gradle` hold ranges and are refused, each naming the lockfile to pass; `go.sum` is refused, as it lists module versions the build does not select; `pom.xml`, `yarn.lock` and `pnpm-lock.yaml` are not read yet. The MCP server reads the files into purls and only the purls are sent to the API; filenames and file contents are 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 files are the request body. `data.results` holds one row per distinct purl, as `check_sbom`'s do, each match with `kev_listed`, `epss_score` and `fixed_in`. Every entry not sent is in `data.not_checked`, with its file, line and `reason`: `version_unpinned` (a range, such as `django>=4`, whose bound is never sent as a version), `version_unresolved`, `local_path`, `vcs_source` or `unsupported_line`, counted in `coverage.not_checked_by_reason`; they are not clean. A file that holds no version this tool can check answers `not_assessed` without calling the API. Only `not_affected` is clean. No ranking, no score. | *Check this package-lock.json against the advisory corpus.* |
258
- | `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 first `fixed` above it that no range of its row holds (the `fixed` of the range that holds it, moved past any range that still holds that version; none past a range without `fixed`), 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?* |
258
+ | `cve_intel` | CVE weakness, exploits and packages | Weakness, exploit code and detection templates (a Nuclei template checks whether a system is affected; it is not necessarily exploit code, and the note counts templates apart), 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 first `fixed` above it that no range of its row holds (the `fixed` of the range that holds it, moved past any range that still holds that version; none past a range without `fixed`), 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?* |
259
259
  | `cve_remediation` | How one CVE is fixed | How one CVE is fixed, as its sources state it, in one answer; every text is relayed as its source states it, untested by EchelonGraph: `cisa` (for a KEV-listed CVE, CISA's `required_action`, `due_date`, `short_description` and `notes_urls`, verbatim; CISA directs the action at US federal civilian agencies, and it is not EchelonGraph's advice), `fixed_branches` (each affected package with every affected range and its fix), `vendor_remediations` (each vendor advisory naming the CVE, at most 20, with its `remediation_state` and the items it lists for the CVE, each with `kind`, the vendor's own category: `vendor_fix`, `workaround`, `mitigation`, `no_fix_planned`, `none_available` or `other`), `fix_references` (references NVD or the CNA tagged Patch or Mitigation), `patches`, `coverage` and `failed_sections`, with `fix_status`, the API's fix-status rule that CVE Pulse pages show. Given the installed `ecosystem`, `package` and `version` (all three or none), it also answers `installed` (that version's verdict) and `fixed_in`: the first version above it that no affected range on record holds, or null with `fixed_in_reason`. `remediation_state` `none_in_source` or `not_parsed`, and an empty list, are not a finding that no fix exists. A REJECTED CVE answers `not_assessed`. | *How is CVE-2021-44228 fixed, as its sources state it?* |
260
260
  | `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?* |
261
261
  | `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, each with `remediation_kinds` and `remediation_state` (`not_parsed`: EchelonGraph does not read that vendor's remediation, which is not a finding of none). | *Which vendor advisories name CVE-2024-3400?* |
@@ -396,13 +396,22 @@ tool states. Where one row is large, every row is first thinned, all rows kept:
396
396
  `scan_manifest` keep each row's `index`, `purl`, `verdict`, `not_assessed_reason`, `cve_ids`,
397
397
  `matched_package` and `matched_via`, and per match `cve_id`, `kev_listed`, `ransomware`, `epss_score`,
398
398
  `effective_score`, `score_assessed`, `fixed_in` and `fixed_in_reason`, every field a prompt reads from
399
- a match (all 50 rows of the Juice Shop 11.1.2 SBOM stay); `check_affected` keeps each
399
+ a match, then each row's matches to at most the same
400
+ number, its first, a row so cut carrying `matches_truncated` (its total, and the KEV-listed and
401
+ ransomware CVE IDs and `fixed_in` versions of all its matches), then each match to `cve_id`,
402
+ `kev_listed`, `ransomware`, `fixed_in` and `effective_score`, then each row to `index`, `purl`,
403
+ `verdict`, `not_assessed_reason` and `cve_ids` (rows leave at the compact level; the rows kept
404
+ take back detail in order); `check_affected` keeps each
400
405
  match's scores, flags, criteria and `fixed_in`, then fewer; `cve_remediation` drops each item's
401
- `product_ids`, then its text, keeping every advisory with its state, kinds, URLs and fixed builds,
402
- and the note then says `get_vendor_advisory` returns each text; `search_cves` keeps each row's
406
+ `product_ids`, then its text, keeping every advisory's state, kinds, URLs and fixed builds
407
+ (`get_vendor_advisory` returns each text), thins `fixed_branches` (then caps each
408
+ package's ranges) and `patches`, then cuts `fix_references`, then `patches`, then
409
+ `fixed_branches`, advisories last, the note naming `vendor_advisories_for_cve`, which
410
+ lists them all; `search_cves` keeps each row's
403
411
  description and scores, then the scores alone; the vendor-advisory
404
412
  tools drop `affected_products`, then `summary`. Only then do rows leave: `check_sbom` and
405
- `scan_manifest` cut `not_sent_purls` (and `not_checked`) first, then drop the `not_affected` rows,
413
+ `scan_manifest` cut `not_checked`, then `not_sent_purls`, whole or none (the note says how to
414
+ get it), then drop the `not_affected` rows,
406
415
  then the `not_assessed` ones (`data.summary` counts every verdict), and only then affected rows,
407
416
  from the end; `check_affected` drops its `excluded` and `undetermined` samples first and its
408
417
  `cve_ids`, which names every match, last; the other row tools drop rows from the end, and those that
@@ -413,7 +422,8 @@ also the page size whose rows fit `data` whole, counted on the rows whole). `get
413
422
  entries, then every ranking, then `kev_exposure.newest_kev`; then any other list at the top of
414
423
  `data` is cut the same way. The room left once a list fits goes back to the lists emptied before
415
424
  it, as many of their entries as fit (their first, a series its newest). No string is shortened and no value changes (a thinned row
416
- keeps some of its fields, each optional in the outputSchema, so `data` still validates). The result
425
+ keeps some of its fields, each optional in the outputSchema, so `data` still validates;
426
+ a cut adds only `matches_truncated` and `fixed_branches_truncated`). The result
417
427
  then says `data_truncated: true` (`false` on every other success), `truncated` gives each list cut,
418
428
  by its path in `data`, with its `total`, the number `returned` and, where its entries are thinned,
419
429
  `thinned: true`, and the note ends with sentences starting `DATA CUT` that say what was cut, how to
@@ -611,7 +621,7 @@ The npm package (stdio):
611
621
  The HTTP entrypoint, `dist/http.js` (`npm run start:http`), serves the same tools over
612
622
  Streamable HTTP on `POST /mcp`, with a liveness check on `GET /health`. In a container with
613
623
  little memory, start it with V8's heap capped below the container's limit, as EchelonGraph's
614
- hosted deployment does at half of 256 MiB (`node --max-old-space-size=128 --max-semi-space-size=16 dist/http.js`): Node
624
+ hosted deployment does in 256 MiB (`node --max-old-space-size=104 --max-semi-space-size=16 dist/http.js`): Node
615
625
  otherwise sizes its heap from the memory the system reports, which in a container can be the
616
626
  host's, and the container is then killed before V8 collects.
617
627
  It reads the three variables above and:
@@ -657,6 +667,12 @@ shape. Every release is published to npm with a provenance attestation by the pu
657
667
  release workflow, from the commit tagged `v<version>`. Changes per version:
658
668
  [CHANGELOG.md](CHANGELOG.md).
659
669
 
670
+ Since 3.0.2 a success's `outputSchema` admits top-level fields it does not name, so a field added
671
+ in a minor release does not fail a client still holding the tool list it read before. A client
672
+ that shows "does not match the tool's output schema" after an upgrade is validating against the
673
+ schema of the version it listed: reconnect it (Claude Code: `/mcp`, then reconnect `echelongraph`) or restart it. Each tool's `data`
674
+ schema stays closed: a field added inside it needs that reconnect too.
675
+
660
676
  ## Security
661
677
 
662
678
  Report a vulnerability privately to **support@echelongraph.io**, not in a public issue. The
package/dist/fixStatus.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // first.
8
8
  import * as z from "zod";
9
9
  export const FIX_STATES = ["available", "fix_referenced", "fix_merged", "none_confirmed", "unknown"];
10
- export const FIX_STATUS_FIELD = "The API's fix-status rule for the CVE, the one CVE Pulse pages show: state is available (a fix is on record), fix_referenced (a reference tagged Patch, often a source commit, and no release confirmed), fix_merged (an upstream fix merged, not yet in a tagged release), none_confirmed (no fix evidence on record, which is not a finding that no fix exists) or unknown (an input could not be read). basis is the step of the rule that decided it; described_by is the CVE description's sentence naming the fix, solution_by the CVE record's solution sentence naming it (statement) and the CNA that wrote it (cna), stated_in_record that patch_evidence counts a fix the CVE record states with no sentence to quote, listed_in_patch_table that a fixed version or patch row is on record; unread_inputs names any input the API could not read.";
10
+ export const FIX_STATUS_FIELD = "The API's fix-status rule for the CVE, the one CVE Pulse pages show: state is available (a fix is on record), fix_referenced (a reference tagged Patch, often a source commit, and no release confirmed), fix_merged (an upstream fix merged, not yet in a tagged release), none_confirmed (no fix evidence on record, which is not a finding that no fix exists) or unknown (an input could not be read). basis is the step of the rule that decided it; described_by is the CVE description's sentence naming the fix, solution_by the CVE record's solution sentence naming it (statement) and the CNA that wrote it (cna), stated_in_record that patch_evidence counts a fix the CVE record states with no sentence to quote, listed_in_patch_table that a fixed version or patch row is on record; unread_inputs names any input the API could not read. vendor_released (basis vendor_fix_released) is the vendor's own statement that a fix is released where the CVE record names no fixed version: its sentence, verbatim (statement, or null), never a version, so the fixed release is the vendor advisory's to name. solution_by's section is workarounds when the fix sentence is the record's workaround, not its solution.";
11
11
  const opt = (t) => t.nullable().optional();
12
12
  export function fixStatusSchema() {
13
13
  return z
@@ -17,9 +17,11 @@ export function fixStatusSchema() {
17
17
  basis: opt(z.string()),
18
18
  described_by: opt(z.string()),
19
19
  // #3076 (merged from 24dad5eb): the CVE record's own fix sentence, and its CNA.
20
- solution_by: opt(z.looseObject({ statement: opt(z.string()), cna: opt(z.string()) })),
20
+ solution_by: opt(z.looseObject({ statement: opt(z.string()), cna: opt(z.string()), section: opt(z.string()) })),
21
21
  stated_in_record: opt(z.boolean()),
22
22
  listed_in_patch_table: opt(z.boolean()),
23
+ // #3076, third pass: the vendor's released-fix statement with no version (cause G).
24
+ vendor_released: opt(z.looseObject({ statement: opt(z.string()) })),
23
25
  unread_inputs: opt(z.array(z.string())),
24
26
  })
25
27
  .nullable()
@@ -295,7 +295,7 @@ export class Slots {
295
295
  // - V8's heap. In that sandbox (a cgroup v1 on a 16 GB host) Node 22 sized V8's heap from the
296
296
  // host's memory, not the cgroup's (heap_size_limit 8,195 MiB in 256 MiB), so V8 left the
297
297
  // garbage of the requests served (~1 MB each) uncollected until the kernel killed the
298
- // instance. So the image caps V8's old space at 128 MiB (Dockerfile.http), and V8 collects
298
+ // instance. So the image caps V8's old space at 104 MiB (Dockerfile.http; 128 until 3.0.2), and V8 collects
299
299
  // first. On Cloud Run this is not verified: an instance that shows Node only its 256 MiB gets
300
300
  // a default heap near the cap already, and then the cap changes little. Its start-up line says
301
301
  // which (heap_limit_mb; memory_limit_mb, null where Node is told no limit).
@@ -482,7 +482,22 @@ export function uaFamily(ua) {
482
482
  // method name), so the message is never logged. It is matched against the SDK's own fixed
483
483
  // prefixes and only the matching label is logged; anything unrecognised is "other". A
484
484
  // ProtocolError also carries its numeric JSON-RPC code, which is content-free.
485
+ //
486
+ // #2923 (2.7.1 audit, 2026-10-10): 78 of 78 production lines were "other": the list held the legacy
487
+ // transport's messages, not those of the bundled SDK's createMcpHandler (@modelcontextprotocol/server
488
+ // 2.1.0 dist/index.mjs), which reports "Rejected inbound request (<cell>): …" (:1346, :1384, :1458),
489
+ // "Rejected 2025-era request on a modern-only endpoint (<cell>): …" (:1438), an
490
+ // UnsupportedProtocolVersionError, "Unsupported protocol version: <requested>", code -32022 (:1338;
491
+ // src-D-y6h4N7.mjs:3693), and a MissingRequiredClientCapabilityError, "Missing required client
492
+ // capabilities: …" (:1357; src-D-y6h4N7.mjs:3727). The legacy transport's own wording is
493
+ // "Bad Request: Unsupported protocol version: …" (index.mjs:896). All of these lead the list, each
494
+ // with its fixed prefix only (the cell and the rest can name the client's method or version).
485
495
  const HANDLER_ERROR_CLASSES = [
496
+ ["Rejected 2025-era request on a modern-only endpoint (", "rejected_legacy_on_modern_only"],
497
+ ["Rejected inbound request (", "rejected_inbound_request"],
498
+ ["Unsupported protocol version", "unsupported_protocol_version"],
499
+ ["Bad Request: Unsupported protocol version", "unsupported_protocol_version"],
500
+ ["Missing required client capabilities", "missing_client_capability"],
486
501
  ["Not Acceptable", "not_acceptable"],
487
502
  ["Unsupported Media Type", "unsupported_media_type"],
488
503
  ["Method not allowed", "method_not_allowed"],
package/dist/index.js CHANGED
@@ -72,7 +72,7 @@ import { registerVendorAdvisoryTools } from "./tools/vendor_advisories.js";
72
72
  import { registerPrompts } from "./prompts.js";
73
73
  import { registerResources } from "./resources.js";
74
74
  import { answering, answeringNow, cutData, dataText, FIRST_TEXT_BLOCK_BESIDE_A_CUT, FULL_ARG, GET_CVE_WHOLE, NO_DATA_CUT, STRUCTURED_BUDGET, TEXT_BUDGET_DESCRIPTION, wholePage, } from "./textBudget.js";
75
- import { SHODAN_ATTRIBUTION, SHODAN_OWNERSHIP } from "./requiredText.js";
75
+ import { PATCH_AVAILABLE_WITHDRAWN, SHODAN_ATTRIBUTION, SHODAN_OWNERSHIP } from "./requiredText.js";
76
76
  // The package actually running, read from the package.json that ships beside dist/. The MCP
77
77
  // handshake (serverInfo) and the User-Agent both carry its name and version, so the API's
78
78
  // access log can count adoption per version without a code change per release.
@@ -374,7 +374,9 @@ function unexpectedShape(tool, error) {
374
374
  export const checked = (tool, schema, r) => {
375
375
  if (r.isError)
376
376
  return r;
377
- const p = schema.safeParse(r.structuredContent);
377
+ // #3121: the published schema's success branch is open; this server's own answers are held to
378
+ // the closed one.
379
+ const p = (STRICT.get(schema) ?? schema).safeParse(r.structuredContent);
378
380
  return p.success ? r : unexpectedShape(tool, p.error);
379
381
  };
380
382
  // A success: content[0] is the data (cut to DATA_TEXT_BUDGET when it is larger, #2783), content[1]
@@ -869,11 +871,17 @@ function cnaRecordNote(cve, rec) {
869
871
  const by = who ? `its CNA, ${who},` : "its CNA";
870
872
  const date = strAt(rec, "date_public");
871
873
  const fix = strAt(rec, "cna_solution");
874
+ // #3178: a sentence of the record's workarounds is said to be its workaround.
875
+ const workaround = strAt(rec, "cna_solution_section") === "workaround";
872
876
  const parts = [];
873
877
  if (date)
874
878
  parts.push(` date_public is when ${cve} was made public as ${by} states it in the CVE record (datePublic), not the record's publication date.`);
875
- if (fix)
879
+ if (fix && workaround) {
880
+ parts.push(` cna_solution is a fix statement from the CVE record's workarounds (cna_solution_section workaround), not its solutions, written by ${by} and relayed verbatim: it names the fixed release, and is not EchelonGraph's advice.`);
881
+ }
882
+ else if (fix) {
876
883
  parts.push(` cna_solution is the CVE record's own fix statement, written by ${by} and relayed verbatim: it names the fixed release, and is not EchelonGraph's advice.`);
884
+ }
877
885
  return parts.join("");
878
886
  }
879
887
  function remediationSummaryNote(cve, rec) {
@@ -2226,10 +2234,43 @@ const SUCCESS_STATE = "measured: a measurement of what was asked; an exposure co
2226
2234
  const DATA_TRUNCATED_FIELD = "false: data is the answer whole. true: the structured result would pass 30,000 characters of JSON with data whole, so data is cut (DATA CUT): the entries of some of its lists keep only some of their fields, or the lists only some of their entries, truncated gives each, and no string is shortened. The same call with full: true returns data whole.";
2227
2235
  const TRUNCATED_FIELD = "Present when data_truncated is true: each list of data that was cut, by its path in data (a dot between the names of an object and a list inside it), with total, its entries in the answer, returned, the entries data holds, and thinned, true when each of them keeps only some of its fields.";
2228
2236
  const TRUNCATED = z.record(z.string(), z.strictObject({ total: z.number().int().min(0), returned: z.number().int().min(0), thinned: z.literal(true).optional() }));
2237
+ // #3121: the success branch is published open to fields it does not name, and this server holds
2238
+ // its own answers to the closed one (STRICT, read by checked). Through 3.0.0 both branches were
2239
+ // closed (additionalProperties false), and a client validates against the outputSchema it listed,
2240
+ // which the hosted endpoint never tells it to list again (stateless, listChanged false): a Claude
2241
+ // Code session that listed the tools under 2.7.2 failed its first check_sbom call once 3.0.0 went
2242
+ // live, "Structured content does not match the tool's output schema: data must NOT have additional
2243
+ // properties" (ajv's root is "data"; 3.0.0 added data_truncated and truncated to every success),
2244
+ // until it reconnected. So a field added beside them in a minor release would break every such
2245
+ // session too, against the README's semver promise. Opening the success branch keeps such a field
2246
+ // additive for a client holding an older list; the failure branch stays closed (a failure is never
2247
+ // read as data), and data keeps each tool's own schema. The suite validates answers against the
2248
+ // closed branch (test/strict-output.mjs), and checked() does here, so a field this server adds
2249
+ // without naming it in the schema still fails, as it did when the branch was closed.
2250
+ //
2251
+ // 3.0.2: every output schema is built once per process, and its strict copy with it. The hosted
2252
+ // endpoint builds a server for each request (http.ts: createMcpHandler's stateless leg calls
2253
+ // createServer), and through 3.0.1 the tool modules that take envelopeSchema from this file
2254
+ // (check_affected, check_sbom, epss_history, kev_recent, scan_manifest and the three vendor-advisory
2255
+ // tools) built their output schemas in their register functions, so once per request in flight.
2256
+ // 3.0.1's strict copy doubled those: 1,371 KB of heap per server, against 3.0.0's 1,168
2257
+ // (64 servers held at once, node --expose-gc). test/http-memory.test.mjs's "served as they come"
2258
+ // case holds 64 requests in flight beside two 5,000,000-character SBOMs; it failed the 3.0.1
2259
+ // release run at Dockerfile.http's 128 MiB, and passed here only at 128, where 3.0.0 passes at
2260
+ // 112. At 112 V8 aborted 3.5 s in, with 59 servers live and both documents still at their first
2261
+ // batches, so before either answer was cut (a heap snapshot near the limit). Each such module now
2262
+ // keeps its schemas per envelopeSchema, as cve_intel, cve_remediation and get_cwe already did
2263
+ // (schemasOnce): 611 KB a server, and the case passes at --max-old-space-size=80.
2264
+ const STRICT = new WeakMap();
2229
2265
  export function envelopeSchema(o) {
2266
+ const published = envelopeOf(o, z.looseObject);
2267
+ STRICT.set(published, envelopeOf(o, z.strictObject));
2268
+ return published;
2269
+ }
2270
+ function envelopeOf(o, success) {
2230
2271
  const coverage = o.coverage ? o.coverage.nullable() : z.null();
2231
2272
  return z.discriminatedUnion("state", [
2232
- z.strictObject({
2273
+ success({
2233
2274
  state: z.enum(["measured", "not_assessed"]).describe(SUCCESS_STATE),
2234
2275
  measured_at: Instant.nullable().describe("When the underlying observation was made, as the API states it; null when the answer does not say or holds no observation."),
2235
2276
  method: z.string().describe("How the numbers were produced."),
@@ -2275,8 +2316,8 @@ const CPE_CONFIGURATIONS_DESCRIPTION = "NVD's configurations array as NVD sent i
2275
2316
  // templates, internal/cve/fix_statement.go). The last two are the record's own words: get_cve
2276
2317
  // relays the solution's sentence as cna_solution, and the description is in the record.
2277
2318
  // One constant string each (the site's tool-claims check folds only constant strings).
2278
- const PATCH_EVIDENCE_LABELS = ["vendor_patch", "fixed_version", "release_reference", "vendor_advisory_fix", "nvd_patch_reference", "cna_patch_reference", "cna_solution", "description_fix"];
2279
- const PATCH_EVIDENCE_SOURCES = "vendor_patch (a vendor patch on record, from Ubuntu or Red Hat), fixed_version (a fixed version recorded for an affected package, or the fixed bound of an affected version range), release_reference (a reference that EchelonGraph confirmed through the GitHub API is a published release containing the fix, or a pull request or commit that a published release contains), vendor_advisory_fix (a vendor advisory that lists a fix for the CVE under the vendor's own structured category: Red Hat's CSAF vendor_fix, Microsoft's Vendor Fix), nvd_patch_reference (an NVD reference tagged Patch), cna_patch_reference (a reference the CVE's CNA tagged patch in its CVE record), cna_solution (a sentence of the CVE record's solutions, written by its CNA, that names a fixed version; relayed as cna_solution) and description_fix (a sentence of the CVE description that names a fixed version)";
2319
+ const PATCH_EVIDENCE_LABELS = ["vendor_patch", "fixed_version", "release_reference", "vendor_advisory_fix", "nvd_patch_reference", "cna_patch_reference", "cna_solution", "description_fix", "vendor_fix_released"];
2320
+ const PATCH_EVIDENCE_SOURCES = "vendor_patch (a vendor patch on record, from Ubuntu or Red Hat), fixed_version (a fixed version recorded for an affected package, or the fixed bound of an affected version range), release_reference (a reference that EchelonGraph confirmed through the GitHub API is a published release containing the fix, or a pull request or commit that a published release contains), vendor_advisory_fix (a vendor advisory that lists a fix for the CVE under the vendor's own structured category: Red Hat's CSAF vendor_fix, Microsoft's Vendor Fix), nvd_patch_reference (an NVD reference tagged Patch), cna_patch_reference (a reference the CVE's CNA tagged patch in its CVE record), cna_solution (a sentence of the CVE record's solutions or workarounds, written by its CNA, that names a fixed version; relayed as cna_solution), description_fix (a sentence of the CVE description that names a fixed version) and vendor_fix_released (the vendor states, in the CVE record, that a fix is released and the record names no fixed version; check the vendor's advisory)";
2280
2321
  // #3070, #3076: what the CVE record's CNA wrote, as GET /cves/:id serves it (core-backend
2281
2322
  // cna_record.go). One constant string each.
2282
2323
  const CVE_CNA_ABSENT = "Absent where EchelonGraph has not read the CVE record's CNA container yet, where the record has none, for a REJECTED CVE, and from an API older than the field.";
@@ -2284,12 +2325,26 @@ const DATE_PUBLIC_FIELD = "When the vulnerability was made public, as the CVE re
2284
2325
  CVE_CNA_ABSENT;
2285
2326
  const DATE_PUBLIC_SOURCE_FIELD = "Whose date date_public is: cna, the CVE record's CNA. Absent whenever date_public is.";
2286
2327
  const CNA_FIELD = "The CNA that wrote the CVE record: short_name as the record gives it, and name, the CVE Program's name for that CNA (the short name where its list does not hold it). " + CVE_CNA_ABSENT;
2287
- const CNA_SOLUTION_FIELD = "The sentence of the CVE record's solutions, written by its CNA, that names a released fix, verbatim: what makes cna_solution patch evidence. It is the CNA's text, not EchelonGraph's advice. Absent where the record's solutions name no fixed version, as well as where the field itself is: " +
2328
+ const CNA_SOLUTION_FIELD = "The sentence of the CVE record's solutions or workarounds, written by its CNA, that names a released fix, verbatim: what makes cna_solution patch evidence. It is the CNA's text, not EchelonGraph's advice. cna_solution_section says which of the two sections it is. Absent where the record's solutions name no fixed version, as well as where the field itself is: " +
2288
2329
  CVE_CNA_ABSENT;
2330
+ // #3178: which section cna_solution is. MCP relays a field its outputSchema does not describe without saying what it
2331
+ // is, so the section is described here, beside the sentence.
2332
+ const CNA_SOLUTION_SECTION_FIELD = "Which section of the CVE record cna_solution was read from: solution (the record's solutions) or workaround (its workarounds, read only where its solutions name no fix: a temporary measure the CNA lists beside its fix). A workaround's sentence is the record's workaround, never its solution. Present whenever cna_solution is; absent from an API older than the field.";
2289
2333
  // #2842: what the descriptions say; the sources, and that patch_available once true is never set
2290
2334
  // back, are the outputSchema's (PATCH_AVAILABLE_FIELD, PATCH_EVIDENCE_FIELD).
2291
- const PATCH_AVAILABLE_DESCRIPTION = "patch_available is true when EchelonGraph holds evidence of a fix for the CVE, and patch_evidence names its sources, strongest first. patch_available false means no fix evidence is on record, which is not a finding that no fix exists.";
2292
- const PATCH_AVAILABLE_FIELD = "true: EchelonGraph holds evidence of a fix for the CVE, its sources named in patch_evidence. false: no fix evidence is on record, which is not a finding that no fix exists. Once true it is never set back to false. An answer with no patch_evidence (an API older than that field) does not name the evidence, and a false there is not a finding that no fix exists either.";
2335
+ const PATCH_AVAILABLE_DESCRIPTION = "patch_available is true when EchelonGraph holds, or once held, fix evidence for the CVE; patch_evidence is what it holds now, strongest first. patch_available false means no fix evidence is on record, which is not a finding that no fix exists.";
2336
+ // #3138: true says evidence was held at some point, not that it is held now: the sources held now
2337
+ // are patch_evidence's, and true beside an empty one is a withdrawal (requiredText.ts
2338
+ // PATCH_AVAILABLE_WITHDRAWN, pinned there). #3138's review: round 1 changed the field text only,
2339
+ // and this sentence, in get_cve's and search_cves' descriptions, still said true means
2340
+ // EchelonGraph holds evidence; it now says "holds, or once held", in 9 more characters than it
2341
+ // had, as get_cve's description had 9 left under #2924's headroom of 100 below the 2,048 cut
2342
+ // (get_cve 1,948 characters, search_cves 1,807; test/prompts-resources.test.mjs holds the
2343
+ // headroom, test/client-cut.mjs (b) the cut, and marketing-site lib/mcpToolClaims.test.ts the
2344
+ // word "for" in get_cve's description, which its /pulse/mcp row uses).
2345
+ const PATCH_AVAILABLE_FIELD = "true: EchelonGraph holds, or once held, evidence of a fix for the CVE; patch_evidence names the sources it holds now. " +
2346
+ PATCH_AVAILABLE_WITHDRAWN +
2347
+ " false: no fix evidence is on record, which is not a finding that no fix exists. An answer with no patch_evidence (an API older than that field) does not name the evidence, and a false there is not a finding that no fix exists either.";
2293
2348
  const PATCH_EVIDENCE_FIELD = "The sources of the fix evidence EchelonGraph holds for the CVE, strongest first: " +
2294
2349
  PATCH_EVIDENCE_SOURCES +
2295
2350
  ". Empty where none was found when EchelonGraph last computed it. Absent (an API older than the field): the answer does not name the evidence.";
@@ -2340,6 +2395,8 @@ const CVERecord = z.looseObject({
2340
2395
  date_public_source: opt(z.string()).describe(DATE_PUBLIC_SOURCE_FIELD),
2341
2396
  cna: opt(z.looseObject({ short_name: opt(z.string()), name: opt(z.string()) })).describe(CNA_FIELD),
2342
2397
  cna_solution: opt(z.string()).describe(CNA_SOLUTION_FIELD),
2398
+ // #3178: a section the API adds later is relayed, never a failed call (as patch_evidence's labels).
2399
+ cna_solution_section: opt(z.union([z.enum(["solution", "workaround"]), z.string()])).describe(CNA_SOLUTION_SECTION_FIELD),
2343
2400
  references: z.unknown().optional(),
2344
2401
  // #2720: NVD's configurations, losslessly, beside the flat list (see cpeConfigurationsNote).
2345
2402
  cpe_match: z.unknown().optional().describe(CPE_MATCH_DESCRIPTION),
package/dist/prompts.js CHANGED
@@ -49,6 +49,12 @@ const text = (t) => ({ messages: [{ role: "user", content: { type: "text", text:
49
49
  // gives them as the vendor's, untested, and a not_parsed or none_in_source state as no finding that
50
50
  // the vendor lists no fix. vendor_advisories_for_cve rows carry remediation_kinds too, so step 4
51
51
  // sends the model to get_vendor_advisory where a row lists a workaround or a mitigation.
52
+ //
53
+ // Its exploit lines (#3039): cve_intel's exploits hold Nuclei detection templates beside exploit
54
+ // code, and a template checks whether a system is affected; it is not necessarily exploit code
55
+ // (core-backend egkev.ExploitCodeKinds, #3038). Until #3039 step 2 called the whole list "public
56
+ // exploit code" and the template asked for "public exploit code on record", so an agent reported
57
+ // exploit code for the ~2,800 CVEs EchelonGraph holds only a template for.
52
58
  // #3013's review: the Patch line has the model state a vendor's workaround or mitigation step,
53
59
  // whose wording is each item's text, and cve_remediation's cut can leave that text out of data
54
60
  // (its second thinning level) or shorten it in the text (its TEXT CUT); no string is shortened in
@@ -58,14 +64,14 @@ export function triageCveText(cve) {
58
64
  return [
59
65
  `Triage ${cve} using the EchelonGraph MCP tools. Call them in this order, each with cve_id ${cve}:`,
60
66
  "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, and, where the record carries it, kev_required_action, CISA's own text.",
61
- "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.",
67
+ "2. cve_intel: weaknesses (cwes), public exploit code and detection templates (exploits, exploits_total and exploits_by_kind; a nuclei_template row is a Nuclei detection template, which checks whether a system is affected and is not necessarily exploit code; 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.",
62
68
  "3. cve_remediation: how it is fixed, as its sources state it: fixed_branches (each affected package's ranges with their fixes), vendor_remediations (each vendor advisory naming the CVE, with its vendor, vendor_advisory_id, remediation_state and remediation_kinds, each kind the vendor's own category) and fix_references (the record's references tagged Patch or Mitigation).",
63
69
  "4. vendor_advisories_for_cve: the vendor advisories that name it, with remediation_kinds and remediation_state where a row carries them. Call get_vendor_advisory for one whose remediation you need in full, such as one whose remediation_kinds include workaround or mitigation.",
64
70
  "5. 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.",
65
71
  "6. 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.",
66
72
  ENVELOPE_RULES,
67
73
  "Then answer with this template, one line each, naming the tool and the measured_at behind every line:",
68
- "- Exploited? CISA-KEV listing and its date, known ransomware use, and public exploit code on record.",
74
+ "- Exploited? CISA-KEV listing and its date, known ransomware use, and public exploit code on record (Metasploit modules, Exploit-DB entries, GitHub or vendor proofs of concept); give Nuclei detection templates apart, as detection checks, never as exploit code.",
69
75
  "- Likelihood: EPSS score and percentile, and how it has moved.",
70
76
  "- Reachable? exposure_state and the count on record, or not assessed.",
71
77
  `- Patch: fixed versions, vendor remediation and fix references; 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. From cve_remediation, give each vendor_remediations advisory by vendor and vendor_advisory_id with its remediation_kinds, a workaround or mitigation as the step that vendor states, which EchelonGraph has not tested and which is not a fix, and its fix_references; a remediation_state of not_parsed or none_in_source is not a finding that the vendor lists no fix. ${VENDOR_STEP_ELSEWHERE}`,
@@ -116,3 +116,18 @@ export const REQUIRED_IN_HEAD = {
116
116
  get_vendor_advisory: ["withdrawn (true: the vendor rescinded it", "which are not active vulnerabilities"],
117
117
  search_vendor_advisories: ["never exactly 1,000", "The query is sent in a request header, never in the URL.", "which are not active vulnerabilities"],
118
118
  };
119
+ // #3138: sentences a tool's outputSchema must carry, by the path of the field in its success
120
+ // branch (a dot between an object and a field inside it, [] for each entry of a list).
121
+ // test/client-cut.mjs (e) holds every one to tools/list's outputSchema, with a control in
122
+ // tools.test.mjs. Through 3.0.0 patch_available's field text said true means "EchelonGraph holds
123
+ // evidence of a fix for the CVE, its sources named in patch_evidence", while #2861's rule never sets
124
+ // it back to false, so every withdrawal of evidence leaves true beside patch_evidence [] (hosted
125
+ // get_cve CVE-2026-1281, 2026-10-11: true, [], and remediation.fix_status none_confirmed), and an
126
+ // agent reading the field through its schema reported a fix on record where EchelonGraph held
127
+ // none. The sentence says what that pair means and which field is the current reading; it names a
128
+ // field, not a tool (test/description-policy.test.mjs: no schema text names another tool).
129
+ export const PATCH_AVAILABLE_WITHDRAWN = "true beside an empty patch_evidence means evidence of a fix once held has since been withdrawn (patch_available is never set back to false), and fix_status (remediation.fix_status in the CVE's record) is the current reading, not patch_available.";
130
+ export const REQUIRED_IN_OUTPUT_SCHEMA = {
131
+ get_cve: { "data.patch_available": [PATCH_AVAILABLE_WITHDRAWN] },
132
+ search_cves: { "data.cves[].patch_available": [PATCH_AVAILABLE_WITHDRAWN] },
133
+ };