echelongraph-mcp 1.0.3 → 2.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 EchelonGraph
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -19,9 +19,9 @@ API call a tool needs to answer.
19
19
  |---|---|
20
20
  | `cve_summary` | Counts of active CVEs by severity, and when the feed was last updated. |
21
21
  | `search_cves` | Search/filter CVEs (severity, min CVSS, text, sort) with EchelonGraph scores. |
22
- | `get_cve` | Full detail for one CVE (CVSS v3/v4, EG score, EPSS, KEV+ransomware, GHSA, CWE, references). |
22
+ | `get_cve` | Full record for one CVE: CVSS v3 and (when scored) v4, the EchelonGraph score and its confidence, EPSS, CISA-KEV status and known ransomware use, the GitHub GHSA id, references, and its published, modified and `updated_at` times. |
23
23
  | `cve_exposure` | Internet-exposure footprint for a CVE: exposed service count (distinct ip:port, the `exposed_hosts` field) + country/product breakdown, from the KEV-exposure radar. |
24
- | `exposure_radar` | Aggregate totals across the exposure radars: shadow AI; 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. |
24
+ | `exposure_radar` | 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. Every number is labelled by what it counts, and a field the tool cannot label is left out and named. |
25
25
 
26
26
  Every figure is as fresh as the schedule that refreshes it. The CVE feed is polled from its
27
27
  sources on a schedule; each radar refreshes on its own.
@@ -40,8 +40,9 @@ Shodan InternetDB refreshes `last_seen` without re-reading the banner, so a patc
40
40
  stay counted while its port stays open.
41
41
 
42
42
  The unit is a service, not a machine. Shodan returns one banner per port and the radar keys
