echelongraph-mcp 3.0.0 → 3.1.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 +695 -2
- package/README.md +85 -74
- package/dist/fixStatus.js +4 -2
- package/dist/http.js +73 -81
- package/dist/httpPolicy.js +213 -11
- package/dist/index.js +516 -52
- package/dist/logLines.js +86 -0
- package/dist/prompts.js +126 -21
- package/dist/readBody.js +140 -0
- package/dist/requiredText.js +38 -3
- package/dist/resources.js +3 -3
- package/dist/runtime.js +66 -1
- package/dist/schemaRefusal.js +138 -0
- package/dist/textBudget.js +1082 -78
- package/dist/tools/check_affected.js +23 -7
- package/dist/tools/check_sbom.js +287 -8
- package/dist/tools/cve_intel.js +163 -8
- package/dist/tools/cve_remediation.js +174 -18
- package/dist/tools/epss_history.js +45 -6
- package/dist/tools/kev_recent.js +4 -1
- package/dist/tools/match_batch.js +78 -15
- package/dist/tools/packageRecords.js +58 -0
- package/dist/tools/scan_manifest.js +26 -7
- package/dist/tools/vendor_advisories.js +40 -8
- package/docs/exposure-radar.md +4 -0
- package/docs/rate-limits.md +6 -3
- package/docs/structured-results.md +15 -3
- package/docs/tools.md +149 -12
- package/package.json +1 -1
- package/server.json +2 -2
package/docs/tools.md
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Part of the [README](../README.md), whose Tools table says what each tool answers. This page says,
|
|
4
4
|
field by field, how `cve_summary`, `get_cve`, `search_cves`, `cve_exposure` and `check_affected`
|
|
5
|
-
read the API's answer
|
|
5
|
+
read the API's answer, and how `check_sbom` and `scan_manifest` cut their rows past the
|
|
6
|
+
structured result's budget; `exposure_radar` has [a page of its own](exposure-radar.md). It is kept out
|
|
6
7
|
of the README because npm keeps only the first 65,536 characters of a package's README, and the
|
|
7
8
|
package ships it in `docs/`. Exposure counts are derived from Shodan data. Shodan data is owned by
|
|
8
9
|
Shodan, which holds its copyright (© Shodan).
|
|
@@ -34,6 +35,15 @@ outputSchema describes is left out and named in the note, and a `poller` that is
|
|
|
34
35
|
object nor null is left out whole; `summary` is relayed either way, so a change to that diagnostic
|
|
35
36
|
block never withholds the counts.
|
|
36
37
|
|
|
38
|
+
`nvd_poll` is when the fleet of API instances last completed an NVD poll: `last_completed_at` (the
|
|
39
|
+
end of the last window fetched and stored without an error), with `scope` `fleet` and the poll's
|
|
40
|
+
`interval`. The API leaves it out when that time is unknown, and never puts the answering
|
|
41
|
+
instance's counters in its place. It is the result's `freshness` (`freshness.nvd_poll`, its time as
|
|
42
|
+
`last_run_at`) where `last_completed_at` is a real RFC 3339 time at most 5 minutes after this
|
|
43
|
+
server's clock and `scope` is `fleet`. Otherwise `freshness` is `null`, and the note says whether
|
|
44
|
+
the answer carries no `nvd_poll` or one without such a time. `poller.last_poll_at` is never the
|
|
45
|
+
feed's freshness.
|
|
46
|
+
|
|
37
47
|
The tool relays the API's JSON as it was sent, less a `poller` field left out as above. When
|
|
38
48
|
`summary.none`, `summary.nvd_none` or `summary.rejected` is above zero, the note says what it
|
|
39
49
|
counts, with the number from the answer.
|
|
@@ -62,8 +72,8 @@ Both tools relay the API's JSON as it was sent, and the note says what the score
|
|
|
62
72
|
|
|
63
73
|
## What `patch_available` says
|
|
64
74
|
|
|
65
|
-
`patch_available` is `true` when EchelonGraph holds evidence of a fix for the CVE,
|
|
66
|
-
`patch_evidence` lists the sources
|
|
75
|
+
`patch_available` is `true` when EchelonGraph holds, or once held, evidence of a fix for the CVE,
|
|
76
|
+
and `patch_evidence` lists the sources it holds now, strongest first: `vendor_patch` (a vendor
|
|
67
77
|
patch on record, from Ubuntu or Red Hat), `fixed_version` (a fixed version recorded for an
|
|
68
78
|
affected package, or the fixed bound of an affected version range), `release_reference` (a
|
|
69
79
|
reference that EchelonGraph confirmed through the GitHub API is a published release containing
|
|
@@ -74,11 +84,15 @@ CSAF `vendor_fix`, Microsoft's Vendor Fix), `nvd_patch_reference` (an NVD refere
|
|
|
74
84
|
(a sentence of the CVE record's solutions, written by its CNA, that names a fixed version) and
|
|
75
85
|
`description_fix` (a sentence of the CVE description that names a fixed version). A reference page
|
|
76
86
|
that only mentions a patch, and a CVE's presence in CISA's KEV catalog, are not evidence.
|
|
77
|
-
`get_cve` relays the CNA's own sentence as `cna_solution`,
|
|
78
|
-
|
|
79
|
-
|
|
87
|
+
`get_cve` relays the CNA's own sentence as `cna_solution`, which section of the record it is as
|
|
88
|
+
`cna_solution_section` (`solution` or `workaround`: a workaround's sentence is the record's
|
|
89
|
+
workaround, never its solution), its date for when the vulnerability was made public as
|
|
90
|
+
`date_public` (with `date_public_source` `cna`), and the CNA as `cna`, each only where the CVE
|
|
91
|
+
record has it. `false` means no fix evidence is on record, which is **not** a
|
|
80
92
|
finding that no fix exists. Once `true`, `patch_available` is not set back to `false`, so it can be
|
|
81
|
-
`true` beside an empty `patch_evidence
|
|
93
|
+
`true` beside an empty `patch_evidence`: evidence of a fix once held has since been withdrawn, and
|
|
94
|
+
`remediation.fix_status` in the CVE's record is the current reading, not `patch_available` (since
|
|
95
|
+
3.0.2 the field's outputSchema text says so). An answer with no `patch_evidence` (an API older than that
|
|
82
96
|
field) does not name the evidence, and a `false` there is not a finding that no fix exists either.
|
|
83
97
|
Both tools relay the two fields as the API sends them.
|
|
84
98
|
|
|
@@ -129,9 +143,14 @@ affected, by one of two lookup paths:
|
|
|
129
143
|
criteria name that product with a version range that includes the version. It matches the
|
|
130
144
|
token across vendors, so each match carries `cpe_vendor` and `vendor_unknown`: a match with
|
|
131
145
|
`vendor_unknown` true is real, but its vendor is not verified to be yours.
|
|
132
|
-
- **Registry path** — `ecosystem` (`npm`, `PyPI`, `Maven`, …), `package` and `version`. Each
|
|
133
|
-
advisory
|
|
134
|
-
affected (
|
|
146
|
+
- **Registry path** — `ecosystem` (`npm`, `PyPI`, `Maven`, …), `package` and `version`. Each CVE
|
|
147
|
+
named by the OSV advisory records EchelonGraph holds for the package is decided against the
|
|
148
|
+
version once, as affected (one match), not affected (`not_affected_count`) or undetermined
|
|
149
|
+
(`undetermined_count`). When OSV publishes one CVE for the package in several records (a GHSA
|
|
150
|
+
and a package repository's own advisory, say), EchelonGraph stores a row for each and decides
|
|
151
|
+
them together, so the two counts count CVEs, while `advisories_considered` counts the stored
|
|
152
|
+
rows: a CVE stored as two rows and cleared counts 2 in `advisories_considered` and 1 in
|
|
153
|
+
`not_affected_count`.
|
|
135
154
|
|
|
136
155
|
Read `assessed` before `count`. `assessed` false means the lookup did not evaluate the
|
|
137
156
|
component, and `not_assessed_reason` says why: `product_not_in_cpe_corpus`,
|
|
@@ -152,8 +171,19 @@ holding interval's fixed bound, moved past any overlapping interval that still h
|
|
|
152
171
|
and `fixed_in_detail` then names the intervals it moved past (#3074). `fixed_in` is always above
|
|
153
172
|
the version asked about. It is null where no such version is recorded (the walk reaches an
|
|
154
173
|
interval that ends at `last_affected`, or has no end, with none beyond it) or no interval holds
|
|
155
|
-
the version, and `fixed_in_reason` then says which: a null is not a finding that no fix exists.
|
|
156
|
-
|
|
174
|
+
the version, and `fixed_in_reason` then says which: a null is not a finding that no fix exists. When
|
|
175
|
+
a CVE is stored as several records (#3137), `fixed_in` is walked over the records that store
|
|
176
|
+
intervals. A record of the same CVE that stores none withholds it only when that record's versions
|
|
177
|
+
or recorded fix may hold it affected. A record that cannot decide it (no version data, a git commit
|
|
178
|
+
range, or one recorded fix that `fixed_in` is at or past) does not, so the same lookup at
|
|
179
|
+
`fixed_in` itself can come back undetermined rather than not affected. A CPE
|
|
180
|
+
match carries no `fixed_in`, since the end of a CPE version range is not a fix. Once
|
|
181
|
+
EchelonGraph's API serves it, a CPE match carries that end in its own field, as NVD states it,
|
|
182
|
+
`cpe_version_end_excluding` (the first version the matched range no longer holds) or
|
|
183
|
+
`cpe_version_end_including` (the last one it holds), absent where the range sets no end. Only the
|
|
184
|
+
first cut level of the text and the first thinned level of `data` keep them, so a CPE answer cut
|
|
185
|
+
past those levels, as typical ones are, carries them only with `full: true`. Another range of the
|
|
186
|
+
same CVE, or a vendor backport, can sit past it, so it is never given as a fixed version. `check_sbom` and
|
|
157
187
|
`scan_manifest` relay the same fields (`interval`, `fixed_in`, `fixed_in_reason` and
|
|
158
188
|
`fixed_in_detail`) on each match.
|
|
159
189
|
|
|
@@ -161,3 +191,110 @@ match carries no `fixed_in`, since the end of a CPE version range is not a fix.
|
|
|
161
191
|
`X-EG-Ecosystem` and `X-EG-Package` request headers, never in the URL, so request logs and
|
|
162
192
|
trace spans that record URLs do not hold what was looked up. The endpoint reads a CPE vendor
|
|
163
193
|
from the URL only, so this tool takes none.
|
|
194
|
+
|
|
195
|
+
## The client cut
|
|
196
|
+
|
|
197
|
+
The server instructions and every tool description are held to 2,048 characters
|
|
198
|
+
(`CLIENT_DESCRIPTION_CUT`, `src/requiredText.ts`), and the sentences a client must not lose sit in
|
|
199
|
+
the first 2,048 characters of each (`REQUIRED_IN_HEAD`). 2,048 is the shortest cut a client is
|
|
200
|
+
known to make:
|
|
201
|
+
|
|
202
|
+
- Claude Code from v2.1.280 sends the model at most `CLAUDE_CODE_MAX_MCP_DESCRIPTION_LENGTH`
|
|
203
|
+
characters of each description and of the instructions. Its documented default was 2,048 when
|
|
204
|
+
this budget was set (read 2026-10-05). On 2026-10-18 its documentation gives 4,096, and 16,384
|
|
205
|
+
for a tool it loads through tool search, and v2.1.302's debug log shows a 4,096 cut ("Tool
|
|
206
|
+
"exposure_radar" description truncated from 11807 to 4096 chars", from 2.6.8's 11,807-character
|
|
207
|
+
description). Builds between v2.1.280 and that change still cut at 2,048.
|
|
208
|
+
- Cursor cuts tool descriptions at 6,500 characters. VS Code and Claude Desktop document no cap.
|
|
209
|
+
|
|
210
|
+
Text that fits 2,048 therefore reaches each of them whole. The tests hold every served text to it
|
|
211
|
+
over stdio (`test/tools.test.mjs`) and over the hosted endpoint (`test/http.test.mjs`), and the
|
|
212
|
+
synthetic writes an `mcp text cut` line per transport and era that an alert reads.
|
|
213
|
+
|
|
214
|
+
## How `check_sbom` and `scan_manifest` cut their rows
|
|
215
|
+
|
|
216
|
+
Past 30,000 characters of structured result, and without `full: true`, `check_sbom` and
|
|
217
|
+
`scan_manifest` cut `data` in this order (the note's `DATA CUT` sentences say where each answer
|
|
218
|
+
stopped):
|
|
219
|
+
|
|
220
|
+
1. `scan_manifest`'s `not_checked`, then `not_sent_purls`, which is kept whole or not at all (the
|
|
221
|
+
note gives the positions of the purls not sent, so a second call can send them).
|
|
222
|
+
2. Every row is thinned, every row kept: each keeps `index`, `purl`, `verdict`,
|
|
223
|
+
`not_assessed_reason`, `cve_ids`, `matched_package` and `matched_via` (and an upstream row's
|
|
224
|
+
`matched_version_from` and `purl_name`), and each match `cve_id`, `kev_listed`, `ransomware`,
|
|
225
|
+
`epss_score`, `effective_score`, `score_assessed`, `fixed_in` and `fixed_in_reason`: every field
|
|
226
|
+
a prompt reads from a match.
|
|
227
|
+
3. Before any other row's matches are cut, the `not_affected` and `not_assessed` rows are cut to
|
|
228
|
+
`index`, `purl`, `verdict`, `not_assessed_reason` and `cve_ids`, and leave, the `not_affected`
|
|
229
|
+
ones first, as many as must for every other row to keep all its matches. `data.summary` counts
|
|
230
|
+
every verdict, and the note says how many left (#3269).
|
|
231
|
+
4. Where the other rows' matches must be capped even with all of those rows gone, those rows stay
|
|
232
|
+
instead, cut so, while every other row fits at its floor: its matches used in ransomware or
|
|
233
|
+
KEV-listed, and 10 more (#3269's review). Each row's matches are capped at one number, the most
|
|
234
|
+
that fit, never under 10, and a row whose floor is above that number keeps its floor. A row
|
|
235
|
+
with more keeps the matches chosen used in ransomware first, then its other KEV-listed ones,
|
|
236
|
+
then those of highest `effective_score`, listed in the API's order, and carries
|
|
237
|
+
`matches_truncated`: its total, the number returned, and the KEV-listed and ransomware CVE IDs
|
|
238
|
+
and `fixed_in` versions of all its matches (#3270). Only where even the floor does not fit with
|
|
239
|
+
every one of those rows do they leave, as many as must for it to.
|
|
240
|
+
5. Each match is cut to the fields of step 2 less `fixed_in_reason`, still capped as step 4 says:
|
|
241
|
+
every level that keeps matches keeps every field `workload_triage` ranks by. A null `fixed_in`
|
|
242
|
+
there without its `fixed_in_reason` is a reason cut for size, and `sbom_review` never calls it
|
|
243
|
+
no fix: it checks the purl again with `full: true`, or reads the fix from `cve_remediation` or
|
|
244
|
+
`get_cve`. Kept here, the reason cost the 201-purl probe a row (43 to 42).
|
|
245
|
+
6. Where even that does not fit with all of those rows gone, no row's matches are cut below its
|
|
246
|
+
floor (#3269's second review): the other rows leave instead, whole, at step 5's level and
|
|
247
|
+
their floor. The rows kept are chosen those with a KEV-listed or ransomware match first, then
|
|
248
|
+
the others, each in input order, each one that fits beside those chosen before it, so a row
|
|
249
|
+
too large to fit beside them leaves and the rows after it can stay. The note names the
|
|
250
|
+
`affected` and `undetermined` rows that left by `index`, those with a KEV-listed or ransomware
|
|
251
|
+
match first and apart from the others (the first 100 indices or runs of them in all), and
|
|
252
|
+
`check_sbom`'s note says how to check them again (#3269's third review): "Re-check these K
|
|
253
|
+
purls in this order, in calls of 7, 5, 8, 8 and 16, then of at most 17: each such call keeps
|
|
254
|
+
every row in data." Each of the first five sizes is the most of the rows left, in that order,
|
|
255
|
+
that the cut's own measure counts as fitting at their floor with `FIRST_FIT_SLACK` (600
|
|
256
|
+
characters) held back, so a lower bound: a real call of them often keeps more. The last is the
|
|
257
|
+
most such that any that many of the rest in a row count so. Each is counted for those rows, as
|
|
258
|
+
the cut would cut such a call, with this call's note longer by what such a call's note names of
|
|
259
|
+
them (its affected components). A call of fewer of them in a row keeps every row too. A row whose floor alone is
|
|
260
|
+
past the budget goes in a call of its own. Where not even one row fits at its floor (one row
|
|
261
|
+
whose KEV-listed matches alone are past the budget), the row chosen first stays alone with the
|
|
262
|
+
most of its matches that fit, chosen as in step 4, and the note says so; where not one match
|
|
263
|
+
fits beside it, with none.
|
|
264
|
+
7. Only where no row but the `not_affected` and `not_assessed` ones has a match is each row cut
|
|
265
|
+
further, to `index`, `purl`, `verdict`, `not_assessed_reason` and `cve_ids`, before rows leave.
|
|
266
|
+
|
|
267
|
+
Once the cut is settled, the rows kept take back detail in input order while room is left, and
|
|
268
|
+
then the rows steps 3 and 4 sent out come back, cut as step 3 says, as many as the room left
|
|
269
|
+
holds, where every other row stayed.
|
|
270
|
+
|
|
271
|
+
When rows leave `data`, the note names a page size N: "A call of the first N of these purls, or
|
|
272
|
+
of fewer, keeps every row in data; a call of other purls says in its own note what it leaves
|
|
273
|
+
out." (`scan_manifest`: a call whose files hold the first N of these purls.) N is counted for this
|
|
274
|
+
answer's own rows, as the cut above would cut a call of them, so it can be larger or smaller than
|
|
275
|
+
the number of rows this answer keeps; it is not a page size for the rest of the purls, whose rows
|
|
276
|
+
can be larger: round 2's "To read every row in data, check N or fewer purls per call" read as
|
|
277
|
+
one, and calls of the purls in chunks of N lost rows in 55% of such mixes (#3269's second review).
|
|
278
|
+
Nor is it a size for checking the rows left out again, which are the heavy ones: calls of N of
|
|
279
|
+
them dropped 504 of 2,540 rows on realistic Debian images (#3269's third review); the re-check
|
|
280
|
+
sentence of step 6 gives those sizes. `workload_triage` (its step 6) and `sbom_review` (its step
|
|
281
|
+
1) check again the `affected` and `undetermined` rows the note names, those with a KEV-listed or
|
|
282
|
+
ransomware match first, in calls of the sizes that sentence gives, at most 5 calls, and report the
|
|
283
|
+
rest as unchecked; `workload_triage` takes its counts from `data.summary`, which counts every row
|
|
284
|
+
whatever the cut. `scan_manifest` names its rows left out the same way and gives no re-check sizes:
|
|
285
|
+
its rows are checked again through `check_sbom`, by purls its note does not give.
|
|
286
|
+
|
|
287
|
+
The first text block, past its own 30,000 characters, keeps the same priority: before any row is
|
|
288
|
+
cut to `index`, `purl`, `verdict`, `not_assessed_reason` and `cve_ids`, the `not_affected` and
|
|
289
|
+
`not_assessed` rows are cut to that and leave the text, as many as must, so an affected row keeps
|
|
290
|
+
its matches while room allows; only where that cannot fit is every row cut so, and then rows
|
|
291
|
+
leave, those two kinds first, and the rows with a KEV-listed or ransomware match last, as `data`
|
|
292
|
+
keeps them (#3284: through 3.1.0's review the text left the rest from the end in input order, so a
|
|
293
|
+
KEV-bearing row `data` kept could leave the text unnamed; on 36 realistic Debian 12 images a client
|
|
294
|
+
reading the text alone missed 95 of 413 such rows). The note names by `index` each of those that
|
|
295
|
+
still leaves ("Of the rows left out of it, 30 have a KEV-listed or ransomware match: at index 511
|
|
296
|
+
to 513, …"). The note says what those rows leave out, and that each string in
|
|
297
|
+
them keeps its first 100 characters where one is longer. The text names no page size (through
|
|
298
|
+
#3269's review it said "check N or fewer purls per call", N the rows it kept, and a call of the
|
|
299
|
+
first N purls kept fewer in 34 of 89 mixes); its note says how many rows it leaves out, of which
|
|
300
|
+
kinds, and that `structuredContent.data` holds them.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "echelongraph-mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"mcpName": "io.echelongraph/echelongraph-mcp",
|
|
5
5
|
"description": "MCP server for CVE lookups: EchelonGraph scores, CISA KEV, EPSS, CWE, exploits, version and SBOM checks, vendor advisories, and per-CVE exposure derived from Shodan data. Shodan data is owned by Shodan, which holds its copyright (© Shodan). Keyless.",
|
|
6
6
|
"type": "module",
|
package/server.json
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
]
|
|
18
18
|
}
|
|
19
19
|
],
|
|
20
|
-
"version": "3.
|
|
20
|
+
"version": "3.1.0",
|
|
21
21
|
"remotes": [
|
|
22
22
|
{
|
|
23
23
|
"type": "streamable-http",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
{
|
|
29
29
|
"registryType": "npm",
|
|
30
30
|
"identifier": "echelongraph-mcp",
|
|
31
|
-
"version": "3.
|
|
31
|
+
"version": "3.1.0",
|
|
32
32
|
"transport": {
|
|
33
33
|
"type": "stdio"
|
|
34
34
|
},
|