echelongraph-mcp 2.6.2 → 2.6.3
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 +65 -0
- package/README.md +47 -7
- package/dist/index.js +119 -29
- package/dist/resources.js +2 -0
- package/dist/textBudget.js +247 -0
- package/dist/tools/check_affected.js +55 -2
- package/dist/tools/check_sbom.js +65 -4
- package/dist/tools/cve_intel.js +2 -1
- package/dist/tools/epss_history.js +2 -1
- package/dist/tools/get_cwe.js +11 -2
- package/dist/tools/kev_recent.js +25 -2
- package/dist/tools/vendor_advisories.js +204 -17
- package/package.json +1 -1
- package/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,71 @@ with a provenance attestation by the release workflow of
|
|
|
6
6
|
[github.com/echelongraph/echelongraph-mcp](https://github.com/echelongraph/echelongraph-mcp),
|
|
7
7
|
from the commit tagged `v<version>`.
|
|
8
8
|
|
|
9
|
+
## 2.6.3 — 2026-10-04
|
|
10
|
+
|
|
11
|
+
- `exposure_radar` relays the observation window each count was measured over, where the API now
|
|
12
|
+
serves one: `exposed_databases.window` (when our verifier last confirmed each host),
|
|
13
|
+
`leaked_credentials.window` (when each key was last seen) and `shadow_ai.confirmed_exposed.window`
|
|
14
|
+
(when our verifier's deciding probe ran). A window is relayed only when no counted row is undated;
|
|
15
|
+
otherwise the note says how many are, and a malformed window is left out and named. KEV-exposure
|
|
16
|
+
carries no observation time yet. `exposure_radar` stays `not_assessed` with `measured_at` null:
|
|
17
|
+
a window's end is not one observation time (#2438).
|
|
18
|
+
- `vendor_advisories_for_cve`, `search_vendor_advisories` and `get_vendor_advisory` read each
|
|
19
|
+
vendor's window from `GET /api/v1/public/vendor-advisories/coverage` beside their own request,
|
|
20
|
+
and relay it in `coverage` (`vendor_windows`, or `vendor_window` for the detail): the advisories
|
|
21
|
+
held, `earliest_vendor_published_at`, `latest_vendor_published_at` and `history_backfill`.
|
|
22
|
+
`vendor_advisories_for_cve` adds `cve_year` and `vendors_not_fully_held`, the vendors with no
|
|
23
|
+
advisory in the answer of which EchelonGraph holds none, whose earliest held advisory is dated
|
|
24
|
+
after 1 January of the year in the CVE ID, or whose history is still being read, and its note
|
|
25
|
+
names each with its window: an empty answer for CVE-2024-3400 can no longer be read as Palo Alto
|
|
26
|
+
having published none. Its description no longer says an empty answer means none of the polled
|
|
27
|
+
feeds names the CVE: it means none of the advisories EchelonGraph holds does. The search
|
|
28
|
+
and the detail name the vendors whose history is still being read. When the windows cannot be
|
|
29
|
+
read, the answer is still relayed, measured, with the windows null and a note saying so (#2729).
|
|
30
|
+
- `search_vendor_advisories`: a query of 1 or 2 characters matches whole words only
|
|
31
|
+
(`search_match` `word`), as the API now answers it, and the description and the note say so; a
|
|
32
|
+
search's total is counted to at most 1,000, and a capped total (`total_capped` true) is written
|
|
33
|
+
"1,000+" in the note, never as a count. `coverage` gains `total_capped` and `search_match`
|
|
34
|
+
(#2728).
|
|
35
|
+
- `check_sbom`'s 50-second budget bounds the whole call, not only when a batch may start: each
|
|
36
|
+
batch's request may take only what is left of it, and a batch still unanswered when the budget
|
|
37
|
+
runs out is cut off and its purls answered as not sent (`not_sent_reason` `time_budget`). Before,
|
|
38
|
+
a batch started at 49.9 s could run for the 15-second request timeout, past the 60-second limits
|
|
39
|
+
of the SDK's client and the hosted endpoint, which then answered nothing at all. A Retry-After
|
|
40
|
+
wait that ends at the budget's end, before any batch was answered, answers that 429 instead of
|
|
41
|
+
sending a request with no time left (#2756).
|
|
42
|
+
- Repository only, not in the package: the production synthetic probes `check_sbom` with 201
|
|
43
|
+
purls, so every run sends two batches, and fails the probe (`expectation_unmet`) unless both
|
|
44
|
+
were answered; core-backend's tests fail when `MAX_COMPONENTS`, `MAX_PURL_LEN` or
|
|
45
|
+
`API_COMPONENTS_PER_MINUTE` disagree with the API's own values, and every core-backend deploy runs
|
|
46
|
+
them before it builds (`scripts/check-outbound-identity.sh`'s table), refusing on drift (#2757).
|
|
47
|
+
- A success's first text block is cut to fit 30,000 characters when the API's JSON is longer, and
|
|
48
|
+
`structuredContent.data` still carries the answer whole. Through 2.6.2 that block was the JSON
|
|
49
|
+
whatever its size: on production's answers of 2026-10-04 a default `search_cves` for "openssl"
|
|
50
|
+
was 133,300 characters of text, `get_cve` CVE-2021-44228 80,897, `check_affected` openssl 3.0.0
|
|
51
|
+
156,058 (the Linux kernel at 5.10.0, 364,408), `check_sbom` on a 50-component SBOM 112,820,
|
|
52
|
+
`kev_recent` at limit 200 105,925 and `search_vendor_advisories` at limit 50 103,147, against
|
|
53
|
+
the 60,000-character ceiling; the same calls now measure 13,513, 22,960, 25,951 (27,717),
|
|
54
|
+
28,733, 31,588 and 19,917. The JSON is first laid out one row a line, which loses nothing; past
|
|
55
|
+
that, a list tool cuts each row to the fields it names (the description cut to 200 characters,
|
|
56
|
+
every long string ending in "…"), then to fewer, and leaves rows out only when its leanest cut
|
|
57
|
+
is still too long (`check_sbom` leaves out `not_affected` rows first); `get_cve` keeps the first
|
|
58
|
+
entries of `cpe_match` and `references`. The note then ends with a sentence starting
|
|
59
|
+
`TEXT CUT` that names what the first text block leaves out, which tool returns a row whole, and,
|
|
60
|
+
when rows are left out, the `offset` or page size at which they fit. An answer that fits is sent
|
|
61
|
+
as before. Every tool description, the server instructions and the methodology resource say so
|
|
62
|
+
(#2783).
|
|
63
|
+
- `search_cves` takes `offset` (0 to 10,000), so the next page a cut text names can be read (#2783).
|
|
64
|
+
- A list beside the rows is cut before the rows are: `check_affected`'s `excluded` and
|
|
65
|
+
`undetermined` samples keep each entry's `cve_id` and `reason`, and `cve_ids` (each match's
|
|
66
|
+
`cve_id`, in order) its first 10, so every match stays in the text (`flash_player` 10.0.0, 200
|
|
67
|
+
matches and 50 excluded entries, had 75 matches left out of it, two of them KEV-listed), and `check_sbom`'s `not_sent_purls` keeps its first 10, with the note
|
|
68
|
+
saying from which position of the input the purls not sent run. A 2,000-purl `check_sbom` call
|
|
69
|
+
that the API's budget stops after 1,200 now keeps the affected rows before the clean ones in the
|
|
70
|
+
text. A cut `check_affected` match keeps `match_reason` (a registry match's advisory interval,
|
|
71
|
+
which names the fixed version) where it fits, and the note names `cve_intel` for fixed versions
|
|
72
|
+
(#2783).
|
|
73
|
+
|
|
9
74
|
## 2.6.2 — 2026-10-04
|
|
10
75
|
|
|
11
76
|
- `kev_recent` reads an RFC 3339 `since` or `until` as the date written in it, dropping the time
|
package/README.md
CHANGED
|
@@ -155,7 +155,7 @@ The example questions are ones a client can answer with that tool alone.
|
|
|
155
155
|
| Tool | Title | What it answers | Example question |
|
|
156
156
|
|---|---|---|---|
|
|
157
157
|
| `cve_summary` | CVE feed summary | Counts of active CVEs by severity band, the count with no severity band from any source (`summary.none`, sent again as `summary.unscored`: CVEs not yet scored, not a rating of None), the same CVEs counted by NVD's severity label, as provenance (`summary.nvd_critical` to `summary.nvd_none`), the rejected (withdrawn) records outside the total (`summary.rejected`), and when the feed was last updated. | *How many critical CVEs does the feed hold, and when was it last updated?* |
|
|
158
|
-
| `search_cves` | Search CVEs | Search/filter CVEs (severity, min CVSS, text, sort) with EchelonGraph scores and `score_assessed`; the note names each row not yet scored. | *Find critical CVEs that mention Tomcat with a CVSS of 9 or more.* |
|
|
158
|
+
| `search_cves` | Search CVEs | Search/filter CVEs (severity, min CVSS, text, sort) with EchelonGraph scores and `score_assessed`; page with `limit` and `offset`; the note names each row not yet scored. | *Find critical CVEs that mention Tomcat with a CVSS of 9 or more.* |
|
|
159
159
|
| `get_cve` | CVE detail | Full record for one CVE: CVSS v3 and (when scored) v4, the EchelonGraph score and its confidence, whether EchelonGraph has scored it (`score_assessed`), EPSS, CISA-KEV status and known ransomware use, the GitHub GHSA id, references, and its published, modified and `updated_at` times. | *What are CVE-2024-3094's CVSS, EPSS and CISA-KEV status?* |
|
|
160
160
|
| `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?* |
|
|
161
161
|
| `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?* |
|
|
@@ -312,6 +312,7 @@ A `newest_kev` row's `exposure_state` says whether its count is a measurement:
|
|
|
312
312
|
| `exposed_databases.engines` | Distinct engine types among them (for example `redis` or `grafana`). |
|
|
313
313
|
| `exposed_databases.top_engines`, `exposed_databases.top_countries` | Up to 15 engines and 10 countries, ranked by those services. |
|
|
314
314
|
| `exposed_databases.pii_likely`, `exposed_databases.pci_likely` | Services whose schema names pass a high-confidence gate for personal data, or for payment-card data. The names are index, database, table or field names in the Shodan or LeakIX banner or in the check's response, never record values; the gate passes one unambiguous term (such as `ssn` or `cardholder`) or two distinct indicators. It is a precision-first schema gate, not a census: a service whose names do not pass it is not counted, whatever it holds, so the other services are not shown to hold no such data. |
|
|
315
|
+
| `exposed_databases.window.from`, `exposed_databases.window.to` | Timestamps, not counts: when EchelonGraph's check last confirmed the oldest and the newest of the services counted answering without authentication, a span of checks made at different times. Relayed only when every counted service has such a time; otherwise the note says how many do not. |
|
|
315
316
|
| `exposed_databases.last_run_at` | A timestamp, not a count: when the radar last completed a check, a cycle in which its Shodan search, or the LeakIX fallback, answered at least one query (others may have failed), its list of services due for a re-check was read, and the scan opt-out register could be consulted. A cycle that searched nothing, or whose reads failed, does not move it. |
|
|
316
317
|
|
|
317
318
|
#### `leaked_credentials`
|
|
@@ -322,6 +323,7 @@ A `newest_kev` row's `exposure_state` says whether its count is a measurement:
|
|
|
322
323
|
| `leaked_credentials.distinct_secrets` | Each secret once. |
|
|
323
324
|
| `leaked_credentials.distinct_repos` | Public GitHub repositories with at least one. |
|
|
324
325
|
| `leaked_credentials.top_providers`, `leaked_credentials.top_types` | Up to 15 providers and secret types, ranked by (repository, secret) pairs. |
|
|
326
|
+
| `leaked_credentials.window.from`, `leaked_credentials.window.to` | Timestamps, not counts: when EchelonGraph's detector last found the oldest and the newest of the (repository, secret) pairs counted in a public commit. |
|
|
325
327
|
| `leaked_credentials.last_run_at` | A timestamp, not a count: when the radar last completed a check, a cycle that read the public GitHub event stream (fetches of some of the commits it lists may have failed). A cycle whose read of that stream failed does not move it. |
|
|
326
328
|
|
|
327
329
|
None of them is validated. Each is a credential-shaped string in a public commit that passed
|
|
@@ -339,6 +341,7 @@ labels each one. Only `shadow_ai.confirmed_exposed` counts exposed services.
|
|
|
339
341
|
| `confirmed_exposed.total` | Confirmed exposed: services EchelonGraph's probes found answering without an authentication gate (liveness `active`, or `rechecking` during a re-check). It is the sum of `confirmed_exposed.by_category`. |
|
|
340
342
|
| `confirmed_exposed.by_category` | The same services, by category. |
|
|
341
343
|
| `confirmed_exposed.last_24h` | Confirmed-exposed services first recorded in the last 24 hours. |
|
|
344
|
+
| `confirmed_exposed.window.from`, `confirmed_exposed.window.to` | Timestamps, not counts: when EchelonGraph's verifier ran the probe that last decided the oldest and the newest of the confirmed-exposed services, a span of probes made at different times. Relayed only when every one has such a time. |
|
|
342
345
|
| `observed.total` | Every Certificate Transparency or Shodan observation on record, whatever its verification state: observed, not exposed. |
|
|
343
346
|
| `observed.by_category` | The same observations, by category. |
|
|
344
347
|
| `observed.last_24h` | Observations first recorded in the last 24 hours. |
|
|
@@ -485,7 +488,8 @@ never "not affected", and each figure cites its `measured_at`. A prompt makes no
|
|
|
485
488
|
Every tool answers in one of two shapes, so a model reading the result cannot mistake an
|
|
486
489
|
outage for an all-clear:
|
|
487
490
|
|
|
488
|
-
- **Success** — the first text block is the API's JSON verbatim
|
|
491
|
+
- **Success** — the first text block is the API's JSON verbatim (cut to fit 30,000 characters
|
|
492
|
+
when it is longer: see "Long answers" below); the second is a one-line
|
|
489
493
|
note saying the call succeeded, which base URL answered, and what it found; the third is
|
|
490
494
|
the structured result as JSON, less what the first two already say (below). When the
|
|
491
495
|
feed genuinely holds nothing for the query the note says so in words ("we looked and
|
|
@@ -515,6 +519,37 @@ what an earlier block already says, so a client that passes only `content` to th
|
|
|
515
519
|
sees how the answer was measured. The first text block (the API's JSON, or the failure) and the
|
|
516
520
|
note after it are where 1.x put them.
|
|
517
521
|
|
|
522
|
+
|
|
523
|
+
### Long answers
|
|
524
|
+
|
|
525
|
+
A model pays for every character of text it reads, and some clients cap what they pass on:
|
|
526
|
+
Claude Code warns past 10,000 tokens of tool output and, past 25,000, saves the result to a
|
|
527
|
+
file and hands the model its path instead. Through 2.6.2 the first text block was the API's JSON
|
|
528
|
+
whatever its size: on production's answers of 2026-10-04 a default `search_cves` page for
|
|
529
|
+
"openssl" was 133,300 characters of text, `get_cve` for CVE-2021-44228 80,897, `check_affected`
|
|
530
|
+
for the Linux kernel at 5.10.0 364,408, `kev_recent` at its largest page 105,925 and
|
|
531
|
+
`search_vendor_advisories` at its largest page 103,147.
|
|
532
|
+
|
|
533
|
+
So when the API's JSON is longer than 30,000 characters, the first text block holds it cut to
|
|
534
|
+
fit, and `structuredContent`'s `data` still holds it whole. First the same JSON is laid out with
|
|
535
|
+
each row on a line of its own, which drops no character of the answer but the indentation. If
|
|
536
|
+
that is still too long, a tool that returns a list of rows cuts each row to the fields it names,
|
|
537
|
+
most kept first (`search_cves` keeps `cve_id`, `severity`, `cvss_v3_score`, `echelongraph_score`,
|
|
538
|
+
`score_assessed`, `epss_score`, `kev_listed` and the first 200 characters of the description,
|
|
539
|
+
among others), with every long string ending in "…". A list beside the rows is cut before any
|
|
540
|
+
row field the first level keeps, and before any row leaves the text: `check_affected`'s
|
|
541
|
+
`excluded` and `undetermined` samples keep each entry's `cve_id` and `reason`, and its `cve_ids`
|
|
542
|
+
(each match's `cve_id`, in order) its first 10, so that every match stays in the text, and `check_sbom`'s `not_sent_purls` keeps its first 10, the note saying from
|
|
543
|
+
which position of the input the purls not sent run, so that a second call can send them without
|
|
544
|
+
reading the list. When even the leanest cut is too long, rows are left out of the text
|
|
545
|
+
(`check_sbom` leaves out the `not_affected` rows first, then the `not_assessed` ones), and the
|
|
546
|
+
note says how to read them: the `offset` to call next, or a smaller `limit`. Any other answer
|
|
547
|
+
keeps the first entries of each of its lists (`get_cve`'s `cpe_match` and `references`). The note
|
|
548
|
+
then ends with one sentence starting `TEXT CUT` that says what the first text block leaves out
|
|
549
|
+
and which tool returns a row whole (`get_cve`, `get_vendor_advisory`, `check_affected`, and
|
|
550
|
+
`cve_intel` a CVE's fixed versions). An answer that fits is sent as before, pretty-printed and
|
|
551
|
+
whole. The same answer is always cut the same way.
|
|
552
|
+
|
|
518
553
|
## Structured results
|
|
519
554
|
|
|
520
555
|
Since 2.0.0 every result, success or failure, carries `structuredContent`, and every tool
|
|
@@ -530,7 +565,7 @@ declares its shape as an `outputSchema` in `tools/list`, with a title and the an
|
|
|
530
565
|
| `coverage` | What the answer covers, where the tool can say: `in_scope` for `cve_exposure`, the list's own account of its count for `search_cves`, the radars that answered for `exposure_radar`, and `assessed`, `not_assessed_reason`, the lookup path and the match counts for `check_affected`. |
|
|
531
566
|
| `freshness` | The producing radar's last completed check (`last_run_at`), where the API serves one; `null` where it serves none. |
|
|
532
567
|
| `notes` | The caveats, one sentence each: what the envelope itself needs saying, then the note from the text block. |
|
|
533
|
-
| `data` | On a success only: the
|
|
568
|
+
| `data` | On a success only: the API's JSON, whole: the first text block carries the same JSON, or, past 30,000 characters, a cut of it that the note names (`TEXT CUT`). |
|
|
534
569
|
| `error` | On a failure only: `kind`, `path`, `status` and `message`. |
|
|
535
570
|
|
|
536
571
|
The last text block of every result is this structured result serialized as JSON, less what
|
|
@@ -574,7 +609,8 @@ a note says so. Per tool:
|
|
|
574
609
|
answer gives one time at which what it counts was observed, so no count is presented as a
|
|
575
610
|
dated measurement. `mcp_servers` dates its verdicts only by a window, `mcp_servers.window.from`
|
|
576
611
|
to `mcp_servers.window.to`, over checks made at different times, which is not one observation
|
|
577
|
-
time
|
|
612
|
+
time; so, since #2438, do `exposed_databases.window`, `leaked_credentials.window` and
|
|
613
|
+
`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
|
|
578
614
|
API serves one, for `shadow_ai` also `running`, and for `mcp_servers` also `enabled`.
|
|
579
615
|
- `check_affected` is `measured` only when the answer says `assessed` true, and `not_assessed`
|
|
580
616
|
when it says false or does not say. Its `measured_at` and `freshness` are `null`: a match answer
|
|
@@ -626,7 +662,7 @@ Where each tool's input travels:
|
|
|
626
662
|
| Input | Where it is sent |
|
|
627
663
|
|---|---|
|
|
628
664
|
| 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. |
|
|
629
|
-
| `search_cves`: `search` | The `X-EG-Search` request header, never the URL. Its severity, minimum CVSS, sort and
|
|
665
|
+
| `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. |
|
|
630
666
|
| `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. |
|
|
631
667
|
| `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. |
|
|
632
668
|
| `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. |
|
|
@@ -716,8 +752,12 @@ npm test # build, then the behavioural suite over both protocol eras,
|
|
|
716
752
|
npm run smoke # spawn the server + call cve_exposure against the production API
|
|
717
753
|
```
|
|
718
754
|
|
|
719
|
-
`npm run smoke` calls the production API with this package's User-Agent
|
|
720
|
-
|
|
755
|
+
`npm run smoke` calls the production API with this package's User-Agent. Run from our own
|
|
756
|
+
workspace, whose egress address the API recognises as ours, its requests are counted as our own
|
|
757
|
+
traffic and appear in no adoption count. Run from any other machine, they are counted as external
|
|
758
|
+
MCP adoption unless you set `ECHELONGRAPH_MCP_UA` (for example
|
|
759
|
+
`ECHELONGRAPH_MCP_UA=my-smoke/1.0 npm run smoke`), which files them under that token's family
|
|
760
|
+
instead. `npm test` never leaves the machine.
|
|
721
761
|
|
|
722
762
|
`npm test` runs the suite once over a 2026-07-28 `server/discover` and once over a 2025-06-18
|
|
723
763
|
`initialize`, plus the era tests. To run it against an installed package rather than `dist/`,
|
package/dist/index.js
CHANGED
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
// relays each radar's answer cut to the fields it can label by what they count, and names
|
|
16
16
|
// what it left out (readKEVExposure, readExposedDatabases, readLeakedCredentials,
|
|
17
17
|
// readShadowAI and readMCPServers; #2307, #2313, #2315). content[2] is the envelope below,
|
|
18
|
-
// as JSON, less what content[0] and the note already say.
|
|
18
|
+
// as JSON, less what content[0] and the note already say. Past 30,000 characters of JSON,
|
|
19
|
+
// content[0] is that JSON cut to fit and the note ends by saying how (TEXT CUT); the
|
|
20
|
+
// envelope's data is always the answer whole (textBudget.ts, #2783).
|
|
19
21
|
// - failure (isError: true): the lookup did not complete — unreachable host, non-2xx,
|
|
20
22
|
// timeout, or a 2xx whose body is not a JSON object — named by tool, cause and base URL,
|
|
21
23
|
// in content[0]; content[1] is the envelope below, as JSON, less the message's sentences.
|
|
@@ -56,6 +58,7 @@ import { isHttpEntrypoint, upstreamHeaders, upstreamUserAgent } from "./runtime.
|
|
|
56
58
|
import { registerVendorAdvisoryTools } from "./tools/vendor_advisories.js";
|
|
57
59
|
import { registerPrompts } from "./prompts.js";
|
|
58
60
|
import { registerResources } from "./resources.js";
|
|
61
|
+
import { dataText, TEXT_BUDGET_DESCRIPTION } from "./textBudget.js";
|
|
59
62
|
// The package actually running, read from the package.json that ships beside dist/. The MCP
|
|
60
63
|
// handshake (serverInfo) and the User-Agent both carry its name and version, so the API's
|
|
61
64
|
// access log can count adoption per version without a code change per release.
|
|
@@ -169,16 +172,20 @@ function retryAfterSeconds(v) {
|
|
|
169
172
|
// can fail comes back as a typed Failure so the tool renders it as an error result. A 2xx whose
|
|
170
173
|
// body is not a JSON object (an SPA shell, an edge challenge page, a literal null) is a failure
|
|
171
174
|
// too — it is not data. init.headers carries what a caller typed (#1983: X-EG-* headers, never
|
|
172
|
-
// the URL); the User-Agent and Accept are this server's and are not overridden.
|
|
175
|
+
// the URL); the User-Agent and Accept are this server's and are not overridden. init.timeoutMs
|
|
176
|
+
// can only shorten the request's timeout below TIMEOUT_MS, never lengthen it: check_sbom passes
|
|
177
|
+
// what is left of its call's budget, so a slow batch cannot carry the call past it (#2756).
|
|
173
178
|
export async function api(path, init) {
|
|
179
|
+
const limitMs = init?.timeoutMs === undefined ? TIMEOUT_MS : Math.max(0, Math.min(TIMEOUT_MS, Math.floor(init.timeoutMs)));
|
|
174
180
|
const ctrl = new AbortController();
|
|
175
181
|
let timedOut = false;
|
|
176
182
|
const timer = setTimeout(() => {
|
|
177
183
|
timedOut = true;
|
|
178
184
|
ctrl.abort();
|
|
179
|
-
},
|
|
185
|
+
}, limitMs);
|
|
180
186
|
const m = init?.method && init.method.toUpperCase() !== "GET" ? { method: init.method.toUpperCase() } : {};
|
|
181
|
-
const
|
|
187
|
+
const own = limitMs === TIMEOUT_MS ? {} : { timeoutMs: limitMs };
|
|
188
|
+
const timeout = () => ({ ok: false, kind: "timeout", path, ...m, ...own, detail: `no response within ${limitMs} ms` });
|
|
182
189
|
try {
|
|
183
190
|
let res;
|
|
184
191
|
try {
|
|
@@ -241,7 +248,7 @@ export function describeFailure(f) {
|
|
|
241
248
|
case "network":
|
|
242
249
|
return `EchelonGraph at ${SHOWN_BASE} could not be reached for ${verb} ${f.path}: ${f.detail}.`;
|
|
243
250
|
case "timeout":
|
|
244
|
-
return `EchelonGraph at ${SHOWN_BASE} did not answer ${verb} ${f.path} within ${TIMEOUT_MS} ms.`;
|
|
251
|
+
return `EchelonGraph at ${SHOWN_BASE} did not answer ${verb} ${f.path} within ${f.timeoutMs ?? TIMEOUT_MS} ms.`;
|
|
245
252
|
case "http":
|
|
246
253
|
return `EchelonGraph answered HTTP ${f.status} ${where} — the API said: ${f.detail}.`;
|
|
247
254
|
case "not_json":
|
|
@@ -338,12 +345,15 @@ export const checked = (tool, schema, r) => {
|
|
|
338
345
|
const p = schema.safeParse(r.structuredContent);
|
|
339
346
|
return p.success ? r : unexpectedShape(tool, p.error);
|
|
340
347
|
};
|
|
341
|
-
// A success: content[0] is the data
|
|
342
|
-
// the envelope carries both, the note as sentences
|
|
343
|
-
|
|
348
|
+
// A success: content[0] is the data (cut to DATA_TEXT_BUDGET when it is larger, #2783), content[1]
|
|
349
|
+
// the note, content[2] the envelope less both; the envelope carries both, the note as sentences
|
|
350
|
+
// after any the envelope adds about itself. structuredContent.data is the data whole.
|
|
351
|
+
export const succeeded = (data, note, env, cut) => {
|
|
344
352
|
const own = env.notes ?? [];
|
|
345
|
-
const
|
|
346
|
-
|
|
353
|
+
const shown = dataText(data, cut);
|
|
354
|
+
const said = shown.said ? `${note} ${shown.said}` : note;
|
|
355
|
+
const structuredContent = { ...env, notes: [...own, ...sentences(said)], data };
|
|
356
|
+
return { content: [text(shown.text), text(said), envelopeText(structuredContent, said, own)], structuredContent };
|
|
347
357
|
};
|
|
348
358
|
export const okHead = (tool, status) => `${tool} OK: EchelonGraph answered${status === undefined ? "" : ` HTTP ${status}`} from ${SHOWN_BASE}.`;
|
|
349
359
|
// Tolerant readers for the note: a missing or renamed field degrades the sentence, never
|
|
@@ -557,6 +567,24 @@ async function cveSummary() {
|
|
|
557
567
|
return crashed(tool, e);
|
|
558
568
|
}
|
|
559
569
|
}
|
|
570
|
+
// #2783: a page of production rows is about 6,600 characters a row as pretty JSON (38 fields, the
|
|
571
|
+
// description, cpe_match, references and score_factors among them), so the default page of 20 was
|
|
572
|
+
// 133,300 characters of text. Past DATA_TEXT_BUDGET each row in the first text block keeps the
|
|
573
|
+
// fields the description promises and a few more a triage reads, the description cut to 200
|
|
574
|
+
// characters; then, for a page of 50, the fields the description promises with the description cut
|
|
575
|
+
// to 100. structuredContent.data keeps every field of every row; get_cve returns one record whole.
|
|
576
|
+
const SEARCH_CVES_TEXT = {
|
|
577
|
+
rows: "cves",
|
|
578
|
+
levels: [
|
|
579
|
+
{
|
|
580
|
+
keep: ["cve_id", "description", "severity", "cvss_v3_score", "echelongraph_score", "echelongraph_severity", "echelongraph_risk", "score_assessed", "score_unassessed_reason", "epss_score", "kev_listed", "kev_ransomware", "published", "vuln_status", "patch_available", "exploit_poc_available"],
|
|
581
|
+
clip: 200,
|
|
582
|
+
},
|
|
583
|
+
{ keep: ["cve_id", "description", "severity", "cvss_v3_score", "echelongraph_score", "score_assessed", "score_unassessed_reason", "epss_score", "kev_listed", "published"], clip: 100 },
|
|
584
|
+
],
|
|
585
|
+
whole: "get_cve returns any one of these CVEs' records whole.",
|
|
586
|
+
page: (shown, _rows, d) => `To read the rows left out in the text, call search_cves again with the same arguments and offset ${(numAt(d, "offset") ?? 0) + shown}; a page of ${shown} rows or fewer like these fits the text without leaving rows out.`,
|
|
587
|
+
};
|
|
560
588
|
async function searchCVEs(a) {
|
|
561
589
|
const tool = "search_cves";
|
|
562
590
|
try {
|
|
@@ -571,6 +599,8 @@ async function searchCVEs(a) {
|
|
|
571
599
|
if (a.sort)
|
|
572
600
|
q.set("sort", a.sort);
|
|
573
601
|
q.set("limit", String(a.limit ?? 20));
|
|
602
|
+
if (a.offset)
|
|
603
|
+
q.set("offset", String(a.offset));
|
|
574
604
|
const r = await api(`/api/v1/public/cves?${q.toString()}`, a.search ? { headers: { "X-EG-Search": encodeURIComponent(a.search) } } : undefined);
|
|
575
605
|
if (!r.ok)
|
|
576
606
|
return failed(tool, r);
|
|
@@ -610,16 +640,16 @@ async function searchCVEs(a) {
|
|
|
610
640
|
const empty = shown === 0 && (offset ?? 0) === 0;
|
|
611
641
|
return succeeded(r.data, empty
|
|
612
642
|
? `${head} The query returned 0 CVEs — a measured empty result: EchelonGraph was queried successfully and nothing matched these filters (we looked and found nothing). This is not a lookup failure.${tail}`
|
|
613
|
-
: `${head} The API did not count the matches (total_counted is false), so its total is not a count${shown === undefined ? "" : `; ${shown} returned in this page`}.${tail}`, env);
|
|
643
|
+
: `${head} The API did not count the matches (total_counted is false), so its total is not a count${shown === undefined ? "" : `; ${shown} returned in this page`}.${tail}`, env, SEARCH_CVES_TEXT);
|
|
614
644
|
}
|
|
615
645
|
const total = numAt(r.data, "total") ?? shown;
|
|
616
646
|
if (total === undefined)
|
|
617
|
-
return succeeded(r.data, `${head}${tail}`, env);
|
|
647
|
+
return succeeded(r.data, `${head}${tail}`, env, SEARCH_CVES_TEXT);
|
|
618
648
|
if (total === 0 && floor !== true) {
|
|
619
|
-
return succeeded(r.data, `${head} The query matched 0 CVEs — a measured empty result: EchelonGraph was queried successfully and nothing matched these filters (we looked and found nothing). This is not a lookup failure.${tail}`, env);
|
|
649
|
+
return succeeded(r.data, `${head} The query matched 0 CVEs — a measured empty result: EchelonGraph was queried successfully and nothing matched these filters (we looked and found nothing). This is not a lookup failure.${tail}`, env, SEARCH_CVES_TEXT);
|
|
620
650
|
}
|
|
621
651
|
const matched = floor === true ? `at least ${total} CVEs (total_is_lower_bound is true: the count stopped at that floor)` : `${total} CVEs`;
|
|
622
|
-
return succeeded(r.data, `${head} The query matched ${matched}${shown === undefined ? "" : `; ${shown} returned in this page`}.${tail}`, env);
|
|
652
|
+
return succeeded(r.data, `${head} The query matched ${matched}${shown === undefined ? "" : `; ${shown} returned in this page`}.${tail}`, env, SEARCH_CVES_TEXT);
|
|
623
653
|
}
|
|
624
654
|
catch (e) {
|
|
625
655
|
return crashed(tool, e);
|
|
@@ -882,6 +912,7 @@ const SHADOW_AI_STATS = new Set([
|
|
|
882
912
|
"total",
|
|
883
913
|
"by_category",
|
|
884
914
|
"visible_by_category",
|
|
915
|
+
"confirmed_window",
|
|
885
916
|
"last_observation",
|
|
886
917
|
"last_24h_count",
|
|
887
918
|
"last_24h_visible_count",
|
|
@@ -972,6 +1003,34 @@ function leftOut(radar, unknown, unusable) {
|
|
|
972
1003
|
out.push(`Left out of ${radar} because they were not in the expected shape: ${unusable.join(", ")}.`);
|
|
973
1004
|
return out;
|
|
974
1005
|
}
|
|
1006
|
+
function readWindow(v) {
|
|
1007
|
+
if (v === undefined || v === null)
|
|
1008
|
+
return { bad: false };
|
|
1009
|
+
if (!isPlainObject(v))
|
|
1010
|
+
return { bad: true };
|
|
1011
|
+
const from = strAt(v, "from");
|
|
1012
|
+
const to = strAt(v, "to");
|
|
1013
|
+
const undated = v.undated;
|
|
1014
|
+
if (!isCount(undated) || undated < 0 || !Number.isInteger(undated))
|
|
1015
|
+
return { bad: true };
|
|
1016
|
+
if (undated > 0)
|
|
1017
|
+
return { undated, bad: false };
|
|
1018
|
+
if (v.from === null && v.to === null)
|
|
1019
|
+
return { bad: false }; // nothing counted, so nothing dated
|
|
1020
|
+
if (!realInstant(from) || !realInstant(to) || Date.parse(from) > Date.parse(to))
|
|
1021
|
+
return { bad: true };
|
|
1022
|
+
return { window: { from, to }, bad: false };
|
|
1023
|
+
}
|
|
1024
|
+
// The sentence for a radar's window, or for the rows it leaves undated.
|
|
1025
|
+
function windowSentences(prefix, w, what) {
|
|
1026
|
+
if (w.window) {
|
|
1027
|
+
return [`${prefix}.window.from (${w.window.from}) and ${prefix}.window.to (${w.window.to}) are timestamps, not counts: ${what}. They span observations made at different times, not one observation time.`];
|
|
1028
|
+
}
|
|
1029
|
+
if (w.undated !== undefined) {
|
|
1030
|
+
return [`${prefix} carries no window: ${w.undated} of the rows it counts have no observation time on record, so a window over the rest would not describe them.`];
|
|
1031
|
+
}
|
|
1032
|
+
return [];
|
|
1033
|
+
}
|
|
975
1034
|
function readRadar(body, spec) {
|
|
976
1035
|
const src = isPlainObject(body) ? body : {};
|
|
977
1036
|
const data = {};
|
|
@@ -1001,9 +1060,18 @@ function readRadar(body, spec) {
|
|
|
1001
1060
|
else if (present(k) && typeof src[k] !== "string")
|
|
1002
1061
|
unusable.push(k);
|
|
1003
1062
|
}
|
|
1004
|
-
|
|
1063
|
+
let undated;
|
|
1064
|
+
if (spec.window) {
|
|
1065
|
+
const w = readWindow(src[spec.window]);
|
|
1066
|
+
if (w.window)
|
|
1067
|
+
data.window = w.window;
|
|
1068
|
+
else if (w.bad)
|
|
1069
|
+
unusable.push(spec.window);
|
|
1070
|
+
undated = w.undated;
|
|
1071
|
+
}
|
|
1072
|
+
const known = new Set([...spec.counts, ...Object.keys(spec.rows), ...spec.instants, ...(spec.window ? [spec.window] : [])]);
|
|
1005
1073
|
unknown.unshift(...Object.keys(src).filter((k) => !known.has(k)));
|
|
1006
|
-
return { data, unknown, unusable };
|
|
1074
|
+
return { data, unknown, unusable, undated };
|
|
1007
1075
|
}
|
|
1008
1076
|
// ── last_run_at: when a radar last COMPLETED a check (#2335) ──
|
|
1009
1077
|
//
|
|
@@ -1201,9 +1269,10 @@ const EXPOSED_DB_SPEC = {
|
|
|
1201
1269
|
top_countries: { required: { country: "string", hosts: "number" } },
|
|
1202
1270
|
},
|
|
1203
1271
|
instants: ["generated_at", "last_run_at"],
|
|
1272
|
+
window: "observed_window",
|
|
1204
1273
|
};
|
|
1205
1274
|
function readExposedDatabases(body) {
|
|
1206
|
-
const { data, unknown, unusable } = readRadar(body, EXPOSED_DB_SPEC);
|
|
1275
|
+
const { data, unknown, unusable, undated } = readRadar(body, EXPOSED_DB_SPEC);
|
|
1207
1276
|
const sentences = [];
|
|
1208
1277
|
const n = (k) => numIn(data, k);
|
|
1209
1278
|
const totals = [
|
|
@@ -1226,6 +1295,7 @@ function readExposedDatabases(body) {
|
|
|
1226
1295
|
if (gated.length) {
|
|
1227
1296
|
sentences.push(`${gated.join("; ")}. The names are index, database, table or field names in the Shodan or LeakIX banner or in the check's response, never record values, and the gate passes one unambiguous term (such as ssn or cardholder) or two distinct indicators. It is precision-first: a service whose names do not pass it is not counted, whatever it holds, so the other services are not shown to hold no personal or card data.`);
|
|
1228
1297
|
}
|
|
1298
|
+
sentences.push(...windowSentences("exposed_databases", { window: data.window, undated, bad: false }, "when EchelonGraph's check last confirmed the oldest and the newest of the services counted answering without authentication"));
|
|
1229
1299
|
sentences.push(...lastCheck("exposed_databases", data));
|
|
1230
1300
|
sentences.push(...leftOut("exposed_databases", unknown, unusable));
|
|
1231
1301
|
return { data, sentences };
|
|
@@ -1249,9 +1319,10 @@ const LEAKED_CREDS_SPEC = {
|
|
|
1249
1319
|
top_types: { required: { secret_type: "string", count: "number" } },
|
|
1250
1320
|
},
|
|
1251
1321
|
instants: ["generated_at", "last_run_at"],
|
|
1322
|
+
window: "observed_window",
|
|
1252
1323
|
};
|
|
1253
1324
|
function readLeakedCredentials(body) {
|
|
1254
|
-
const { data, unknown, unusable } = readRadar(body, LEAKED_CREDS_SPEC);
|
|
1325
|
+
const { data, unknown, unusable, undated } = readRadar(body, LEAKED_CREDS_SPEC);
|
|
1255
1326
|
const sentences = [];
|
|
1256
1327
|
const n = (k) => numIn(data, k);
|
|
1257
1328
|
const totals = [
|
|
@@ -1271,6 +1342,7 @@ function readLeakedCredentials(body) {
|
|
|
1271
1342
|
if (totals.length || rankings.length) {
|
|
1272
1343
|
sentences.push("None of these is validated: each is a credential-shaped string in a public commit that passed EchelonGraph's filters, at most structurally checked (a checksum or format decode) and never tested against its provider, so none of them is a count of working credentials.");
|
|
1273
1344
|
}
|
|
1345
|
+
sentences.push(...windowSentences("leaked_credentials", { window: data.window, undated, bad: false }, "when EchelonGraph's detector last found the oldest and the newest of the (repository, secret) pairs counted in a public commit"));
|
|
1274
1346
|
sentences.push(...lastCheck("leaked_credentials", data));
|
|
1275
1347
|
sentences.push(...leftOut("leaked_credentials", unknown, unusable));
|
|
1276
1348
|
return { data, sentences };
|
|
@@ -1295,6 +1367,13 @@ function readShadowAI(sa) {
|
|
|
1295
1367
|
const last24Visible = take("last_24h_visible_count", numAt(stats, "last_24h_visible_count"));
|
|
1296
1368
|
if (last24Visible !== undefined)
|
|
1297
1369
|
confirmed.last_24h = last24Visible;
|
|
1370
|
+
// When the confirmed rows were observed (#2438): the verifier's deciding probe times, over the
|
|
1371
|
+
// rows visible_by_category counts. Relayed only beside the count it dates.
|
|
1372
|
+
const confirmedWindow = readWindow(field(stats, "confirmed_window"));
|
|
1373
|
+
if (!confirmedWindow.bad && field(stats, "confirmed_window") !== undefined)
|
|
1374
|
+
used.add("confirmed_window");
|
|
1375
|
+
if (confirmedWindow.window && confirmed.total !== undefined)
|
|
1376
|
+
confirmed.window = confirmedWindow.window;
|
|
1298
1377
|
// Observed: every row of shadow_ct_observations, whatever its liveness.
|
|
1299
1378
|
const observed = {};
|
|
1300
1379
|
const total = take("total", numAt(stats, "total"));
|
|
@@ -1330,6 +1409,9 @@ function readShadowAI(sa) {
|
|
|
1330
1409
|
sentences.push(confirmed.total === undefined
|
|
1331
1410
|
? `Shadow AI: the answer carries no usable stats.visible_by_category, so it does not say how many services are confirmed exposed${last24Visible === undefined ? "" : `; confirmed_exposed.last_24h (${last24Visible}) counts confirmed-exposed services first recorded in the last 24 h`}.`
|
|
1332
1411
|
: `Shadow AI confirmed exposed: ${confirmed.total} (confirmed_exposed.total, the sum of confirmed_exposed.by_category: services EchelonGraph's probes found answering without an authentication gate, with liveness active or, during a re-check, rechecking)${last24Visible === undefined ? "" : `, ${last24Visible} of them first recorded in the last 24 h (confirmed_exposed.last_24h)`}. Of the shadow_ai numbers, only confirmed_exposed counts exposed services.`);
|
|
1412
|
+
if (confirmed.total !== undefined) {
|
|
1413
|
+
sentences.push(...windowSentences("shadow_ai.confirmed_exposed", { window: confirmedWindow.window, undated: confirmedWindow.undated, bad: false }, "when EchelonGraph's verifier ran the probe that last decided the oldest and the newest of the services confirmed_exposed counts"));
|
|
1414
|
+
}
|
|
1333
1415
|
if (total !== undefined) {
|
|
1334
1416
|
sentences.push(`Shadow AI observed: ${total} (observed.total) counts every Certificate Transparency or Shodan observation on record, whatever its verification state: that many observed, not that many exposed.`);
|
|
1335
1417
|
}
|
|
@@ -1621,9 +1703,9 @@ function readMCPServers(src) {
|
|
|
1621
1703
|
}
|
|
1622
1704
|
const EXPOSURE_RADAR_DESCRIPTION = `Aggregate totals from EchelonGraph's internet-exposure radars, each refreshed on its own schedule: internet-facing services running actively-exploited (CISA-KEV) CVEs, plus the ransomware-linked subset, derived from Shodan data; 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 (not a pure read: on Redis it names its client, and on ClickHouse its query is recorded in the server's query log); leaked credentials sampled from public GitHub push events; and shadow AI services found through Certificate Transparency logs and Shodan and then checked by EchelonGraph's identified probes. It also gives MCP-server counts: hostnames named like an MCP server in EchelonGraph's own Certificate Transparency feed (no Shodan data), each counted once by its latest verdict from EchelonGraph's identified MCP probe, which never sends tools/call. Every number in the result is labelled here and in the result's note by what it counts; a field this version cannot label is left out and named in the note. ` +
|
|
1623
1705
|
`kev_exposure: kev_exposure.distinct_hosts counts distinct ip:port services, not machines (a machine answering on two ports counts twice), with at least one CISA-KEV-listed CVE on record, and kev_exposure.ransomware_hosts those with a ransomware-linked one; kev_exposure.kev_cves_exposed and kev_exposure.ransomware_cves count distinct CVEs with at least one such service; kev_exposure.correlations counts service×CVE pairs, not services, so a service with three KEV CVEs counts three times. kev_exposure.top_products, kev_exposure.top_countries and kev_exposure.top_cves rank up to 12 products, 10 countries and 12 CVEs by those services; kev_exposure.top_cves[].cvss_v3_score and kev_exposure.top_cves[].epss_score are the highest CVSS v3 base score and EPSS probability recorded on that CVE's observations. kev_exposure.trend counts service×CVE pairs still on record by the week in which each was first recorded, over 12 weeks, so earlier weeks read low. kev_exposure.newest_kev lists the 15 CVEs that EchelonGraph's CVE records most recently mark as CISA-KEV-listed, each with an exposure_state: exposed, where kev_exposure.newest_kev[].exposed_hosts counts distinct ip:port services on record with it; or not_assessed, where the API's answer holds no measurement for that CVE (its 0 is not one) and no count is relayed, and cve_exposure says per CVE whether the radar tracks it. kev_exposure.newest_kev[].cvss_v3_score and kev_exposure.newest_kev[].epss_score are the CVE record's CVSS v3 base score and EPSS probability. ` +
|
|
1624
|
-
`exposed_databases: exposed_databases.distinct_hosts counts distinct ip:port services the check confirmed answering without authentication, and exposed_databases.engines the distinct engine types among them; exposed_databases.top_engines and exposed_databases.top_countries rank up to 15 engines and 10 countries by those services. exposed_databases.pii_likely and exposed_databases.pci_likely count services whose schema names (never record values) pass a high-confidence, precision-first gate for personal or payment-card data, so a service outside them is not shown to hold no such data. ` +
|
|
1625
|
-
`leaked_credentials: leaked_credentials.total counts (repository, secret) pairs, not distinct secrets, so one secret in three repositories counts three times; leaked_credentials.distinct_secrets counts each secret once and leaked_credentials.distinct_repos counts repositories; leaked_credentials.top_providers and leaked_credentials.top_types rank up to 15 providers and secret types by those pairs. None is validated: each is a credential-shaped string that passed EchelonGraph's filters, at most structurally checked and never tested against its provider. Each of those three radars also carries generated_at, when the API computed its totals, and, when the API can tell, last_run_at. kev_exposure.last_run_at, exposed_databases.last_run_at and leaked_credentials.last_run_at are timestamps, not counts: each is when that radar last completed a check, a cycle whose reads succeeded, among them a Shodan search (for exposed_databases, or its LeakIX fallback) that answered at least one query, or for leaked_credentials a read of the public GitHub event stream. A cycle that read nothing does not move it, and it is not the time of every record a radar's numbers count, which cover everything still on record, not only what the last check found. A radar whose answer carries no last_run_at has none in the result, and the note says nothing about it. ` +
|
|
1626
|
-
`shadow_ai: the result is regrouped by what each number counts. shadow_ai.confirmed_exposed counts services EchelonGraph's probes found answering without an authentication gate (liveness active, or rechecking during a re-check): confirmed_exposed.total is the sum of confirmed_exposed.by_category, and confirmed_exposed.last_24h counts those first recorded in the last 24 h. Of the shadow_ai numbers, only confirmed_exposed counts exposed services. shadow_ai.observed counts every Certificate Transparency or Shodan observation on record, whatever its verification state: its numbers are observed, not exposed. They are observed.total; observed.by_category (the same observations by category); observed.last_24h (those first recorded in the last 24 h); observed.trend_30d (observations per UTC day over the last 30 days); and observed.top_products, observed.top_countries and observed.top_issuers (up to ten products, countries and issuers ranked by observations, where an issuer is the certificate's CA for a Certificate Transparency observation and the hosting operator Shodan reports for a Shodan one). shadow_ai.authentication counts observations by probe outcome: authentication.observed where a probe observed an authentication gate (a 401/403, a login page or an auth marker), and authentication.not_determined where the service answered but no probe could tell. Neither authentication count is part of confirmed_exposed, and observed.total minus confirmed_exposed.total is not a count of secured services. The shadow-AI poller block carries only running and last_run_at: last_run_at is when the radar's leader last completed a Certificate Transparency (crt.sh) cycle, and its running is true only when that was within ${SHADOW_AI_SILENT_AFTER} of the answer. ` +
|
|
1706
|
+
`exposed_databases: exposed_databases.distinct_hosts counts distinct ip:port services the check confirmed answering without authentication, and exposed_databases.engines the distinct engine types among them; exposed_databases.top_engines and exposed_databases.top_countries rank up to 15 engines and 10 countries by those services. exposed_databases.pii_likely and exposed_databases.pci_likely count services whose schema names (never record values) pass a high-confidence, precision-first gate for personal or payment-card data, so a service outside them is not shown to hold no such data. exposed_databases.window.from and exposed_databases.window.to are timestamps, not counts: when EchelonGraph's check last confirmed the oldest and the newest of the services counted, a span of checks made at different times; with a counted service that has no such time on record there is no window. ` +
|
|
1707
|
+
`leaked_credentials: leaked_credentials.total counts (repository, secret) pairs, not distinct secrets, so one secret in three repositories counts three times; leaked_credentials.distinct_secrets counts each secret once and leaked_credentials.distinct_repos counts repositories; leaked_credentials.top_providers and leaked_credentials.top_types rank up to 15 providers and secret types by those pairs. None is validated: each is a credential-shaped string that passed EchelonGraph's filters, at most structurally checked and never tested against its provider. leaked_credentials.window.from and leaked_credentials.window.to are timestamps, not counts: when EchelonGraph's detector last found the oldest and the newest of the pairs counted. Each of those three radars also carries generated_at, when the API computed its totals, and, when the API can tell, last_run_at. kev_exposure.last_run_at, exposed_databases.last_run_at and leaked_credentials.last_run_at are timestamps, not counts: each is when that radar last completed a check, a cycle whose reads succeeded, among them a Shodan search (for exposed_databases, or its LeakIX fallback) that answered at least one query, or for leaked_credentials a read of the public GitHub event stream. A cycle that read nothing does not move it, and it is not the time of every record a radar's numbers count, which cover everything still on record, not only what the last check found. A radar whose answer carries no last_run_at has none in the result, and the note says nothing about it. ` +
|
|
1708
|
+
`shadow_ai: the result is regrouped by what each number counts. shadow_ai.confirmed_exposed counts services EchelonGraph's probes found answering without an authentication gate (liveness active, or rechecking during a re-check): confirmed_exposed.total is the sum of confirmed_exposed.by_category, and confirmed_exposed.last_24h counts those first recorded in the last 24 h. Of the shadow_ai numbers, only confirmed_exposed counts exposed services. confirmed_exposed.window.from and confirmed_exposed.window.to are timestamps, not counts: when EchelonGraph's verifier ran the probe that last decided the oldest and the newest of those services, a span of probes made at different times. shadow_ai.observed counts every Certificate Transparency or Shodan observation on record, whatever its verification state: its numbers are observed, not exposed. They are observed.total; observed.by_category (the same observations by category); observed.last_24h (those first recorded in the last 24 h); observed.trend_30d (observations per UTC day over the last 30 days); and observed.top_products, observed.top_countries and observed.top_issuers (up to ten products, countries and issuers ranked by observations, where an issuer is the certificate's CA for a Certificate Transparency observation and the hosting operator Shodan reports for a Shodan one). shadow_ai.authentication counts observations by probe outcome: authentication.observed where a probe observed an authentication gate (a 401/403, a login page or an auth marker), and authentication.not_determined where the service answered but no probe could tell. Neither authentication count is part of confirmed_exposed, and observed.total minus confirmed_exposed.total is not a count of secured services. The shadow-AI poller block carries only running and last_run_at: last_run_at is when the radar's leader last completed a Certificate Transparency (crt.sh) cycle, and its running is true only when that was within ${SHADOW_AI_SILENT_AFTER} of the answer. ` +
|
|
1627
1709
|
`mcp_servers: counts and timestamps only, no hostname. mcp_servers.total counts hostnames the AI-exposure radar has checked for an MCP server, each once by its latest verdict on record, and not every one is an MCP server; EchelonGraph's own control servers are left out, and mcp_servers.own_controls_excluded counts them. mcp_servers.total is mcp_servers.protected plus mcp_servers.pending_readjudication plus mcp_servers.not_assessed. mcp_servers.protected counts hostnames whose /mcp endpoint asked for credentials and whose OAuth protected-resource metadata validated under RFC 9728; mcp_servers.prm_via divides them by where that document was found: mcp_servers.prm_via.header, mcp_servers.prm_via.wellknown_path and mcp_servers.prm_via.wellknown_root. mcp_servers.pending_readjudication counts verdicts of a rule since replaced, not yet re-checked. mcp_servers.not_assessed counts the rest, whose protection the radar could not assess (not assessed does not mean unprotected), and mcp_servers.not_assessed_by_reason puts each in one bucket. mcp_servers.not_assessed_by_reason.identified_no_challenge holds servers that identified themselves as MCP servers and did not ask for credentials at the handshake. That is normal in MCP: authorization is optional in the spec, and a server can enforce it at tools/call instead, which EchelonGraph never sends, so this bucket is not a finding of exposure. ${MCP_CHALLENGE_REASON_FIELDS} hold endpoints that asked for credentials but whose RFC 9728 metadata did not validate, by why, so none of them is shown to lack protection; mcp_servers.not_assessed_by_reason.challenge_unadjudicated holds endpoints that asked for credentials before that check existed, not yet re-checked. mcp_servers.not_assessed_by_reason.no_http_answer holds hostnames that gave no HTTP answer, and mcp_servers.not_assessed_by_reason.not_identified_as_mcp hostnames whose HTTP answer identified no MCP server. mcp_servers.era divides every counted hostname by protocol era: mcp_servers.era.legacy; mcp_servers.era.dual, a lower bound; mcp_servers.era.modern, not proven modern-only; mcp_servers.era.unknown; and mcp_servers.era.not_measured, recorded before the era probe and not re-checked since. mcp_servers.transport divides them by transport: mcp_servers.transport.streamable_http, mcp_servers.transport.legacy_sse, mcp_servers.transport.unknown and mcp_servers.transport.not_measured. mcp_servers.window.from and mcp_servers.window.to are when the oldest and the newest of the verdicts counted were last checked. mcp_servers.last_run_at is a timestamp, not a count: when the AI-exposure radar, which checks other AI services too, last completed a check; mcp_servers.enabled is whether one completed within ${MCP_ENABLED_WITHIN} of the answer, and mcp_servers.counted_at is when the API read the counts. A partition whose buckets do not add up to the count it divides is left out and named; an answer that does not say it is the MCP-server counts, or whose mcp_servers.total, mcp_servers.protected, mcp_servers.pending_readjudication and mcp_servers.not_assessed are missing or contradict each other, is a failure. ${SHODAN_OWNERSHIP}`;
|
|
1628
1710
|
// exposure_radar's envelope. Every radar answered, but no stats answer says when the services
|
|
1629
1711
|
// or records it counts were observed: each count covers everything that radar still holds on
|
|
@@ -1646,7 +1728,7 @@ const EXPOSURE_RADAR_DESCRIPTION = `Aggregate totals from EchelonGraph's interne
|
|
|
1646
1728
|
// and the envelope's own note names it.
|
|
1647
1729
|
const RADAR_METHOD = "Aggregate counts over what each radar holds on record: kev_exposure, Shodan banners whose version maps to a CISA-KEV-listed CVE; exposed_databases, services found through Shodan (LeakIX when Shodan query credits run low) and confirmed by EchelonGraph's own identified check; leaked_credentials, credential-shaped strings in public GitHub push events; shadow_ai, services found through Certificate Transparency logs and Shodan and checked by EchelonGraph's identified probes; mcp_servers, the latest verdict on record per hostname named like an MCP server in EchelonGraph's own Certificate Transparency feed, from EchelonGraph's identified MCP probe (server/discover, and initialize only if that is refused; never tools/call), where protected means the endpoint's RFC 9728 protected-resource metadata validated.";
|
|
1648
1730
|
const RADAR_NAMES = RADARS.map(([name]) => name);
|
|
1649
|
-
const RADAR_STATE_NOTE = "state is not_assessed: every radar answered, but none gives one time at which what it counts was observed: kev_exposure, exposed_databases, leaked_credentials
|
|
1731
|
+
const RADAR_STATE_NOTE = "state is not_assessed: every radar answered, but none gives one time at which what it counts was observed: kev_exposure gives none, and exposed_databases, leaked_credentials, shadow_ai's confirmed_exposed and mcp_servers date what they count at most by a window (exposed_databases.window, leaked_credentials.window, shadow_ai.confirmed_exposed.window, mcp_servers.window), from the oldest observation among the rows counted to the newest, so no count here is presented as a dated measurement and measured_at is null. Each count is what that radar holds on record, and the note labels each one by what it counts.";
|
|
1650
1732
|
const RADAR_FRESHNESS_NOTE = "freshness gives each radar's last completed check where the API serves one (freshness.kev_exposure.last_run_at, freshness.exposed_databases.last_run_at, freshness.leaked_credentials.last_run_at, freshness.shadow_ai.last_run_at with freshness.shadow_ai.running, and freshness.mcp_servers.last_run_at with freshness.mcp_servers.enabled), and null where it does not.";
|
|
1651
1733
|
async function exposureRadar() {
|
|
1652
1734
|
const tool = "exposure_radar";
|
|
@@ -1902,10 +1984,13 @@ const zRow = (spec) => z.strictObject({
|
|
|
1902
1984
|
...Object.fromEntries(Object.entries(spec.required).map(([k, kind]) => [k, zKind(kind)])),
|
|
1903
1985
|
...Object.fromEntries(Object.entries(spec.optional ?? {}).map(([k, kind]) => [k, zKind(kind).optional()])),
|
|
1904
1986
|
});
|
|
1987
|
+
// A relayed observation window (#2438): two instants, never a count.
|
|
1988
|
+
const Window = z.strictObject({ from: Instant, to: Instant });
|
|
1905
1989
|
const zRadar = (spec, rows = {}) => z.strictObject({
|
|
1906
1990
|
...Object.fromEntries(spec.counts.map((k) => [k, z.number().optional()])),
|
|
1907
1991
|
...Object.fromEntries(Object.entries(spec.rows).map(([k, rs]) => [k, (rows[k] ?? z.array(zRow(rs))).optional()])),
|
|
1908
1992
|
...Object.fromEntries(spec.instants.map((k) => [k, Instant.optional()])),
|
|
1993
|
+
...(spec.window ? { window: Window.optional() } : {}),
|
|
1909
1994
|
});
|
|
1910
1995
|
// A newest_kev row as newestKEVRow relays it: `tracked` is read, not relayed, and a count only
|
|
1911
1996
|
// when the row's exposure_state is not not_assessed.
|
|
@@ -1919,7 +2004,9 @@ const NewestKEVRow = z.strictObject({
|
|
|
1919
2004
|
const CountMap = z.record(z.string(), z.number());
|
|
1920
2005
|
const countRowsOf = (label) => z.array(z.strictObject({ [label]: z.string(), count: z.number() }));
|
|
1921
2006
|
const ShadowAIData = z.strictObject({
|
|
1922
|
-
confirmed_exposed: z
|
|
2007
|
+
confirmed_exposed: z
|
|
2008
|
+
.strictObject({ total: z.number().optional(), by_category: CountMap.optional(), last_24h: z.number().optional(), window: Window.optional() })
|
|
2009
|
+
.optional(),
|
|
1923
2010
|
observed: z
|
|
1924
2011
|
.strictObject({
|
|
1925
2012
|
total: z.number().optional(),
|
|
@@ -1986,6 +2073,7 @@ const INSTRUCTIONS = [
|
|
|
1986
2073
|
"Everything these tools return is public: EchelonGraph's CVE Pulse feed, and aggregate, host-redacted totals from its exposure radars.",
|
|
1987
2074
|
"Every result says how it was measured: state, measured_at, method, coverage, freshness and notes, in its structuredContent and again in its text.",
|
|
1988
2075
|
"Its last text block is that structuredContent as JSON, less what an earlier text block already gives verbatim: data, which is a success's first text block; the sentences of the text block just before it (the note, or a failure's message), with which structuredContent's notes end; and method, where that block quotes it.",
|
|
2076
|
+
"Past 30,000 characters of JSON, that first text block holds data cut to fit, and the note says what the cut leaves out and where to read it (TEXT CUT); structuredContent's data always holds it whole.",
|
|
1989
2077
|
"state is measured, not_assessed, failed or invalid_input.",
|
|
1990
2078
|
"not_assessed means the answer holds no dated measurement of what was asked, so no count in it is presented as one.",
|
|
1991
2079
|
"It can still relay a count, as what the source holds on record, undated, and its notes say what each count is: cve_exposure's exposed_hosts when its exposure_state is exposed, and exposure_radar's labelled totals, are such counts.",
|
|
@@ -2026,40 +2114,42 @@ export function createServer() {
|
|
|
2026
2114
|
const server = new McpServer({ name: NAME, version: VERSION }, { instructions: INSTRUCTIONS, capabilities: { tools: { listChanged: false }, prompts: { listChanged: false }, resources: { listChanged: false } } });
|
|
2027
2115
|
server.registerTool("cve_summary", {
|
|
2028
2116
|
title: "CVE feed summary",
|
|
2029
|
-
description: `Summary of EchelonGraph's CVE Pulse feed: summary.total active CVEs, their counts by severity band (summary.critical, summary.high, summary.medium, summary.low), the count with no band (summary.none), and summary.last_updated, the newest modification time among those records. ${SUMMARY_NONE_DESCRIPTION} ${SUMMARY_NVD_DESCRIPTION} ${SUMMARY_REJECTED_DESCRIPTION} ${SUMMARY_POLLER_DESCRIPTION} The feed is polled from its sources on a schedule, so this is the state as of that update. ${FEED_ENVELOPE}`,
|
|
2117
|
+
description: `Summary of EchelonGraph's CVE Pulse feed: summary.total active CVEs, their counts by severity band (summary.critical, summary.high, summary.medium, summary.low), the count with no band (summary.none), and summary.last_updated, the newest modification time among those records. ${SUMMARY_NONE_DESCRIPTION} ${SUMMARY_NVD_DESCRIPTION} ${SUMMARY_REJECTED_DESCRIPTION} ${SUMMARY_POLLER_DESCRIPTION} The feed is polled from its sources on a schedule, so this is the state as of that update. ${FEED_ENVELOPE} ${TEXT_BUDGET_DESCRIPTION}`,
|
|
2030
2118
|
outputSchema: CVE_SUMMARY_OUTPUT,
|
|
2031
2119
|
annotations: ANNOTATIONS,
|
|
2032
2120
|
}, async () => checked("cve_summary", CVE_SUMMARY_OUTPUT, await cveSummary()));
|
|
2033
2121
|
server.registerTool("search_cves", {
|
|
2034
2122
|
title: "Search CVEs",
|
|
2035
|
-
description: `Search/list CVEs from EchelonGraph's CVE feed (NVD + MITRE-CNA pre-NVD + CISA-KEV + EPSS + GitHub GHSA, each polled on a schedule). Filter by severity, minimum CVSS, free text, and sort. Returns cves, each with cve_id, severity, cvss_v3_score, echelongraph_score and score_assessed (whether EchelonGraph has scored it), epss_score and kev_listed where the record has them, and the list's total, total_counted (false: the matches were not counted, so total is not a count), total_is_lower_bound (true: at least total), search_relaxed (true: a phrase was relaxed to all of its words), limit and offset. ${SCORE_ASSESSED_DESCRIPTION} ${FEED_ENVELOPE} coverage repeats total, total_counted, total_is_lower_bound, search_relaxed, limit and offset, and gives returned, the rows in this page.`,
|
|
2123
|
+
description: `Search/list CVEs from EchelonGraph's CVE feed (NVD + MITRE-CNA pre-NVD + CISA-KEV + EPSS + GitHub GHSA, each polled on a schedule). Filter by severity, minimum CVSS, free text, and sort; page with limit and offset. Returns cves, each with cve_id, severity, cvss_v3_score, echelongraph_score and score_assessed (whether EchelonGraph has scored it), epss_score and kev_listed where the record has them, and the list's total, total_counted (false: the matches were not counted, so total is not a count), total_is_lower_bound (true: at least total), search_relaxed (true: a phrase was relaxed to all of its words), limit and offset. ${SCORE_ASSESSED_DESCRIPTION} ${FEED_ENVELOPE} coverage repeats total, total_counted, total_is_lower_bound, search_relaxed, limit and offset, and gives returned, the rows in this page. ${TEXT_BUDGET_DESCRIPTION} Cut, each row keeps at least the fields named above and the first 200 characters of its description (100 on a page too long for that), or rows are left out and the note gives the offset to call next.`,
|
|
2036
2124
|
inputSchema: z.object({
|
|
2037
2125
|
search: z.string().optional().describe("free-text search (product, vendor, or keyword, e.g. 'tomcat')"),
|
|
2038
2126
|
severity: z.enum(["CRITICAL", "HIGH", "MEDIUM", "LOW"]).optional().describe("filter to one severity"),
|
|
2039
2127
|
min_cvss: z.number().min(0).max(10).optional().describe("minimum CVSS score"),
|
|
2040
2128
|
sort: z.enum(["published", "modified", "nvd", "echelongraph", "epss"]).optional().describe("sort order (default: published)"),
|
|
2041
2129
|
limit: z.number().int().min(1).max(50).optional().describe("page size (default 20, max 50)"),
|
|
2130
|
+
// #2783: the next page, which the note names when the text leaves rows out.
|
|
2131
|
+
offset: z.number().int().min(0).max(10000).optional().describe("rows to skip (default 0)"),
|
|
2042
2132
|
}),
|
|
2043
2133
|
outputSchema: SEARCH_CVES_OUTPUT,
|
|
2044
2134
|
annotations: ANNOTATIONS,
|
|
2045
2135
|
}, async (a) => checked("search_cves", SEARCH_CVES_OUTPUT, await searchCVEs(a)));
|
|
2046
2136
|
server.registerTool("get_cve", {
|
|
2047
2137
|
title: "CVE detail",
|
|
2048
|
-
description: `Full record for one CVE: description, severity, cvss_v3_score, cvss_v4_score and cvss_v4_severity (when the record has a CVSS v4 score), echelongraph_score and echelongraph_severity with score_confidence (EchelonGraph's multi-source score) and score_assessed (whether EchelonGraph has scored it), epss_score and epss_percentile, CISA-KEV status (kev_listed, kev_added_date, and kev_ransomware for known ransomware-campaign use), ghsa_id (GitHub GHSA), references, cpe_match (the CPE criteria as one flat list) and cpe_configurations (NVD's configurations as NVD sent them, with each AND/OR operator, negate, versionStartExcluding and matchCriteriaId; absent where EchelonGraph has stored none, which is not a finding that no product is affected), published, modified and updated_at; each field only where the record has it. Pass a CVE ID like CVE-2023-44487. ${SCORE_ASSESSED_DESCRIPTION} ${FEED_ENVELOPE}
|
|
2138
|
+
description: `Full record for one CVE: description, severity, cvss_v3_score, cvss_v4_score and cvss_v4_severity (when the record has a CVSS v4 score), echelongraph_score and echelongraph_severity with score_confidence (EchelonGraph's multi-source score) and score_assessed (whether EchelonGraph has scored it), epss_score and epss_percentile, CISA-KEV status (kev_listed, kev_added_date, and kev_ransomware for known ransomware-campaign use), ghsa_id (GitHub GHSA), references, cpe_match (the CPE criteria as one flat list) and cpe_configurations (NVD's configurations as NVD sent them, with each AND/OR operator, negate, versionStartExcluding and matchCriteriaId; absent where EchelonGraph has stored none, which is not a finding that no product is affected), published, modified and updated_at; each field only where the record has it. Pass a CVE ID like CVE-2023-44487. ${SCORE_ASSESSED_DESCRIPTION} ${FEED_ENVELOPE} ${TEXT_BUDGET_DESCRIPTION} Cut, each list in the record keeps its first entries, and the note names each list cut with its full length.`,
|
|
2049
2139
|
inputSchema: z.object({ cve_id: CVE_ID_ARG }),
|
|
2050
2140
|
outputSchema: GET_CVE_OUTPUT,
|
|
2051
2141
|
annotations: ANNOTATIONS,
|
|
2052
2142
|
}, async ({ cve_id }) => checked("get_cve", GET_CVE_OUTPUT, await getCVE(cve_id)));
|
|
2053
2143
|
server.registerTool("cve_exposure", {
|
|
2054
2144
|
title: "Internet exposure for one CVE",
|
|
2055
|
-
description: `${CVE_EXPOSURE_DESCRIPTION} Its structured result's state is not_assessed with measured_at null: the per-CVE answer says when EchelonGraph last wrote a row (last_seen), not when any counted service was observed, so no count is presented as a dated measurement; the count is still relayed, labelled, as what the radar holds on record. exposure_state says what the count is: exposed, measured_zero, not_assessed or tracking_unknown; coverage.in_scope is the API's tracked verdict; freshness is null, since the per-CVE answer carries no last completed check; data is the API's JSON. The result's last text block repeats the structured result without data (the first text block), without the note's sentences (the text block before it), with which notes ends, and without method where the note quotes it verbatim ("Method: …")
|
|
2145
|
+
description: `${CVE_EXPOSURE_DESCRIPTION} Its structured result's state is not_assessed with measured_at null: the per-CVE answer says when EchelonGraph last wrote a row (last_seen), not when any counted service was observed, so no count is presented as a dated measurement; the count is still relayed, labelled, as what the radar holds on record. exposure_state says what the count is: exposed, measured_zero, not_assessed or tracking_unknown; coverage.in_scope is the API's tracked verdict; freshness is null, since the per-CVE answer carries no last completed check; data is the API's JSON. The result's last text block repeats the structured result without data (the first text block), without the note's sentences (the text block before it), with which notes ends, and without method where the note quotes it verbatim ("Method: …"). ${TEXT_BUDGET_DESCRIPTION}`,
|
|
2056
2146
|
inputSchema: z.object({ cve_id: CVE_ID_ARG }),
|
|
2057
2147
|
outputSchema: CVE_EXPOSURE_OUTPUT,
|
|
2058
2148
|
annotations: ANNOTATIONS,
|
|
2059
2149
|
}, async ({ cve_id }) => checked("cve_exposure", CVE_EXPOSURE_OUTPUT, await cveExposure(cve_id)));
|
|
2060
2150
|
server.registerTool("exposure_radar", {
|
|
2061
2151
|
title: "Exposure radar totals",
|
|
2062
|
-
description: `${EXPOSURE_RADAR_DESCRIPTION} Its structured result's state is not_assessed with measured_at null: every radar answered, but none gives one time at which what it counts was observed (mcp_servers dates its verdicts at most by a window, mcp_servers.window), so no count is presented as a dated measurement; each count is still relayed, labelled, as what that radar holds on record. freshness gives each radar's last_run_at (and running for shadow_ai, enabled for mcp_servers) where the API serves one; coverage names the radars that answered; data is the relayed result above. The result's last text block repeats the structured result without data (the first text block) and without the note's sentences (the text block before it), with which notes ends
|
|
2152
|
+
description: `${EXPOSURE_RADAR_DESCRIPTION} Its structured result's state is not_assessed with measured_at null: every radar answered, but none gives one time at which what it counts was observed (mcp_servers dates its verdicts at most by a window, mcp_servers.window), so no count is presented as a dated measurement; each count is still relayed, labelled, as what that radar holds on record. freshness gives each radar's last_run_at (and running for shadow_ai, enabled for mcp_servers) where the API serves one; coverage names the radars that answered; data is the relayed result above. The result's last text block repeats the structured result without data (the first text block) and without the note's sentences (the text block before it), with which notes ends. ${TEXT_BUDGET_DESCRIPTION}`,
|
|
2063
2153
|
outputSchema: EXPOSURE_RADAR_OUTPUT,
|
|
2064
2154
|
annotations: ANNOTATIONS,
|
|
2065
2155
|
}, async () => checked("exposure_radar", EXPOSURE_RADAR_OUTPUT, await exposureRadar()));
|
|
@@ -2072,7 +2162,7 @@ export function createServer() {
|
|
|
2072
2162
|
registerCveIntel(server);
|
|
2073
2163
|
registerGetCwe(server);
|
|
2074
2164
|
// #2719: vendor_advisories_for_cve, get_vendor_advisory, search_vendor_advisories.
|
|
2075
|
-
registerVendorAdvisoryTools(server, { api, failed, badInput, crashed, succeeded, checked, okHead, envelopeSchema, annotations: ANNOTATIONS });
|
|
2165
|
+
registerVendorAdvisoryTools(server, { api, failed, describeFailure, badInput, crashed, succeeded, checked, okHead, envelopeSchema, annotations: ANNOTATIONS });
|
|
2076
2166
|
// #2722: prompts and resources, in prompts.ts and resources.ts. Both lists are fixed per
|
|
2077
2167
|
// connection, so listChanged is false above.
|
|
2078
2168
|
registerPrompts(server, { cveId: CVE_ID });
|