43
- each observation on ip:port, so a machine answering on two ports counts twice. The API field
44
- keeps its name, `exposed_hosts`, and so does `distinct_hosts` in the radar totals.
43
+ each observation on ip:port, so a machine answering on two ports counts twice. The API's field
44
+ names are kept (`exposed_hosts` here; `distinct_hosts`, `ransomware_hosts` and each ranked
45
+ row's count in `exposure_radar`), but every one of them counts ip:port services.
45
46
 
46
47
  The radar only looks for its **tracked** set of CVEs, so a zero means different things:
47
48
 
@@ -52,7 +53,79 @@ The radar only looks for its **tracked** set of CVEs, so a zero means different
52
53
  | no `tracked` field | The API did not say whether the CVE is in the tracked set: an older API, or the radar cannot decide (for example a CISA-KEV or high-EPSS CVE in a tracked product with 0 services on record). 0 exposed services on record, with no claim either way. |
53
54
  | HTTP 400, or an id that is not `CVE-YYYY-NNNN…` | An error result tagged `invalid_input`; nothing was looked up. |
54
55
 
55
- ### How `exposure_radar` counts shadow AI
56
+ ### How `exposure_radar` counts
57
+
58
+ `exposure_radar` relays each radar's answer cut to the fields it can label, and its note labels
59
+ every number by what it counts. A field this version does not know is left out and the note
60
+ names it, whether it is a total or a field inside a ranked row; so is a known field in an
61
+ unexpected shape, and a list with one malformed row is left out whole, since a partial list
62
+ reads as a complete one. `kev_exposure`, `exposed_databases` and `leaked_credentials` also
63
+ carry `generated_at`, when the API computed their totals, and, when the API can tell,
64
+ `last_run_at`: when that radar last completed a check (each table below says what a completed
65
+ check is for that radar).
66
+
67
+ `last_run_at` is a timestamp, not a count. It is recorded for the radar as a whole, not for
68
+ the server instance that answered, in UTC to the second. It moves only at the end of a cycle
69
+ whose reads succeeded, so a cycle that read nothing leaves it where it was. It is not the time
70
+ of every record a radar's numbers count: those cover everything still on record, not only what
71
+ the last check found. Nor is it `generated_at`, which is only when the API recomputed the
72
+ totals. The API omits `last_run_at` when it has no completed check on record or cannot read
73
+ it; the result then carries none, and the note says nothing about it. When it is there, the
74
+ note gives it as, for example, "kev_exposure last completed check: 2026-09-27T03:12:44Z".
75
+
76
+ #### `kev_exposure`
77
+
78
+ Every count is over the services the KEV-exposure radar keeps: a Shodan banner whose version
79
+ maps to a CISA-KEV-listed CVE (see "How `cve_exposure` counts").
80
+
81
+ | Field | What it counts |
82
+ |---|---|
83
+ | `kev_exposure.distinct_hosts` | Distinct ip:port services, not machines, with at least one CISA-KEV-listed CVE on record. A machine answering on two ports counts twice. |
84
+ | `kev_exposure.ransomware_hosts` | The services among them with at least one ransomware-linked KEV CVE (one whose KEV entry notes known use in ransomware campaigns). |
85
+ | `kev_exposure.kev_cves_exposed` | Distinct CISA-KEV-listed CVEs with at least one service on record. |
86
+ | `kev_exposure.ransomware_cves` | The ransomware-linked CVEs among them. |
87
+ | `kev_exposure.correlations` | Service×CVE pairs, not services: a service with three KEV CVEs counts three times. |
88
+ | `kev_exposure.top_products`, `kev_exposure.top_countries`, `kev_exposure.top_cves` | Up to 12 products, 10 countries and 12 CVEs, ranked by distinct ip:port services. |
89
+ | `kev_exposure.top_cves[].cvss_v3_score`, `kev_exposure.top_cves[].epss_score` | Scores, not counts: the highest CVSS v3 base score and EPSS probability (0 to 1) recorded on that CVE's observations. |
90
+ | `kev_exposure.trend` | Service×CVE pairs, not services, by the week (starting Monday) in which each pair was first recorded, over the last 12 weeks. Only pairs still on record are counted, so earlier weeks read low. |
91
+ | `kev_exposure.newest_kev` | The 15 CVEs that EchelonGraph's CVE records most recently mark as CISA-KEV-listed (by `added_date`), each with an `exposure_state` (below). |
92
+ | `kev_exposure.newest_kev[].exposed_hosts` | Only on a row whose `exposure_state` is `exposed` (or `measured_zero`): distinct ip:port services on record with that CVE. |
93
+ | `kev_exposure.newest_kev[].cvss_v3_score`, `kev_exposure.newest_kev[].epss_score` | Scores, not counts: the CVE record's CVSS v3 base score and EPSS probability (0 to 1), absent when the record has none. |
94
+ | `kev_exposure.last_run_at` | A timestamp, not a count: when the radar last completed a check, a cycle in which its Shodan search answered at least one query (others may have failed) and its list of services due for a re-check was read. A cycle that skipped the search for want of Shodan query credits, or whose reads failed, does not move it. |
95
+
96
+ A `newest_kev` row's `exposure_state` says whether its count is a measurement:
97
+
98
+ | `exposure_state` | What it means |
99
+ |---|---|
100
+ | `exposed` | The radar has services on record with this CVE, and `exposed_hosts` counts them. |
101
+ | `not_assessed` | **NOT ASSESSED**: the answer holds no measurement for this CVE, so no count is relayed. The API answers 0 for every CVE the radar holds no service for, whether or not the radar looks for that CVE at all, and it never marks a CVE as tracked with 0 services, so that 0 is not a measurement. `cve_exposure` says per CVE whether it is in the radar's tracked set. |
102
+ | `measured_zero` | Only if the API ever marks a row `tracked: true` with 0 services: a zero in the radar's sample, not an internet-wide zero. If it marks a row `tracked: false`, that row is `not_assessed` whatever its count. |
103
+
104
+ #### `exposed_databases`
105
+
106
+ | Field | What it counts |
107
+ |---|---|
108
+ | `exposed_databases.distinct_hosts` | Distinct ip:port services, not machines, that EchelonGraph's check confirmed answering without authentication: data stores and observability UIs alike. |
109
+ | `exposed_databases.engines` | Distinct engine types among them (for example `redis` or `grafana`). |
110
+ | `exposed_databases.top_engines`, `exposed_databases.top_countries` | Up to 15 engines and 10 countries, ranked by those services. |
111
+ | `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. |
112
+ | `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. |
113
+
114
+ #### `leaked_credentials`
115
+
116
+ | Field | What it counts |
117
+ |---|---|
118
+ | `leaked_credentials.total` | (repository, secret) pairs, not distinct secrets: a secret committed to three repositories counts three times. |
119
+ | `leaked_credentials.distinct_secrets` | Each secret once. |
120
+ | `leaked_credentials.distinct_repos` | Public GitHub repositories with at least one. |
121
+ | `leaked_credentials.top_providers`, `leaked_credentials.top_types` | Up to 15 providers and secret types, ranked by (repository, secret) pairs. |
122
+ | `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. |
123
+
124
+ None of them is validated. Each is a credential-shaped string in a public commit that passed
125
+ EchelonGraph's filters, at most structurally checked (a checksum or format decode), and never
126
+ tested against its provider, so none of these is a count of working credentials.
127
+
128
+ #### `shadow_ai`
56
129
 
57
130
  The shadow-AI radar's answer mixes numbers that count exposed services with numbers that count
58
131
  every observation. So `exposure_radar` regroups it by what each number counts, and its note
@@ -78,7 +151,7 @@ yet verified, whose hostname no longer resolves, or whose service no longer answ
78
151
 
79
152
  The tool relays no number it cannot label. A stats field this version does not know is left
80
153
  out, and the note names it; so is a count in an unexpected shape. A ranked row carries only
81
- its name and its count.
154
+ its name and its count, and a field added to one is named too.
82
155
 
83
156
  ## What a result means
84
157
 
@@ -89,8 +162,9 @@ outage for an all-clear:
89
162
  note saying the call succeeded, which base URL answered, and what it found. When the
90
163
  feed genuinely holds nothing for the query the note says so in words ("we looked and
91
164
  found nothing … not a lookup failure"), because a measured zero is a measurement. The
92
- exception to verbatim is `exposure_radar`'s `shadow_ai`: its counts are regrouped as above,
93
- and its `poller` block carries only `running` and `last_run_at`, or is dropped when it
165
+ exception to verbatim is `exposure_radar`: each radar is cut to the fields listed above, and
166
+ each `kev_exposure.newest_kev` row gains an `exposure_state`. Its `shadow_ai` counts are
167
+ regrouped as above, and its `poller` block carries only `running` and `last_run_at`, or is dropped when it
94
168
  carries no real completion time. The block's other fields describe the server instance that
95
169
  answered, not the radar. Its `last_run_at` is when the radar's leader last
96
170
  completed a Certificate Transparency (crt.sh) cycle, and its `running` is true only when
@@ -107,9 +181,87 @@ outage for an all-clear:
107
181
  cause (status code or error kind), the path, and the base URL, and says it is not a
108
182
  finding. A failure is never rendered as a success with null fields.
109
183
 
184
+ Both shapes also carry a structured result, below. The text blocks are the same as in 1.x, so
185
+ a client that reads only `content` sees what it always saw.
186
+
187
+ ## Structured results
188
+
189
+ Since 2.0.0 every result, success or failure, carries `structuredContent`, and every tool
190
+ declares its shape as an `outputSchema` in `tools/list`, with a title and the annotations
191
+ `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true` and
192
+ `openWorldHint: true` (hints, which a client treats as untrusted).
193
+
194
+ | Field | What it holds |
195
+ |---|---|
196
+ | `state` | `measured`, `not_assessed`, `failed` or `invalid_input` (below). |
197
+ | `measured_at` | When the underlying observation was made, as the API states it. `null` when the answer does not say or holds no observation; never the Go zero time. |
198
+ | `method` | How the numbers were produced. `null` on a failure. |
199
+ | `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`, and the radars that answered for `exposure_radar`. |
200
+ | `freshness` | The producing radar's last completed check (`last_run_at`), where the API serves one; `null` where it serves none. |
201
+ | `notes` | The caveats, one sentence each: what the envelope itself needs saying, then the note from the text block. |
202
+ | `data` | On a success only: the same JSON as the first text block. |
203
+ | `error` | On a failure only: `kind`, `path`, `status` and `message`. |
204
+
205
+ | `state` | What it means |
206
+ |---|---|
207
+ | `measured` | A measurement of what was asked. An exposure count is `measured` only when the answer dates it (`measured_at`) and says how it was produced (`method`). |
208
+ | `not_assessed` | The answer holds no dated measurement of what was asked, so no number in it is a finding: the radar does not look for this CVE, the answer does not say whether it does, or the answer does not say when what it counts was observed. |
209
+ | `failed` | The lookup did not complete. Not a finding. |
210
+ | `invalid_input` | The input was refused, by this server or by the API, so nothing was looked up. Not a finding. |
211
+
212
+ Every field comes from what the API sends; where the API does not say, the field is `null` and
213
+ a note says so. Per tool:
214
+
215
+ - `cve_summary` is `measured`, and its `measured_at` is `summary.last_updated`, the newest
216
+ modification time among the active CVE records it counts.
217
+ - `search_cves` is `measured`, with `measured_at` `null`: each record carries its own times. Its
218
+ `coverage` repeats the list's `total`, `total_counted`, `total_is_lower_bound`,
219
+ `search_relaxed`, `limit` and `offset`, and adds `returned`, the rows in the page. When
220
+ `total_counted` is false the total is not a count, and the note does not call it one.
221
+ - `get_cve` is `measured`, and its `measured_at` is the record's `updated_at`, when EchelonGraph
222
+ last wrote it.
223
+ - `cve_exposure` is `measured` only for services on record with a real `last_seen`, which is its
224
+ `measured_at`. Every other answer is `not_assessed`, a tracked zero included, since a zero has
225
+ no observation to date it. `exposure_state` is `exposed`, `measured_zero`, `not_assessed` or
226
+ `tracking_unknown`, the cases of "How `cve_exposure` counts", and `coverage.in_scope` is the
227
+ API's `tracked`.
228
+ - `exposure_radar` is `not_assessed` with `measured_at` `null`: every radar answered, but no
229
+ stats answer says when the services or records it counts were observed, so no count is
230
+ presented as a dated measurement. Its numbers keep the labels above. `freshness` holds each
231
+ radar's `last_run_at` where the API serves one, and for `shadow_ai` also `running`.
232
+
233
+ The CVE feed tools' `freshness` is `null`: the feed's answers carry no time at which its
234
+ pollers last completed a poll for the feed as a whole.
235
+
236
+ A success whose fields do not fit the tool's `outputSchema` (a field of a type the schema does
237
+ not allow) is returned as a failure with `error.kind` `unexpected_shape`, never relayed. A
238
+ field the API adds later is still relayed by the four tools that relay the API's JSON, and
239
+ `exposure_radar` leaves it out and names it, as above.
240
+
241
+ ## Protocol versions
242
+
243
+ The server answers both eras of the Model Context Protocol on stdio:
244
+
245
+ - **2026-07-28**: a client that opens with `server/discover` receives a DiscoverResult listing
246
+ `2026-07-28`, the tools capability and the server instructions, and then sends each request
247
+ with the per-request `_meta` envelope.
248
+ - **2025 and earlier**: a client that opens with `initialize`, as every 1.x SDK client does,
249
+ negotiates `2025-11-25`, `2025-06-18`, `2025-03-26` or `2024-11-05`; a version the server does
250
+ not know is answered with `2025-11-25`.
251
+
252
+ The first message of a connection picks its era. The DiscoverResult lists only `2026-07-28`,
253
+ as the SDK builds it: the 2025-era versions are reached through `initialize`. Both handshakes
254
+ carry the package's name and version (`serverInfo`), and every API request carries them in its
255
+ User-Agent, `echelongraph-mcp/<version>`.
256
+
257
+ Both handshakes also carry the server instructions: the data is public; what `state`,
258
+ `measured_at` and `freshness` mean; that exposure numbers are aggregate counts of ip:port
259
+ services, not an internet-wide census; and that Shodan data is Shodan's.
260
+
110
261
  ## Install
111
262
 
112
- Add it to your MCP client's config. It runs via `npx` — no global install needed.
263
+ Requires Node.js 20 or later. Add it to your MCP client's config. It runs via `npx` — no global
264
+ install needed.
113
265
 
114
266
  ### Claude Desktop
115
267
 
@@ -156,13 +308,18 @@ services does EchelonGraph's radar have on record for it?"*
156
308
  ```bash
157
309
  npm install
158
310
  npm run build # tsc → dist/
159
- npm test # build, then behavioural tests against a stub API (no network needed)
311
+ npm test # build, then the behavioural suite over both protocol eras, against a stub API (no network needed)
160
312
  npm run smoke # spawn the server + call cve_exposure against the production API
161
313
  ```
162
314
 
163
315
  `npm run smoke` calls the production API with this package's User-Agent, so its requests are
164
316
  counted as external MCP adoption. `npm test` never leaves the machine.
165
317
 
318
+ `npm test` runs the suite once over a 2026-07-28 `server/discover` and once over a 2025-06-18
319
+ `initialize`, plus the era tests. To run it against an installed package rather than `dist/`,
320
+ set `ECHELONGRAPH_MCP_BIN` to that package's `echelongraph-mcp` bin: the tests then run the bin
321
+ directly, as `npx` does, and read that package's own files.
322
+
166
323
  ## License
167
324
 
168
325
  The code is MIT-licensed.