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 +147 -0
- package/README.md +64 -30
- package/dist/http.js +2 -2
- package/dist/httpPolicy.js +13 -9
- package/dist/index.js +148 -57
- package/dist/prompts.js +102 -9
- package/dist/requiredText.js +118 -0
- package/dist/resources.js +2 -0
- package/dist/tools/check_affected.js +41 -18
- package/dist/tools/check_sbom.js +37 -374
- package/dist/tools/cve_intel.js +1 -1
- package/dist/tools/cve_remediation.js +257 -0
- package/dist/tools/epss_history.js +100 -6
- package/dist/tools/kev_recent.js +4 -4
- package/dist/tools/manifests/common.js +48 -0
- package/dist/tools/manifests/gomod.js +168 -0
- package/dist/tools/manifests/index.js +80 -0
- package/dist/tools/manifests/lockfiles.js +176 -0
- package/dist/tools/manifests/npmlock.js +107 -0
- package/dist/tools/manifests/requirements.js +125 -0
- package/dist/tools/manifests/toml.js +299 -0
- package/dist/tools/match_batch.js +432 -0
- package/dist/tools/scan_manifest.js +257 -0
- package/dist/tools/vendor_advisories.js +128 -20
- package/package.json +3 -1
- package/server.json +2 -2
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
|
-
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
|
|
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
|
|
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
|
|
644
|
-
stays in the text, each with at least `cve_id`, `kev_listed`, `ransomware`, `epss_score`,
|
|
645
|
-
`effective_score` and `score_assessed
|
|
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.
|
|
648
|
-
|
|
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
|
|
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;
|
|
756
|
-
`measured_at` and `freshness` mean, and that
|
|
757
|
-
|
|
758
|
-
|
|
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.
|
|
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`
|
|
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
|
|
833
|
-
`prompts/get` of `sbom_review`, which may be up to 6 MiB: enough for a
|
|
834
|
-
at the
|
|
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`
|
|
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
|
|
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
|
}
|
package/dist/httpPolicy.js
CHANGED
|
@@ -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
|
|
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
|
|
240
|
-
|
|
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,
|
|
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 =
|
|
251
|
-
return 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 &&
|
|
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 {
|