echelongraph-mcp 2.6.7 → 2.7.0

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,153 @@ 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.7.0 — 2026-10-06
10
+
11
+ - New tool scan_manifest (#2835): a project's lockfiles or pinned manifests checked against the
12
+ advisory corpus, one verdict per dependency, in one call. Pass `files` (1 to 20 of `filename` and
13
+ `content`, up to 5,000,000 characters in all). This server reads them into purls and sends only
14
+ the purls, through the same batch route, batches of 200, 50-second budget, `Retry-After` waits
15
+ and cancellation as check_sbom (that loop now lives in `src/tools/match_batch.ts`, shared by both
16
+ tools, check_sbom's behaviour unchanged). Read: requirements.txt (`==` and `===` pins only),
17
+ go.mod (`require` with `replace` and `exclude` applied), package-lock.json and
18
+ npm-shrinkwrap.json (lockfileVersion 1 to 3), Cargo.lock (v1 to v4), Gemfile.lock,
19
+ composer.lock, poetry.lock and gradle.lockfile, each tested on files written by the ecosystem's
20
+ own tool. Every entry not sent is listed in `data.not_checked` with its file, line and reason
21
+ (`version_unpinned`, `version_unresolved`, `local_path`, `vcs_source`, `unsupported_line`) and is
22
+ not clean: `django>=4` is never sent as `pkg:pypi/django@4`. package.json, pyproject.toml,
23
+ Pipfile, Gemfile, Cargo.toml, composer.json and build.gradle are refused with the lockfile to
24
+ pass; go.sum is refused, as it lists versions the build does not select. No fixed version is
25
+ derived: each match's `fixed_in` is the API's. TOML is read by a small reader in this package
26
+ (`src/tools/manifests/toml.ts`), so no runtime dependency is added. 16 tools.
27
+ - The hosted endpoint admits a `tools/call` of scan_manifest up to 6 MiB, as it does check_sbom
28
+ (`LARGE_BODY_TARGETS` now lists names per method); the files are read in memory and neither
29
+ logged nor kept. The 413 and 503 texts name no single tool.
30
+ - check_sbom's outputSchema names `kev_listed` and `epss_score` on each match, which the API
31
+ already sent.
32
+ - New tool cve_remediation (#2841): how one CVE is fixed, as its sources state it, in one answer:
33
+ CISA's KEV text (its required action is directed at US federal civilian agencies), every
34
+ affected range with its fix, each vendor advisory's remediation categorised by the vendor's own
35
+ category (vendor_fix, workaround, mitigation, no_fix_planned, none_available, other), the
36
+ references NVD tagged Patch or Mitigation, and patches. Nothing is generated; every text is its
37
+ source's, untested by EchelonGraph. none_in_source, not_parsed and an empty list are not a
38
+ finding that no fix exists; a REJECTED CVE answers not_assessed. 15 tools. Its text for
39
+ CVE-2021-44228 on production's data is 20,644 characters (assembled before the route was
40
+ deployed; re-measure live).
41
+ - get_cve relays remediation, a capped summary (at most 1,500 characters of JSON) of the same.
42
+ - vendor_advisories_for_cve rows carry remediation_kinds and remediation_state, and
43
+ get_vendor_advisory carries remediations, each item with its kind, verbatim text and URL
44
+ (#2840). Red Hat, Microsoft and Palo Alto are parsed; other vendors are not_parsed.
45
+ - vendor_advisories_for_cve, get_vendor_advisory and search_vendor_advisories now say when an
46
+ advisory names a CVE ID whose CVE record was rejected (withdrawn) by its numbering authority
47
+ (#2865). Through 2.6.8 such an advisory read as a normal one when its vendor had not withdrawn
48
+ it: measured in production on 2026-10-05, 767 single-CVE advisories that their vendors had not
49
+ withdrawn named a CVE that EchelonGraph holds as REJECTED (GitHub 740, Microsoft 25, Red Hat 2),
50
+ and `vendor_advisories_for_cve` for CVE-2026-7936 returned Microsoft's "Chromium: CVE-2026-7936
51
+ Object lifecycle issue in V8" with nothing saying the CVE was rejected. core-backend now serves
52
+ `rejected_cve_ids` on each advisory: the CVE IDs it names that were rejected, `[]` when none
53
+ was, null when that could not be checked. vendor_advisories_for_cve also gets `cve_rejected`
54
+ for the CVE asked for. The vendor's own `withdrawn` is unchanged: it still says only whether the
55
+ vendor rescinded its advisory. The note opens with `CVE REJECTED` for a rejected CVE, names each
56
+ advisory that names a rejected CVE ID, and says when the state could not be checked. The three
57
+ descriptions and the outputSchema describe both fields. When a row is cut to fit the text, it
58
+ keeps `rejected_cve_ids` at both cut levels.
59
+ - GitHub advisories whose text is a CVE rejection notice ("Rejected reason: …") are now served
60
+ with `withdrawn: true` (#2864; core-backend). search_vendor_advisories leaves them out, as it
61
+ does every withdrawn advisory.
62
+ - get_cve relays CISA's own KEV text for a KEV-listed CVE (#2839; core-backend):
63
+ `kev_required_action` (CISA's requiredAction), `kev_short_description` and `kev_notes_urls`
64
+ (the https URLs in CISA's notes, at most 10), alongside `kev_due_date`, which the API already
65
+ served. Through 2.6.8 none of the three was stored. The note says the text is CISA's, under BOD
66
+ 22-01, and not EchelonGraph's advice. triage_cve quotes the required action, attributed to CISA.
67
+ - `patch_evidence` has two more labels (#2889, #2890; core-backend):
68
+ - `release_reference`: EchelonGraph's GitHub release, PR or commit reading found a published,
69
+ non-draft release that contains the fix.
70
+ - `cna_patch_reference`: the CVE's numbering authority tagged one of its references `patch` in
71
+ its CVE record.
72
+
73
+ Measured in production on 2026-10-05, 6,395 CVEs held one of these and read
74
+ `patch_available: false`. Among them were CVE-2026-97724, whose fix release worklets-0.12.2
75
+ EchelonGraph had read, and CVE-2026-69486, which Microsoft tagged `patch`. A reference that is
76
+ only marked from page wording, such as an advisory page that mentions a patch, is still not
77
+ counted as evidence.
78
+ - epss_history now says where its record becomes complete (#2867; core-backend). Through 2.6.8
79
+ it presented 2026-05-18 onward as a complete record. Measured on 2026-10-05, every FIRST score
80
+ date in that span had been applied. But the old write path dropped many per-CVE changes within
81
+ a night's refresh: on 2026-09-21, 202 of 300 sampled CVEs whose EPSS changed got no history row.
82
+ The answer now carries:
83
+ - `complete_since`: 2026-10-04 23:14:54Z, the first refresh of the new write path;
84
+ - `series_complete`;
85
+ - a `score_date` on each point;
86
+ - a coverage block of days held, partial and missing. It comes from a new per-refresh run log,
87
+ so it is exact from the deploy onward.
88
+
89
+ The note names the incomplete stretch. It never calls the record complete without
90
+ `complete_since`.
91
+ - check_sbom follows a Debian or Alpine purl's `upstream=` qualifier to the source package it was
92
+ built from (#2836; core-backend). Advisories are keyed by source package. Measured on
93
+ 2026-10-05, Debian 12 holds 0 rows for `libssl3` and 262 for `openssl`. Through 2.6.8,
94
+ `pkg:deb/debian/libssl3@…?upstream=openssl` and `pkg:apk/alpine/libcrypto3@…` came back
95
+ not_assessed, while the same version under `openssl` was affected. Each row now says
96
+ `matched_package` and `matched_via` (`purl_name`, `upstream` or `coordinates`), and a match found
97
+ through `upstream=` says so in its `match_reason`. The binary name is never silently tried
98
+ instead.
99
+ - check_affected and check_sbom registry matches carry `fixed_in` (#2834; core-backend): the fixed
100
+ bound of the interval that matched the version asked about. For example, log4j-core 2.14.1 →
101
+ 2.15.0 and 2.11.0 → 2.12.2. It is `null` with a `fixed_in_reason` when that interval has no fix
102
+ (it ends at last_affected, or has no upper bound). CPE matches carry no `fixed_in`.
103
+ Every cut level of check_affected's text keeps `fixed_in`, so a registry list near the cap of
104
+ 200 no longer fits whole: on django's rows at 200 matches beside 50 undetermined, the text keeps
105
+ 185 (29,863 characters) and the note says the last 15 are in `structuredContent.data` only. Its
106
+ description said every match stays in the text; it now says that of CPE matches, and that a
107
+ registry list near the cap can leave its last matches out.
108
+ - The server instructions and every tool description now fit in 2,048 characters (#2842). Claude
109
+ Code cuts each MCP tool description and each server's instructions at 2,048 characters
110
+ (`CLIENT_DESCRIPTION_CUT`, the strictest limit any client documents). On this release's text before the cut, 13 of 14
111
+ descriptions and the instructions (2,521) were over it, so a model in Claude Code read them cut.
112
+ `exposure_radar` was 11,807 characters. Detail moved into the outputSchema description of the
113
+ field it describes. A test fails if any text grows past the limit, or if a required sentence
114
+ (the Shodan attribution, the hosted-endpoint disclosure, each tool's load-bearing rule) falls
115
+ after it.
116
+ - exposure_radar relays an observation window for kev_exposure (#2438; core-backend): the
117
+ Shodan banner times of the oldest and newest service×CVE pairs it counts, as
118
+ `kev_exposure.window`. That is passed through only when every counted pair has such a time.
119
+ Until then the note says how many have none. core-backend stores Shodan's banner `timestamp` as
120
+ `banner_seen_at`, and never our own write time; existing rows get one when a search matches
121
+ them again. `state` stays not_assessed and `measured_at` stays null: a window is a span of
122
+ observations made at different times, not one observation time.
123
+ - New prompt `workload_triage` (#2846). It ranks an agent's own pods, images or CI containers by
124
+ CISA-KEV status, caller-asserted exposure and EPSS. The agent lists its workloads and makes an
125
+ SBOM per image digest with its own tools, then sends check_sbom only purls: no workload, image,
126
+ registry or exposure label goes to EchelonGraph. Exposure is labelled caller-asserted, and
127
+ unknown is never treated as not exposed. check_sbom's description says its input for container
128
+ images is an SBOM of each image, or its purls.
129
+ - triage_cve, sbom_review and am_i_affected read the 2.7.0 fields (#2834, #2841). sbom_review and
130
+ am_i_affected give a registry match's `fixed_in` first: a null `fixed_in` means no fixed version
131
+ for that version's range, not that no fix exists. triage_cve and sbom_review call
132
+ cve_remediation, and relay a vendor's stated workaround or mitigation as that vendor's, untested
133
+ by EchelonGraph and not a fix.
134
+
135
+ ## 2.6.8 — 2026-10-05
136
+
137
+ - get_cve and search_cves now say what `patch_available` means, and describe `patch_evidence`
138
+ (#2861). Through 2.6.7 neither tool described `patch_available`, and core-backend set it only
139
+ from Ubuntu and Red Hat patch records, so a `false` read as "no patch": measured in production on
140
+ 2026-10-05, 101,725 CVEs read `false` while EchelonGraph held evidence of a fix for them, 48,145
141
+ with a package `fixed_version` and 71,705 with an NVD reference tagged "Patch" (CVE-2017-5651,
142
+ Apache Tomcat, among them, with two package rows carrying a `fixed_version`). core-backend now
143
+ sets `patch_available` true whenever it holds evidence of a fix, and serves `patch_evidence`, the
144
+ list of its sources, strongest first: `vendor_patch` (a vendor patch on record, from Ubuntu or
145
+ Red Hat), `fixed_version` (a package fix version, or the fixed bound of an affected version
146
+ range) and `nvd_patch_reference` (an NVD reference tagged Patch). Both descriptions, and the
147
+ outputSchema descriptions of both fields, now state: `patch_available` is true when EchelonGraph
148
+ holds evidence of a fix; `false` means no fix evidence is on record, which is not a finding that
149
+ no fix exists; once true it is never set back to false, so it can be true beside an empty
150
+ `patch_evidence`; and an answer with no `patch_evidence` (an API older than that field) does not
151
+ name the evidence. Both fields are relayed as the API sends them; a `patch_evidence` label the API
152
+ adds later is relayed, not a failed call.
153
+ - search_cves: a page cut to fit the text keeps `patch_evidence` beside `patch_available` in each
154
+ row at the first cut level.
155
+
9
156
  ## 2.6.7 — 2026-10-05
10
157
 
11
158
  - Tool descriptions now state facts only: no reporting instructions to the model and no references
package/README.md CHANGED
@@ -10,9 +10,9 @@ internet-facing services (distinct ip:port) EchelonGraph's KEV-exposure radar ha
10
10
  running a version that maps to the CVE. Exposure counts are derived from Shodan data.
11
11
  Shodan data is owned by Shodan, which holds its copyright (© Shodan).
12
12
 
13
- Its 14 tools look up one CVE (record, exploit code and fixed versions, EPSS history, vendor
14
- advisories, exposure footprint), list CISA's newest KEV additions, check whether a product or
15
- package version is affected, check an SBOM, and read a CWE and its CVEs. Every result says how
13
+ Its 16 tools look up one CVE (record, exploit code and fixed versions, how it is fixed as its
14
+ sources state it, EPSS history, vendor advisories, exposure footprint), list CISA's newest KEV additions, check whether a product or
15
+ package version is affected, check an SBOM or a project's lockfiles, and read a CWE and its CVEs. Every result says how
16
16
  it was measured (`state`, `measured_at`, `method`, `coverage`, `freshness`, `notes`), so a model
17
17
  cannot mistake an outage or an unassessed lookup for an all-clear.
18
18
 
@@ -97,7 +97,7 @@ are claude.ai's (see "Remote (no install)" below).
97
97
  Claude → Quit Claude (⌘Q); on Windows right-click the Claude icon in the system tray and choose
98
98
  Quit. The first start can take longer while `npx` downloads the package.
99
99
  5. **Check that it worked.** Settings → Developer lists `echelongraph` as running, and the tools
100
- menu in the chat box lists EchelonGraph's 14 tools. Then ask the question at the top of this
100
+ menu in the chat box lists EchelonGraph's 16 tools. Then ask the question at the top of this
101
101
  section.
102
102
 
103
103
  #### Troubleshooting Claude Desktop
@@ -181,7 +181,7 @@ and most others) take the same block as Cursor above.
181
181
 
182
182
  ### Remote (no install)
183
183
 
184
- The same 14 tools, four prompts and three resources are served over Streamable HTTP at:
184
+ The same 16 tools, four prompts and three resources are served over Streamable HTTP at:
185
185
 
186
186
  ```text
187
187
  https://mcp.echelongraph.io/mcp
@@ -236,13 +236,15 @@ The example questions are ones a client can answer with that tool alone.
236
236
  | `cve_exposure` | Internet exposure for one CVE | Internet-exposure footprint for a CVE: exposed service count (distinct ip:port, the `exposed_hosts` field) + country/product breakdown, from the KEV-exposure radar. | *How many exposed services does EchelonGraph's radar have on record for CVE-2023-44487?* |
237
237
  | `exposure_radar` | Exposure radar totals | Aggregate totals across the exposure radars: services running CISA-KEV CVEs; unauthenticated data stores and observability UIs, found through Shodan (LeakIX when Shodan query credits run low) and then confirmed by EchelonGraph's own identified check, which is not a pure read (on Redis it names its client; on ClickHouse its query lands in the server's query log); leaked credentials; shadow AI; and MCP servers found in EchelonGraph's own Certificate Transparency feed, by RFC 9728 verdict, protocol era and transport. Every number is labelled by what it counts, and a field the tool cannot label is left out and named. | *How many unauthenticated data stores has EchelonGraph's radar confirmed?* |
238
238
  | `kev_recent` | Recent CISA KEV additions | The CVEs CISA has added to its Known Exploited Vulnerabilities catalog, newest first (`kev_added_date`), from EchelonGraph's copy of the catalog, polled from CISA every 5 minutes: due date, vendor, product, known ransomware use, EchelonGraph's severity, CVSS, EPSS and `eg_kev_tier`, and `our_first_seen_kev`. Filter by date range, ransomware and vendor; page with `limit` and `next_cursor`. Dated by `last_successful_fetch_at`, our last successful fetch of CISA's feed; the filters travel as request headers, never in the URL. | *Which CVEs has CISA added to KEV since 1 September, and which have known ransomware use?* |
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?* |
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; `complete_since`, from when every change is a point (before it the record misses changes, so a missing point there does not mean unchanged, and the series is never called complete); `coverage`, which FIRST score dates the record holds and which it misses (`days_missing`, `days_partial`); 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?* |
240
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?* |
241
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.* |
242
+ | `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.* |
242
243
  | `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?* |
244
+ | `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 tagged Patch or Mitigation), `patches`, `coverage` and `failed_sections`. `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?* |
243
245
  | `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?* |
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?* |
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.* |
246
+ | `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?* |
247
+ | `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`, and `remediations`, each remediation the vendor lists with its `kind` (the vendor's own category), verbatim text and URL, with `remediation_state`. | *Show one of those advisories in full: affected products, remediation and references.* |
246
248
  | `search_vendor_advisories` | Search vendor advisories | Search vendor advisories by text (title, description, vendor, advisory ID, products, CVE IDs), vendor, severity and whether they name a CVE. The search text is sent in a request header, never in the URL. | *Search vendor advisories for FortiOS, critical severity only.* |
247
249
 
248
250
  The three vendor-advisory tools relay `vendor_published_at` (the vendor's date), `our_first_seen_at`
@@ -317,6 +319,22 @@ Both tools relay the API's JSON as it was sent, and the note says what the score
317
319
  | `score_assessed: false` | **NOT YET SCORED**, tagged "(score_assessed: false)": report the CVE as not yet scored, never as a zero or low score. Each of `echelongraph_score`, `echelongraph_severity` and `echelongraph_risk` it carries is named as a placeholder. A rejected record is **NOT SCORED** instead. |
318
320
  | no `score_assessed` field | The answer does not say whether the CVE was scored (an API older than the field), and a zero `echelongraph_score` in it is not a rating. |
319
321
 
322
+ ### What `patch_available` says
323
+
324
+ `patch_available` is `true` when EchelonGraph holds evidence of a fix for the CVE, and
325
+ `patch_evidence` lists the sources of that evidence, strongest first: `vendor_patch` (a vendor
326
+ patch on record, from Ubuntu or Red Hat), `fixed_version` (a fixed version recorded for an
327
+ affected package, or the fixed bound of an affected version range), `release_reference` (a
328
+ reference that EchelonGraph confirmed through the GitHub API is a published release containing
329
+ the fix, or a pull request or commit that a published release contains), `nvd_patch_reference` (an
330
+ NVD reference tagged Patch) and `cna_patch_reference` (a reference the CVE's CNA tagged `patch` in
331
+ its CVE record). A reference page that only mentions a patch, and a CVE's presence in CISA's KEV
332
+ catalog, are not evidence. `false` means no fix evidence is on record, which is **not** a
333
+ finding that no fix exists. Once `true`, `patch_available` is not set back to `false`, so it can be
334
+ `true` beside an empty `patch_evidence`. An answer with no `patch_evidence` (an API older than that
335
+ field) does not name the evidence, and a `false` there is not a finding that no fix exists either.
336
+ Both tools relay the two fields as the API sends them.
337
+
320
338
  ### How `cve_exposure` counts
321
339
 
322
340
  Every 12 hours, when Shodan query credits allow, the KEV-exposure radar runs one Shodan query
@@ -392,6 +410,7 @@ maps to a CISA-KEV-listed CVE (see "How `cve_exposure` counts").
392
410
  | `kev_exposure.newest_kev[].exposed_hosts` | Only on a row whose `exposure_state` is `exposed` (or `measured_zero`): distinct ip:port services on record with that CVE. |
393
411
  | `kev_exposure.newest_kev[].cvss_v3_score`, `kev_exposure.newest_kev[].epss_score` | Scores, not counts: the CVE record's CVSS v3 base score and EPSS probability (0 to 1), absent when the record has none. |
394
412
  | `kev_exposure.last_run_at` | A timestamp, not a count: when the radar last completed a check, a cycle in which its Shodan search answered at least one query (others may have failed) and its list of services due for a re-check was read. A cycle that skipped the search for want of Shodan query credits, or whose reads failed, does not move it. |
413
+ | `kev_exposure.window.from`, `kev_exposure.window.to` | Timestamps, not counts: when Shodan collected the banners of the oldest and the newest of the service×CVE pairs counted, Shodan's own times, not when EchelonGraph stored or re-checked a pair; a span of observations made at different times. Relayed only when every counted pair has such a time; otherwise the note says how many do not. |
395
414
 
396
415
  A `newest_kev` row's `exposure_state` says whether its count is a measurement:
397
416
 
@@ -640,12 +659,15 @@ most kept first (`search_cves` keeps `cve_id`, `severity`, `cvss_v3_score`, `ech
640
659
  among others), with every long string ending in "…". A list beside the rows is cut before any
641
660
  row field the first level keeps, and before any row leaves the text: `check_affected`'s
642
661
  `excluded` and `undetermined` samples keep their first 10 entries, each its `cve_id` and
643
- `reason`, and its `cve_ids` (each match's `cve_id`, in order) its first 10, so that every match
644
- stays in the text, each with at least `cve_id`, `kev_listed`, `ransomware`, `epss_score`,
645
- `effective_score` and `score_assessed`, and `check_sbom`'s `not_sent_purls` keeps its first 10, the note saying from
662
+ `reason`, and its `cve_ids` (each match's `cve_id`, in order) its first 10, so that every CPE
663
+ match stays in the text, each with at least `cve_id`, `kev_listed`, `ransomware`, `epss_score`,
664
+ `effective_score` and `score_assessed` (and `fixed_in` on a registry match; a registry list near
665
+ the cap of 200 can leave its last matches out, 15 of 200 beside 50 undetermined, and the note
666
+ says how many), and `check_sbom`'s `not_sent_purls` keeps its first 10, the note saying from
646
667
  which position of the input the purls not sent run, and in what order the input's purls are
647
- counted, so that a second call can send them without reading the list. When even the leanest cut is too long, rows are left out of the text
648
- (`check_sbom` leaves out the `not_affected` rows first, then the `not_assessed` ones), and the
668
+ counted, so that a second call can send them without reading the list. `scan_manifest` cuts its
669
+ rows as `check_sbom` does, and its `not_checked` list keeps its first 20 entries. When even the leanest cut is too long, rows are left out of the text
670
+ (`check_sbom` and `scan_manifest` leave out the `not_affected` rows first, then the `not_assessed` ones), and the
649
671
  note says how to read them: the `offset` to call next, or a smaller `limit`. Any other answer
650
672
  keeps the first entries of each of its lists (`get_cve`'s `cpe_match` and `references`). The note
651
673
  then ends with one sentence starting `TEXT CUT` that says what the first text block leaves out
@@ -718,7 +740,8 @@ a note says so. Per tool:
718
740
  dated measurement. `mcp_servers` dates its verdicts only by a window, `mcp_servers.window.from`
719
741
  to `mcp_servers.window.to`, over checks made at different times, which is not one observation
720
742
  time; so, since #2438, do `exposed_databases.window`, `leaked_credentials.window` and
721
- `shadow_ai.confirmed_exposed.window`. `kev_exposure` gives no observation time at all. Its numbers keep the labels above. `freshness` holds each radar's `last_run_at` where the
743
+ `shadow_ai.confirmed_exposed.window`, and `kev_exposure.window`, from Shodan's banner
744
+ timestamps, where the API serves it. Its numbers keep the labels above. `freshness` holds each radar's `last_run_at` where the
722
745
  API serves one, for `shadow_ai` also `running`, and for `mcp_servers` also `enabled`.
723
746
  - `check_affected` is `measured` only when the answer says `assessed` true, and `not_assessed`
724
747
  when it says false or does not say. Its `measured_at` and `freshness` are `null`: a match answer
@@ -752,10 +775,17 @@ as the SDK builds it: the 2025-era versions are reached through `initialize`. Bo
752
775
  carry the package's name and version (`serverInfo`), and every API request carries them in its
753
776
  User-Agent, `echelongraph-mcp/<version>`.
754
777
 
755
- Both handshakes also carry the server instructions: the data is public; what `state`,
756
- `measured_at` and `freshness` mean, and that the last text block repeats them; that exposure
757
- numbers are aggregate counts of ip:port services, not an internet-wide census; and that Shodan
758
- data is Shodan's.
778
+ Both handshakes also carry the server instructions: the data is public; that Shodan data is
779
+ Shodan's; what `state`, `measured_at` and `freshness` mean, and that each result carries them in
780
+ its structured result and again in its text; and that exposure numbers are aggregate counts of
781
+ ip:port services, not an internet-wide census.
782
+
783
+ The instructions and every tool description are at most 2,048 characters, the length at which
784
+ Claude Code cuts each of them before the model reads it (its `CLAUDE_CODE_MAX_MCP_DESCRIPTION_LENGTH`,
785
+ default 2048), so no client that cuts there loses a sentence. A description says what its tool
786
+ returns and the rules that keep a zero, a `false` or a failure from reading as a finding; what each
787
+ field counts, in full, is in the tool's `outputSchema`, and the result's note says it again for the
788
+ numbers it relays.
759
789
 
760
790
  ## Privacy: what is sent where
761
791
 
@@ -771,12 +801,13 @@ Where each tool's input travels:
771
801
 
772
802
  | Input | Where it is sent |
773
803
  |---|---|
774
- | A CVE ID (`get_cve`, `cve_exposure`, `epss_history`, `cve_intel`, `vendor_advisories_for_cve`), a CWE ID and page (`get_cwe`), a vendor and advisory ID (`get_vendor_advisory`) | The URL path. |
804
+ | A CVE ID (`get_cve`, `cve_exposure`, `epss_history`, `cve_intel`, `cve_remediation`, `vendor_advisories_for_cve`), a CWE ID and page (`get_cwe`), a vendor and advisory ID (`get_vendor_advisory`) | The URL path. |
775
805
  | `search_cves`: `search` | The `X-EG-Search` request header, never the URL. Its severity, minimum CVSS, sort, limit and offset go in the query string. |
776
806
  | `search_vendor_advisories`: `query` | The `X-EG-Advisory-Search` request header, never the URL. Its vendor, severity, CVE filter, limit and offset go in the query string. |
777
807
  | `check_affected`: `product`, `version`, `ecosystem`, `package` | The `X-EG-Product`, `X-EG-Version`, `X-EG-Ecosystem` and `X-EG-Package` request headers, never the URL. |
778
808
  | `kev_recent`: `since`, `until`, `ransomware`, `vendor`, `limit`, `cursor` | The `X-EG-Since`, `X-EG-Until`, `X-EG-Ransomware`, `X-EG-Vendor`, `X-EG-Limit` and `X-EG-Cursor` request headers, never the URL. |
779
809
  | `check_sbom`: `purls`, or an `sbom` document | The purls, in POST bodies of at most 200 each. A CycloneDX or SPDX document is read on your machine and only the purls in it are sent; the document is not. (Over the hosted endpoint the document goes to EchelonGraph's server instead: see below.) |
810
+ | `scan_manifest`: `files` | The purls read from the files, in POST bodies of at most 200 each; the filenames, the files' text and the entries not checked are not sent on to the API. Run from npm, the server that reads them is on your machine; over the hosted endpoint it is EchelonGraph's, and the files are the request body (see below). |
780
811
  | `cve_summary`, `exposure_radar` | No input. |
781
812
 
782
813
  The API marks its answers to header-carried lookups `no-store`, so no shared cache keeps them,
@@ -790,14 +821,17 @@ client's User-Agent. It never logs tool arguments or a query string. A `check_sb
790
821
  an `sbom_review` prompt's) is a tool argument like any other here: it is in the request body, so
791
822
  the whole document reaches EchelonGraph's server, which reads the purls from it in memory, sends
792
823
  only those to the API, and neither logs nor keeps the document. To keep the document on your
793
- machine, run the npm package, or pass `check_sbom` the purls. When a client passes the
824
+ machine, run the npm package, or pass `check_sbom` the purls. `scan_manifest`'s files are the
825
+ same: their whole text is in the request body, which EchelonGraph's server reads into purls in
826
+ memory, sending only those to the API, and neither logs nor keeps the files. To keep lockfiles on
827
+ your machine, run the npm package. When a client passes the
794
828
  per-client limit it logs that client's address key. It passes your client's public address to
795
829
  the API, in a header the API trusts only with a token it checks, so the API's rate limit counts
796
830
  you and not the hosted service. The API's access line then records each of those calls under
797
831
  your address (an IPv6 address by its /64) with its URL path: for a CVE, CWE or advisory lookup,
798
832
  the ID you asked about. It does not record the query string or the header-carried terms above
799
833
  (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.
834
+ and `scan_manifest`'s purls it records how many, not which.
801
835
 
802
836
  EchelonGraph's privacy policy: <https://echelongraph.io/privacy>.
803
837
 
@@ -809,15 +843,15 @@ refuses one once the count passes the ceiling for its path:
809
843
 
810
844
  | Path | Ceiling per minute | Tools |
811
845
  |---|---|---|
812
- | `/api/v1/public/cves` and every path under it | 12,000 | `cve_summary`, `search_cves`, `get_cve`, `epss_history`, `cve_intel`, `check_affected`, `check_sbom` |
846
+ | `/api/v1/public/cves` and every path under it | 12,000 | `cve_summary`, `search_cves`, `get_cve`, `epss_history`, `cve_intel`, `cve_remediation`, `check_affected`, `check_sbom`, `scan_manifest` |
813
847
  | `/api/v1/public/cwes`, `/api/v1/public/vendor-advisories`, `/api/v1/public/kev/recent`, `/api/v1/public/shadow-ai-radar` | 6,000 | `get_cwe`, the three vendor-advisory tools, `kev_recent`, and one of `exposure_radar`'s five calls |
814
848
  | any other `/api/v1/public` path | 600 | `cve_exposure`, and the rest of `exposure_radar`'s calls |
815
849
 
816
850
  Because the count is shared, heavy use of one path also uses up the lower ceilings. On top of
817
851
  that, 500 requests per second per caller is a burst cap on each serving instance, and
818
- `check_sbom`'s batch route is charged per component: 1,200 components a minute per caller.
852
+ `check_sbom`'s and `scan_manifest`'s batch route is charged per component: 1,200 components a minute per caller.
819
853
  Over a limit the API answers 429 with `Retry-After`, and the tool returns a failure
820
- (`state` `failed`, `error.status` 429), never empty data. `check_sbom` is the exception: it
854
+ (`state` `failed`, `error.status` 429), never empty data. `check_sbom` and `scan_manifest` are the exception: each
821
855
  sends a long list as batches of 200, waits out a 429's `Retry-After` and sends the batch again,
822
856
  within 50 seconds per call (MCP clients time a call out at about 60 s). From a fresh budget a
823
857
  list of up to 1,200 distinct purls finishes in one call; a longer one (the cap is 2,000) is
@@ -829,22 +863,22 @@ limits: <https://echelongraph.io/pulse/api>.
829
863
  The hosted endpoint adds its own limit before the API's: 120 MCP requests a minute per client
830
864
  address, counted by each server instance, answered over it with HTTP 429, `Retry-After` and a
831
865
  JSON-RPC error (code `-32029`) that names the limit and the seconds to wait. A request body over
832
- 64 KiB is refused (HTTP 413, code `-32600`), except a `tools/call` of `check_sbom` or a
833
- `prompts/get` of `sbom_review`, which may be up to 6 MiB: enough for a CycloneDX or SPDX document
834
- at the tool's 5,000,000-character cap. Each server instance reads at most two such large bodies at
866
+ 64 KiB is refused (HTTP 413, code `-32600`), except a `tools/call` of `check_sbom` or
867
+ `scan_manifest`, or a `prompts/get` of `sbom_review`, which may be up to 6 MiB: enough for a
868
+ CycloneDX or SPDX document, or a call's lockfiles, at the tools' 5,000,000-character cap. Each server instance reads at most two such large bodies at
835
869
  once; a third is answered HTTP 503 with `Retry-After` and a JSON-RPC error (code `-32030`). Each
836
870
  instance serves at most 64 requests at once; the next waits up to 10 seconds for one to finish,
837
871
  and is then answered the same way. A `subscriptions/listen` stream, which stays open until its
838
872
  client leaves, is not one of the 64.
839
873
 
840
874
  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
875
+ learns of it. Its API calls are then cancelled, `check_sbom` and `scan_manifest` send no further batch, and its
842
876
  large-body slot is freed. Over HTTP/1.1, Cloud Run does not pass a client's disconnect on to the
843
877
  server at all (<https://docs.cloud.google.com/run/docs/troubleshooting>). The hosted endpoint
844
878
  speaks HTTP/2 to Cloud Run, where the same page says a disconnect is passed on. Measured on
845
879
  2026-10-05, Cloud Run reset the stream of a client that stopped reading its answer 15 s in, and the
846
880
  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
881
+ has not been observed yet. Until it is, an abandoned `check_sbom` or `scan_manifest` call may run for up to its
848
882
  50-second budget and hold its slot that long.
849
883
 
850
884
  ## Configuration
@@ -870,7 +904,7 @@ It reads the three variables above and:
870
904
  | `PORT` | `8080` | The port it listens on. |
871
905
  | `MCP_RATE_LIMIT_PER_MIN` | `120` | MCP requests a minute per client address, per instance. |
872
906
  | `MCP_MAX_BODY_BYTES` | `65536` | The largest request body it reads, except for an SBOM (below). |
873
- | `MCP_MAX_SBOM_BODY_BYTES` | `6291456` | The largest body of a `tools/call` of `check_sbom` or a `prompts/get` of `sbom_review`. |
907
+ | `MCP_MAX_SBOM_BODY_BYTES` | `6291456` | The largest body of a `tools/call` of `check_sbom` or `scan_manifest`, or a `prompts/get` of `sbom_review`. |
874
908
  | `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`. |
875
909
  | `MCP_MAX_IN_FLIGHT` | `64` | How many requests it serves at once, not counting `subscriptions/listen` streams; the next, its body read, waits. |
876
910
  | `MCP_ADMISSION_WAIT_MS` | `10000` | How long a request waits to be served before it is answered 503 with `Retry-After`. |
package/dist/http.js CHANGED
@@ -181,7 +181,7 @@ function header(req, name) {
181
181
  return Array.isArray(v) ? v.join(", ") : v;
182
182
  }
183
183
  function tooLarge(res, cors) {
184
- send(res, 413, rpcError(BODY_TOO_LARGE_CODE, `Request body too large: at most ${MAX_BODY_BYTES} bytes, or ${MAX_SBOM_BODY_BYTES} bytes for ${LARGE_BODY_TARGETS_TEXT}. A larger SBOM can be passed to check_sbom as its purls.`, { max_bytes: MAX_BODY_BYTES, max_bytes_sbom: MAX_SBOM_BODY_BYTES }), { ...cors, Connection: "close" });
184
+ send(res, 413, rpcError(BODY_TOO_LARGE_CODE, `Request body too large: at most ${MAX_BODY_BYTES} bytes, or ${MAX_SBOM_BODY_BYTES} bytes for ${LARGE_BODY_TARGETS_TEXT}. A larger SBOM or lockfile can be passed to check_sbom as its purls.`, { max_bytes: MAX_BODY_BYTES, max_bytes_sbom: MAX_SBOM_BODY_BYTES }), { ...cors, Connection: "close" });
185
185
  return { status: 413, refused: "body_size" };
186
186
  }
187
187
  async function serveMcp(req, res, pathname) {
@@ -262,7 +262,7 @@ async function serveMcp(req, res, pathname) {
262
262
  if (read === "too_large")
263
263
  return tooLarge(res, cors);
264
264
  if (read === "busy") {
265
- send(res, 503, rpcError(BUSY_CODE, `Server busy: this instance is already reading its ${LARGE_BODY_SLOTS} large request bodies at once. Retry after ${BUSY_RETRY_AFTER_SEC} s, or pass check_sbom the SBOM's purls.`, { retry_after_seconds: BUSY_RETRY_AFTER_SEC }), { ...cors, "Retry-After": String(BUSY_RETRY_AFTER_SEC), Connection: "close" });
265
+ send(res, 503, rpcError(BUSY_CODE, `Server busy: this instance is already reading its ${LARGE_BODY_SLOTS} large request bodies at once. Retry after ${BUSY_RETRY_AFTER_SEC} s, or pass check_sbom the document's purls.`, { retry_after_seconds: BUSY_RETRY_AFTER_SEC }), { ...cors, "Retry-After": String(BUSY_RETRY_AFTER_SEC), Connection: "close" });
266
266
  log("WARNING", "mcp_large_body_busy", { slots: LARGE_BODY_SLOTS });
267
267
  return { status: 503, refused: "large_body_busy" };
268
268
  }
@@ -207,7 +207,8 @@ export class FixedWindowLimiter {
207
207
  //
208
208
  // - 64 KiB for every request: far above any real request but an SBOM's, and the cap the
209
209
  // endpoint shipped with (#2316).
210
- // - 6 MiB for one tools/call of check_sbom, or one prompts/get of sbom_review, which takes the
210
+ // - 6 MiB for one tools/call of check_sbom or scan_manifest (#2835: a project's lockfiles, up to
211
+ // the same 5,000,000 characters), or one prompts/get of sbom_review, which takes the
211
212
  // same document to hand to check_sbom (LARGE_BODY_TARGETS). The sbom argument is up to MAX_SBOM_CHARS
212
213
  // (5,000,000) characters, and over this endpoint that document IS the JSON-RPC body: JSON
213
214
  // text passed as a string is escaped once more (every '"' and newline doubles), which for a
@@ -236,24 +237,27 @@ export class FixedWindowLimiter {
236
237
  export const DEFAULT_MAX_BODY_BYTES = 64 * 1024;
237
238
  export const DEFAULT_MAX_SBOM_BODY_BYTES = 6 * 1024 * 1024;
238
239
  export const DEFAULT_LARGE_BODY_SLOTS = 2;
239
- /** The requests whose body may pass DEFAULT_MAX_BODY_BYTES: JSON-RPC method → the one tool or prompt name. */
240
- export const LARGE_BODY_TARGETS = { "tools/call": "check_sbom", "prompts/get": "sbom_review" };
240
+ /** The requests whose body may pass DEFAULT_MAX_BODY_BYTES: JSON-RPC method → the tools or prompts it may name.
241
+ * #2835: scan_manifest takes a project's lockfiles as text, up to the same 5,000,000 characters as
242
+ * check_sbom's document (scan_manifest.ts MAX_MANIFEST_CHARS), so it is admitted the same way. */
243
+ export const LARGE_BODY_TARGETS = { "tools/call": ["check_sbom", "scan_manifest"], "prompts/get": ["sbom_review"] };
241
244
  /** LARGE_BODY_TARGETS in words, for the refusals. */
242
245
  export const LARGE_BODY_TARGETS_TEXT = Object.entries(LARGE_BODY_TARGETS)
243
- .map(([m, n]) => `a ${m} of ${n}`)
244
- .join(" or ");
246
+ .map(([m, ns]) => `a ${m} of ${ns.join(" or ")}`)
247
+ .join(", or ");
248
+ const targetsOf = (method) => (Object.hasOwn(LARGE_BODY_TARGETS, method) ? LARGE_BODY_TARGETS[method] : []);
245
249
  /** Whether a request's own Mcp-Method / Mcp-Name headers leave it free to be one of LARGE_BODY_TARGETS. Absent headers do (a 2025-era client sends neither). */
246
250
  export function largeBodyAdmissible(mcpMethod, mcpName) {
247
251
  const name = mcpName?.trim();
248
252
  if (mcpMethod === undefined)
249
- return name === undefined || Object.values(LARGE_BODY_TARGETS).includes(name);
250
- const want = Object.hasOwn(LARGE_BODY_TARGETS, mcpMethod.trim()) ? LARGE_BODY_TARGETS[mcpMethod.trim()] : undefined;
251
- return want !== undefined && (name === undefined || name === want);
253
+ return name === undefined || Object.values(LARGE_BODY_TARGETS).some((ns) => ns.includes(name));
254
+ const want = targetsOf(mcpMethod.trim());
255
+ return want.length > 0 && (name === undefined || want.includes(name));
252
256
  }
253
257
  /** Whether a read body is one of LARGE_BODY_TARGETS, by its bodyFacts. */
254
258
  export function isLargeBodyTarget(facts) {
255
259
  const name = facts.rpc_method === "tools/call" ? facts.tool : facts.rpc_method === "prompts/get" ? facts.prompt : undefined;
256
- return name !== undefined && Object.hasOwn(LARGE_BODY_TARGETS, facts.rpc_method) && LARGE_BODY_TARGETS[facts.rpc_method] === name;
260
+ return name !== undefined && targetsOf(facts.rpc_method).includes(name);
257
261
  }
258
262
  /** A counting semaphore that never waits: tryAcquire answers at once. */
259
263
  export class Slots {