echelongraph-mcp 1.0.0 → 1.0.2
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/README.md +89 -13
- package/dist/index.js +426 -38
- package/package.json +8 -5
- package/server.json +34 -0
package/README.md
CHANGED
|
@@ -1,25 +1,89 @@
|
|
|
1
1
|
# EchelonGraph MCP server
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
[MCP](https://modelcontextprotocol.io) client
|
|
3
|
+
**CVE and internet-exposure data** for Claude, Cursor, Cline, and any
|
|
4
|
+
[MCP](https://modelcontextprotocol.io) client, straight from
|
|
5
5
|
[EchelonGraph](https://echelongraph.io)'s free public feed.
|
|
6
6
|
|
|
7
7
|
It exposes EchelonGraph's CVE Pulse (NVD + MITRE-CNA *pre-NVD* + CISA-KEV + EPSS + GitHub
|
|
8
|
-
GHSA, fused into one score) **plus
|
|
9
|
-
internet-
|
|
10
|
-
|
|
8
|
+
GHSA, fused into one score) **plus a per-CVE internet-exposure footprint**: how many
|
|
9
|
+
internet-facing services (distinct ip:port) EchelonGraph's KEV-exposure radar has on record
|
|
10
|
+
running a version that maps to the CVE. Exposure counts are derived from Shodan data.
|
|
11
|
+
Shodan data is owned by Shodan, which holds its copyright (© Shodan).
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
Free and keyless: no API key, no auth, read-only. The server makes no request other than the
|
|
14
|
+
API call a tool needs to answer.
|
|
13
15
|
|
|
14
16
|
## Tools
|
|
15
17
|
|
|
16
18
|
| Tool | What it does |
|
|
17
19
|
|---|---|
|
|
18
|
-
| `cve_summary` |
|
|
20
|
+
| `cve_summary` | Counts of active CVEs by severity, and when the feed was last updated. |
|
|
19
21
|
| `search_cves` | Search/filter CVEs (severity, min CVSS, text, sort) with EchelonGraph scores. |
|
|
20
22
|
| `get_cve` | Full detail for one CVE (CVSS v3/v4, EG score, EPSS, KEV+ransomware, GHSA, CWE, references). |
|
|
21
|
-
| `cve_exposure` |
|
|
22
|
-
| `exposure_radar` |
|
|
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. |
|
|
25
|
+
|
|
26
|
+
Every figure is as fresh as the schedule that refreshes it. The CVE feed is polled from its
|
|
27
|
+
sources on a schedule; each radar refreshes on its own.
|
|
28
|
+
|
|
29
|
+
### How `cve_exposure` counts
|
|
30
|
+
|
|
31
|
+
Every 12 hours, when Shodan query credits allow, the KEV-exposure radar runs one Shodan query
|
|
32
|
+
per tracked product, reads up to 100 ip:port services per query, and keeps a service when its
|
|
33
|
+
banner version matches a CISA-KEV or high-EPSS (>= 0.5) CVE. A service not seen on its port
|
|
34
|
+
for 21 days is dropped. A count is therefore a banner-version inference over a sample: not an
|
|
35
|
+
exploit test, and not an internet-wide census.
|
|
36
|
+
|
|
37
|
+
`last_seen` is when a service was last seen listening on its port, not when its vulnerable
|
|
38
|
+
version was last confirmed. Between searches, a re-check that finds the port still listed by
|
|
39
|
+
Shodan InternetDB refreshes `last_seen` without re-reading the banner, so a patched service can
|
|
40
|
+
stay counted while its port stays open.
|
|
41
|
+
|
|
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.
|
|
45
|
+
|
|
46
|
+
The radar only looks for its **tracked** set of CVEs, so a zero means different things:
|
|
47
|
+
|
|
48
|
+
| API answer | What the tool says |
|
|
49
|
+
|---|---|
|
|
50
|
+
| `tracked: false` | **NOT ASSESSED**: outside the radar's tracked set; 0 is not a measurement. |
|
|
51
|
+
| `tracked: true`, `exposed_hosts: 0` | A measured zero in the radar's sample: none of the up to 100 services it read per tracked-product query matched. Not an internet-wide zero. |
|
|
52
|
+
| 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
|
+
| HTTP 400, or an id that is not `CVE-YYYY-NNNN…` | An error result tagged `invalid_input`; nothing was looked up. |
|
|
54
|
+
|
|
55
|
+
### How `exposure_radar` counts shadow AI
|
|
56
|
+
|
|
57
|
+
`shadow_ai.stats.total` counts **every** observation, whatever its verification state: that
|
|
58
|
+
many observed, not that many exposed. The confirmed-exposed count is the sum of
|
|
59
|
+
`stats.visible_by_category` (services EchelonGraph's probes found answering without an
|
|
60
|
+
authentication gate), and `last_24h_visible_count` is its last-24-hour slice. The tool's note
|
|
61
|
+
says which is which.
|
|
62
|
+
|
|
63
|
+
## What a result means
|
|
64
|
+
|
|
65
|
+
Every tool answers in one of two shapes, so a model reading the result cannot mistake an
|
|
66
|
+
outage for an all-clear:
|
|
67
|
+
|
|
68
|
+
- **Success** — the first text block is the API's JSON verbatim; the second is a one-line
|
|
69
|
+
note saying the call succeeded, which base URL answered, and what it found. When the
|
|
70
|
+
feed genuinely holds nothing for the query the note says so in words ("we looked and
|
|
71
|
+
found nothing … not a lookup failure"), because a measured zero is a measurement. One
|
|
72
|
+
exception to verbatim: `exposure_radar` drops the shadow-AI `poller` block when it carries
|
|
73
|
+
no real completion time. That block's `last_run_at` is when the radar's leader last
|
|
74
|
+
completed a Certificate Transparency (crt.sh) cycle, and its `running` is true only when
|
|
75
|
+
that was within 30 minutes of the answer. When `running` is true, the note says the
|
|
76
|
+
radar's leader last completed a cycle at that time; when it is false, the note says no
|
|
77
|
+
cycle has completed since that time; when `last_run_at` is absent, the note says the
|
|
78
|
+
radar's freshness is unknown and the block is left out. An older API answered from
|
|
79
|
+
whichever server instance served the request, and a follower instance sent
|
|
80
|
+
`running: false` with the zero time `0001-01-01T00:00:00Z`; that too is unknown freshness,
|
|
81
|
+
never presented as a stopped radar.
|
|
82
|
+
- **Failure** — an MCP error result (`isError: true`) whenever the lookup did not complete:
|
|
83
|
+
the host could not be reached, it answered non-2xx, it took longer than the timeout, or
|
|
84
|
+
it answered 2xx with a body that is not a JSON object. The text names the tool, the
|
|
85
|
+
cause (status code or error kind), the path, and the base URL, and says it is not a
|
|
86
|
+
finding. A failure is never rendered as a success with null fields.
|
|
23
87
|
|
|
24
88
|
## Install
|
|
25
89
|
|
|
@@ -55,23 +119,35 @@ Add it to your MCP client's config. It runs via `npx` — no global install need
|
|
|
55
119
|
}
|
|
56
120
|
```
|
|
57
121
|
|
|
58
|
-
Restart the client, then ask: *"Is CVE-2023-44487 actively exploited, and how exposed
|
|
59
|
-
|
|
122
|
+
Restart the client, then ask: *"Is CVE-2023-44487 actively exploited, and how many exposed
|
|
123
|
+
services does EchelonGraph's radar have on record for it?"*
|
|
60
124
|
|
|
61
125
|
## Configuration
|
|
62
126
|
|
|
63
127
|
| Env var | Default | Purpose |
|
|
64
128
|
|---|---|---|
|
|
65
129
|
| `ECHELONGRAPH_API_BASE` | `https://app.echelongraph.io` | Override the API base (self-host / proxy). |
|
|
130
|
+
| `ECHELONGRAPH_API_TIMEOUT_MS` | `15000` | Per-request timeout. A slower answer is reported as a failed lookup, not as empty data. |
|
|
66
131
|
|
|
67
132
|
## Develop
|
|
68
133
|
|
|
69
134
|
```bash
|
|
70
135
|
npm install
|
|
71
136
|
npm run build # tsc → dist/
|
|
72
|
-
npm
|
|
137
|
+
npm test # build, then behavioural tests against a stub API (no network needed)
|
|
138
|
+
npm run smoke # spawn the server + call cve_exposure against the production API
|
|
73
139
|
```
|
|
74
140
|
|
|
141
|
+
`npm run smoke` calls the production API with this package's User-Agent, so its requests are
|
|
142
|
+
counted as external MCP adoption. `npm test` never leaves the machine.
|
|
143
|
+
|
|
75
144
|
## License
|
|
76
145
|
|
|
77
|
-
|
|
146
|
+
The code is MIT-licensed.
|
|
147
|
+
|
|
148
|
+
The CVE Pulse compilation (how EchelonGraph combines its CVE sources, and the EchelonGraph
|
|
149
|
+
score) is © EchelonGraph, served under the CVE Pulse free-access terms; the source records it
|
|
150
|
+
compiles stay under their publishers' terms.
|
|
151
|
+
|
|
152
|
+
Exposure counts are derived from Shodan data. Shodan data is owned by Shodan, which holds its
|
|
153
|
+
copyright (© Shodan). EchelonGraph claims no ownership of it or copyright in it.
|
package/dist/index.js
CHANGED
|
@@ -1,51 +1,251 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// EchelonGraph CVE & internet-exposure MCP server.
|
|
3
|
-
// Exposes EchelonGraph's free public CVE
|
|
4
|
-
// Cursor, Cline
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// Exposes EchelonGraph's free, keyless public CVE and exposure data as MCP tools for Claude,
|
|
4
|
+
// Cursor, Cline and any MCP client: the CVE feed, and per CVE the internet-exposure footprint
|
|
5
|
+
// that EchelonGraph's KEV-exposure radar derives from Shodan data. Every figure is as fresh as
|
|
6
|
+
// the schedule that refreshes it, and the tool texts say which schedule that is.
|
|
7
|
+
// stdio transport; no auth required; read-only. It makes no request other than the API call a
|
|
8
|
+
// tool needs to answer.
|
|
9
|
+
//
|
|
10
|
+
// Result contract (#1874). A tool answers in exactly one of two shapes, and they never blur:
|
|
11
|
+
// - success: content[0] is the API's JSON verbatim; content[1] is a one-line note saying
|
|
12
|
+
// the call succeeded, where, and what it found — in words, including when it found
|
|
13
|
+
// nothing ("we looked and found nothing" is a measurement).
|
|
14
|
+
// - failure (isError: true): the lookup did not complete — unreachable host, non-2xx,
|
|
15
|
+
// timeout, or a 2xx whose body is not a JSON object — named by tool, cause and base URL.
|
|
16
|
+
// Never a success with null fields: a model handed one of those tells its user "no
|
|
17
|
+
// exposure found", and an outage becomes an all-clear.
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
7
19
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
20
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
9
21
|
import { z } from "zod";
|
|
22
|
+
// The release actually running, read from the package.json that ships beside dist/. The MCP
|
|
23
|
+
// handshake and the User-Agent both carry it, so the API's access log can count adoption per
|
|
24
|
+
// version without a code change per release.
|
|
25
|
+
const VERSION = (() => {
|
|
26
|
+
try {
|
|
27
|
+
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
28
|
+
const v = pkg?.version;
|
|
29
|
+
return typeof v === "string" && v ? v : "unknown";
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return "unknown";
|
|
33
|
+
}
|
|
34
|
+
})();
|
|
10
35
|
const BASE = (process.env.ECHELONGRAPH_API_BASE || "https://app.echelongraph.io").replace(/\/+$/, "");
|
|
11
|
-
const UA =
|
|
36
|
+
const UA = `echelongraph-mcp/${VERSION} (+https://echelongraph.io/pulse/mcp)`;
|
|
37
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
38
|
+
const TIMEOUT_MS = (() => {
|
|
39
|
+
const n = Number(process.env.ECHELONGRAPH_API_TIMEOUT_MS);
|
|
40
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : DEFAULT_TIMEOUT_MS;
|
|
41
|
+
})();
|
|
42
|
+
// The base as quoted in tool results. A proxy credential in the URL's userinfo is dropped.
|
|
43
|
+
const SHOWN_BASE = (() => {
|
|
44
|
+
try {
|
|
45
|
+
const u = new URL(BASE);
|
|
46
|
+
if (!u.username && !u.password)
|
|
47
|
+
return BASE;
|
|
48
|
+
return `${u.protocol}//***@${u.host}${u.pathname.replace(/\/+$/, "")}`;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return "the configured ECHELONGRAPH_API_BASE (not a valid URL)";
|
|
52
|
+
}
|
|
53
|
+
})();
|
|
54
|
+
// Collapse a response body to something quotable: one line, at most 160 characters.
|
|
55
|
+
function snippet(body) {
|
|
56
|
+
const s = body.replace(/\s+/g, " ").trim();
|
|
57
|
+
if (!s)
|
|
58
|
+
return "(empty body)";
|
|
59
|
+
return s.length > 160 ? `${s.slice(0, 160)}…` : s;
|
|
60
|
+
}
|
|
61
|
+
// A message may echo a URL — undici's refusal of a credentialed URL quotes it whole — so
|
|
62
|
+
// anything shaped scheme://user:pass@host is masked before it can reach a result.
|
|
63
|
+
const redactCredentials = (s) => s.replace(/(\w+:\/\/)[^/\s@]+@/g, "$1***@");
|
|
64
|
+
// The useful part of a fetch error is usually in its cause chain (ECONNREFUSED, ENOTFOUND,
|
|
65
|
+
// "bad port"), so walk it. Messages and codes only — never headers, never a credential.
|
|
66
|
+
function describeError(e) {
|
|
67
|
+
if (!(e instanceof Error))
|
|
68
|
+
return String(e);
|
|
69
|
+
const parts = [e.message];
|
|
70
|
+
let cause = e.cause;
|
|
71
|
+
for (let depth = 0; cause instanceof Error && depth < 4; depth++) {
|
|
72
|
+
const c = cause;
|
|
73
|
+
const codes = Array.isArray(c.errors)
|
|
74
|
+
? c.errors.map((x) => x.code).filter((x) => typeof x === "string")
|
|
75
|
+
: [];
|
|
76
|
+
if (codes.length)
|
|
77
|
+
parts.push(codes.join(", "));
|
|
78
|
+
else if (typeof c.code === "string")
|
|
79
|
+
parts.push(c.code);
|
|
80
|
+
else if (c.message)
|
|
81
|
+
parts.push(c.message);
|
|
82
|
+
cause = c.cause;
|
|
83
|
+
}
|
|
84
|
+
return redactCredentials(parts.join(": "));
|
|
85
|
+
}
|
|
86
|
+
// The API's error envelope is {"error": "..."}; quote that string when a non-2xx carries it.
|
|
87
|
+
function apiMessage(body) {
|
|
88
|
+
try {
|
|
89
|
+
const parsed = JSON.parse(body);
|
|
90
|
+
const msg = parsed?.error;
|
|
91
|
+
if (typeof msg === "string" && msg)
|
|
92
|
+
return msg;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// not JSON — fall through to the raw snippet
|
|
96
|
+
}
|
|
97
|
+
return snippet(body);
|
|
98
|
+
}
|
|
99
|
+
// One GET against the API. Never throws: every way the call can fail comes back as a typed
|
|
100
|
+
// Failure so the tool renders it as an error result. A 2xx whose body is not a JSON object
|
|
101
|
+
// (an SPA shell, an edge challenge page, a literal null) is a failure too — it is not data.
|
|
12
102
|
async function api(path) {
|
|
13
103
|
const ctrl = new AbortController();
|
|
14
|
-
|
|
104
|
+
let timedOut = false;
|
|
105
|
+
const timer = setTimeout(() => {
|
|
106
|
+
timedOut = true;
|
|
107
|
+
ctrl.abort();
|
|
108
|
+
}, TIMEOUT_MS);
|
|
109
|
+
const timeout = () => ({ ok: false, kind: "timeout", path, detail: `no response within ${TIMEOUT_MS} ms` });
|
|
15
110
|
try {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
111
|
+
let res;
|
|
112
|
+
try {
|
|
113
|
+
res = await fetch(`${BASE}${path}`, {
|
|
114
|
+
headers: { "User-Agent": UA, Accept: "application/json" },
|
|
115
|
+
signal: ctrl.signal,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
return timedOut ? timeout() : { ok: false, kind: "network", path, detail: describeError(e) };
|
|
120
|
+
}
|
|
121
|
+
let body;
|
|
122
|
+
try {
|
|
123
|
+
body = await res.text();
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
return timedOut ? timeout() : { ok: false, kind: "network", path, detail: `reading the body failed: ${describeError(e)}` };
|
|
127
|
+
}
|
|
20
128
|
if (!res.ok)
|
|
21
|
-
|
|
22
|
-
|
|
129
|
+
return { ok: false, kind: "http", path, status: res.status, detail: apiMessage(body) };
|
|
130
|
+
let data;
|
|
131
|
+
try {
|
|
132
|
+
data = JSON.parse(body);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
const ctype = res.headers.get("content-type") ?? "no content-type";
|
|
136
|
+
return { ok: false, kind: "not_json", path, status: res.status, detail: `${ctype}; body starts: ${snippet(body)}` };
|
|
137
|
+
}
|
|
138
|
+
if (data === null || typeof data !== "object") {
|
|
139
|
+
return { ok: false, kind: "not_object", path, status: res.status, detail: `body was ${snippet(body)}` };
|
|
140
|
+
}
|
|
141
|
+
return { ok: true, status: res.status, data };
|
|
23
142
|
}
|
|
24
143
|
finally {
|
|
25
|
-
clearTimeout(
|
|
144
|
+
clearTimeout(timer);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const text = (t) => ({ type: "text", text: t });
|
|
148
|
+
// One sentence per failure kind: where we looked, for what, and what came back.
|
|
149
|
+
function describeFailure(f) {
|
|
150
|
+
const where = `from ${SHOWN_BASE} for GET ${f.path}`;
|
|
151
|
+
switch (f.kind) {
|
|
152
|
+
case "network":
|
|
153
|
+
return `EchelonGraph at ${SHOWN_BASE} could not be reached for GET ${f.path}: ${f.detail}.`;
|
|
154
|
+
case "timeout":
|
|
155
|
+
return `EchelonGraph at ${SHOWN_BASE} did not answer GET ${f.path} within ${TIMEOUT_MS} ms.`;
|
|
156
|
+
case "http":
|
|
157
|
+
return `EchelonGraph answered HTTP ${f.status} ${where} — the API said: ${f.detail}.`;
|
|
158
|
+
case "not_json":
|
|
159
|
+
return `EchelonGraph answered HTTP ${f.status} ${where} but the body was not JSON (${f.detail}).`;
|
|
160
|
+
case "not_object":
|
|
161
|
+
return `EchelonGraph answered HTTP ${f.status} ${where} but the body was not a JSON object (${f.detail}).`;
|
|
26
162
|
}
|
|
27
163
|
}
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
164
|
+
// What a model must not conclude from a failure.
|
|
165
|
+
const NOT_A_FINDING = "The lookup did not complete, so this is not a finding: do not report it as zero, none found, absent, or unexposed. Retry later or check ECHELONGRAPH_API_BASE.";
|
|
166
|
+
// A 404 is the one non-2xx that carries an answer of its own — the API's not-found message,
|
|
167
|
+
// quoted in the sentence before it — so it is worded as "no record", not as an outage.
|
|
168
|
+
const NO_RECORD = "The API returned no record for this request; the quoted message is the API's own explanation. This says nothing about internet exposure.";
|
|
169
|
+
// A 400 is the API refusing the input itself, so nothing was looked up and nothing measured.
|
|
170
|
+
// It is tagged invalid_input, the same state a request this server refuses locally carries.
|
|
171
|
+
const INVALID_INPUT = "(state: invalid_input)";
|
|
172
|
+
const REJECTED_INPUT = "The API rejected the request as invalid input, so nothing was looked up: this is not a finding. Do not report it as zero, none found, or unexposed; correct the input and retry.";
|
|
173
|
+
const failed = (tool, f) => {
|
|
174
|
+
if (f.kind === "http" && f.status === 400) {
|
|
175
|
+
return { content: [text(`${tool} FAILED ${INVALID_INPUT}: ${describeFailure(f)} ${REJECTED_INPUT}`)], isError: true };
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
content: [text(`${tool} FAILED: ${describeFailure(f)} ${f.kind === "http" && f.status === 404 ? NO_RECORD : NOT_A_FINDING}`)],
|
|
179
|
+
isError: true,
|
|
180
|
+
};
|
|
181
|
+
};
|
|
182
|
+
const badInput = (tool, why) => ({
|
|
183
|
+
content: [text(`${tool} FAILED ${INVALID_INPUT}: ${why}. Nothing was looked up, so this is not a finding.`)],
|
|
31
184
|
isError: true,
|
|
32
185
|
});
|
|
33
|
-
|
|
34
|
-
|
|
186
|
+
// A bug in this server is still not a finding — it must never surface as an empty success.
|
|
187
|
+
const crashed = (tool, e) => ({
|
|
188
|
+
content: [text(`${tool} FAILED inside the MCP server while querying ${SHOWN_BASE}: ${describeError(e)}. ${NOT_A_FINDING}`)],
|
|
189
|
+
isError: true,
|
|
190
|
+
});
|
|
191
|
+
const succeeded = (data, note) => ({
|
|
192
|
+
content: [text(JSON.stringify(data, null, 2)), text(note)],
|
|
193
|
+
});
|
|
194
|
+
const okHead = (tool, status) => `${tool} OK: EchelonGraph answered${status === undefined ? "" : ` HTTP ${status}`} from ${SHOWN_BASE}.`;
|
|
195
|
+
// Tolerant readers for the note: a missing or renamed field degrades the sentence, never
|
|
196
|
+
// the result — content[0] still carries whatever the API sent.
|
|
197
|
+
const field = (o, ...keys) => keys.reduce((v, k) => (v !== null && typeof v === "object" ? v[k] : undefined), o);
|
|
198
|
+
const numAt = (o, ...keys) => {
|
|
199
|
+
const v = field(o, ...keys);
|
|
200
|
+
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
201
|
+
};
|
|
202
|
+
const strAt = (o, ...keys) => {
|
|
203
|
+
const v = field(o, ...keys);
|
|
204
|
+
return typeof v === "string" && v ? v : undefined;
|
|
205
|
+
};
|
|
206
|
+
const lenAt = (o, ...keys) => {
|
|
207
|
+
const v = field(o, ...keys);
|
|
208
|
+
return Array.isArray(v) ? v.length : undefined;
|
|
209
|
+
};
|
|
210
|
+
const boolAt = (o, ...keys) => {
|
|
211
|
+
const v = field(o, ...keys);
|
|
212
|
+
return typeof v === "boolean" ? v : undefined;
|
|
213
|
+
};
|
|
214
|
+
// A timestamp worth repeating as prose. The API has been seen answering the Go zero time
|
|
215
|
+
// (0001-01-01T00:00:00Z) where it had no instant to give; that is not a time, so it is never
|
|
216
|
+
// presented as one.
|
|
217
|
+
const realInstant = (s) => s !== undefined && Date.parse(s) > 0;
|
|
218
|
+
const server = new McpServer({ name: "echelongraph-cve", version: VERSION });
|
|
219
|
+
server.tool("cve_summary", "Summary of EchelonGraph's CVE Pulse feed: total active CVEs and counts by severity (critical/high/medium/low/none), plus when the feed was last updated. The feed is polled from its sources on a schedule, so this is the state as of that update.", {}, async () => {
|
|
220
|
+
const tool = "cve_summary";
|
|
35
221
|
try {
|
|
36
|
-
|
|
222
|
+
const r = await api("/api/v1/public/cves/summary");
|
|
223
|
+
if (!r.ok)
|
|
224
|
+
return failed(tool, r);
|
|
225
|
+
const head = okHead(tool, r.status);
|
|
226
|
+
const total = numAt(r.data, "summary", "total");
|
|
227
|
+
if (total === undefined)
|
|
228
|
+
return succeeded(r.data, head);
|
|
229
|
+
if (total === 0) {
|
|
230
|
+
return succeeded(r.data, `${head} The feed reports 0 active CVEs — a measured empty result (we looked and found nothing), not a lookup failure.`);
|
|
231
|
+
}
|
|
232
|
+
// A stamp that is not a real instant stays in the JSON and is not repeated as prose.
|
|
233
|
+
const updated = strAt(r.data, "summary", "last_updated");
|
|
234
|
+
const stamp = realInstant(updated) ? ` (last updated ${updated})` : "";
|
|
235
|
+
return succeeded(r.data, `${head} The feed holds ${total} active CVEs${stamp}.`);
|
|
37
236
|
}
|
|
38
237
|
catch (e) {
|
|
39
|
-
return
|
|
238
|
+
return crashed(tool, e);
|
|
40
239
|
}
|
|
41
240
|
});
|
|
42
|
-
server.tool("search_cves", "Search/list CVEs from EchelonGraph's
|
|
241
|
+
server.tool("search_cves", "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 with the EchelonGraph multi-source score, severity, CVSS, EPSS, and KEV status.", {
|
|
43
242
|
search: z.string().optional().describe("free-text search (product, vendor, or keyword, e.g. 'tomcat')"),
|
|
44
243
|
severity: z.enum(["CRITICAL", "HIGH", "MEDIUM", "LOW"]).optional().describe("filter to one severity"),
|
|
45
244
|
min_cvss: z.number().min(0).max(10).optional().describe("minimum CVSS score"),
|
|
46
245
|
sort: z.enum(["published", "modified", "nvd", "echelongraph", "epss"]).optional().describe("sort order (default: published)"),
|
|
47
246
|
limit: z.number().int().min(1).max(50).optional().describe("page size (default 20, max 50)"),
|
|
48
247
|
}, async (a) => {
|
|
248
|
+
const tool = "search_cves";
|
|
49
249
|
try {
|
|
50
250
|
const q = new URLSearchParams();
|
|
51
251
|
if (a.search)
|
|
@@ -57,43 +257,231 @@ server.tool("search_cves", "Search/list CVEs from EchelonGraph's real-time feed
|
|
|
57
257
|
if (a.sort)
|
|
58
258
|
q.set("sort", a.sort);
|
|
59
259
|
q.set("limit", String(a.limit ?? 20));
|
|
60
|
-
|
|
260
|
+
const r = await api(`/api/v1/public/cves?${q.toString()}`);
|
|
261
|
+
if (!r.ok)
|
|
262
|
+
return failed(tool, r);
|
|
263
|
+
const head = okHead(tool, r.status);
|
|
264
|
+
const shown = lenAt(r.data, "cves");
|
|
265
|
+
const total = numAt(r.data, "total") ?? shown;
|
|
266
|
+
if (total === undefined)
|
|
267
|
+
return succeeded(r.data, head);
|
|
268
|
+
if (total === 0) {
|
|
269
|
+
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.`);
|
|
270
|
+
}
|
|
271
|
+
return succeeded(r.data, `${head} The query matched ${total} CVEs${shown === undefined ? "" : `; ${shown} returned in this page`}.`);
|
|
61
272
|
}
|
|
62
273
|
catch (e) {
|
|
63
|
-
return
|
|
274
|
+
return crashed(tool, e);
|
|
64
275
|
}
|
|
65
276
|
});
|
|
66
277
|
server.tool("get_cve", "Full detail for one CVE: description, NVD CVSS v3/v4, the EchelonGraph multi-source score + confidence, EPSS, CISA-KEV status (including ransomware-campaign use), GitHub GHSA, CWE, and references. Pass a CVE ID like CVE-2023-44487.", { cve_id: z.string().describe("a CVE ID, e.g. CVE-2023-44487") }, async ({ cve_id }) => {
|
|
278
|
+
const tool = "get_cve";
|
|
279
|
+
const id = cve_id.trim();
|
|
280
|
+
if (!id)
|
|
281
|
+
return badInput(tool, "cve_id is required");
|
|
67
282
|
try {
|
|
68
|
-
|
|
283
|
+
const r = await api(`/api/v1/public/cves/${encodeURIComponent(id)}`);
|
|
284
|
+
if (!r.ok)
|
|
285
|
+
return failed(tool, r);
|
|
286
|
+
return succeeded(r.data, `${okHead(tool, r.status)} Returned the record for ${strAt(r.data, "cve_id") ?? id}.`);
|
|
69
287
|
}
|
|
70
288
|
catch (e) {
|
|
71
|
-
return
|
|
289
|
+
return crashed(tool, e);
|
|
72
290
|
}
|
|
73
291
|
});
|
|
74
|
-
|
|
292
|
+
// cve_exposure. What the API's footprint means, and what it does not (#2306, #2307).
|
|
293
|
+
//
|
|
294
|
+
// The counts come from the KEV-exposure radar, which does not look at every CVE: every 12 h,
|
|
295
|
+
// when Shodan query credits allow (the poller skips the search below SHODAN_MIN_QUERY_BUDGET),
|
|
296
|
+
// it runs one Shodan query per tracked product and keeps services whose banner version maps
|
|
297
|
+
// to a CISA-KEV or high-EPSS CVE. So a 0 is only a measurement when the CVE is inside that
|
|
298
|
+
// tracked set. The unit is a service, not a machine: the radar keys each observation on
|
|
299
|
+
// ip:port (Shodan returns one banner per port), and exposed_hosts counts distinct ip:port.
|
|
300
|
+
// The API says which with `tracked`; the note reads it and, when the field is absent (an older
|
|
301
|
+
// API, or a CVE the radar cannot decide on), makes no claim either way about the zero.
|
|
302
|
+
//
|
|
303
|
+
// last_seen is not a re-sighting of the vulnerable banner: between searches the radar refreshes
|
|
304
|
+
// it when Shodan InternetDB still lists the service's PORT, without re-reading the version, so
|
|
305
|
+
// a patched service can stay counted while its port stays open. Every text says "last seen
|
|
306
|
+
// listening on its port", never "re-seen" or "most recently seen".
|
|
307
|
+
const CVE_ID = /^CVE-\d{4}-\d{4,}$/i;
|
|
308
|
+
// Quoted when the API does not send its own `method` string.
|
|
309
|
+
const EXPOSURE_METHOD = "Shodan banner match on the radar's tracked products; up to 100 ip:port services per product query; searched every 12 h when Shodan query credits allow";
|
|
310
|
+
const SHODAN_ATTRIBUTION = "Exposure counts are derived from Shodan data.";
|
|
311
|
+
// Shodan's terms (https://static.shodan.io/legal/terms.html, read 2026-09-27) ask for two
|
|
312
|
+
// things: "You must attribute such usage to Shodan", and materials "referencing, including or
|
|
313
|
+
// otherwise based on Shodan information or materials, must clearly indicate Shodan's ownership
|
|
314
|
+
// and copyright in the applicable Shodan materials" (#2306). The attribution above does the
|
|
315
|
+
// first; this sentence, placed beside it wherever Shodan data is named, does the second.
|
|
316
|
+
const SHODAN_OWNERSHIP = "Shodan data is owned by Shodan, which holds its copyright (© Shodan).";
|
|
317
|
+
function exposureNote(head, id, d) {
|
|
318
|
+
const cve = strAt(d, "cve_id") ?? id;
|
|
319
|
+
const hosts = numAt(d, "exposed_hosts");
|
|
320
|
+
const tracked = boolAt(d, "tracked");
|
|
321
|
+
// The API's own method sentence may end in a full stop; the note supplies one.
|
|
322
|
+
const how = `Method: ${(strAt(d, "method") ?? EXPOSURE_METHOD).replace(/[.\s]+$/, "")}. ${SHODAN_ATTRIBUTION} ${SHODAN_OWNERSHIP}`;
|
|
323
|
+
// Before kev_catalog_listed existed, kev_listed was bool_or over the radar's observations:
|
|
324
|
+
// false for every CVE the radar has no host for, whatever the CISA-KEV catalog says.
|
|
325
|
+
// kev_catalog_listed is cves.kev_listed, the flag the KEV fetcher sets on EchelonGraph's CVE
|
|
326
|
+
// record. The fetcher only UPDATEs rows that already exist, and the API answers false when
|
|
327
|
+
// there is no row, so false cannot be told apart from "no record": it is worded as what
|
|
328
|
+
// EchelonGraph's data says, never as a fact about the CISA catalog.
|
|
329
|
+
const catalog = boolAt(d, "kev_catalog_listed");
|
|
330
|
+
const kev = catalog === undefined
|
|
331
|
+
? "Its kev_listed field reflects the radar's own observations, not the CISA-KEV catalog; use get_cve for catalog status."
|
|
332
|
+
: catalog
|
|
333
|
+
? `EchelonGraph's CVE record marks ${cve} as CISA-KEV-listed.`
|
|
334
|
+
: `EchelonGraph's CVE data does not mark ${cve} as CISA-KEV-listed (kev_catalog_listed is false, which it also is when EchelonGraph holds no record of ${cve}); get_cve shows the record.`;
|
|
335
|
+
if (hosts === undefined) {
|
|
336
|
+
return `${head} The answer carries no exposed_hosts count, so it says nothing about exposure to ${cve}. ${how}`;
|
|
337
|
+
}
|
|
338
|
+
if (tracked === false) {
|
|
339
|
+
const why = hosts === 0
|
|
340
|
+
? "outside the radar's tracked set; 0 is not a measurement, and it says nothing about whether any host is exposed to it."
|
|
341
|
+
: `outside the radar's tracked set, so the radar is not looking for it; the ${hosts} service(s) (distinct ip:port) on record are left from earlier scans and are not a current measurement.`;
|
|
342
|
+
return `${head} (state: not_assessed) NOT ASSESSED: ${cve} is ${why} ${kev} ${how}`;
|
|
343
|
+
}
|
|
344
|
+
if (hosts === 0 && tracked === true) {
|
|
345
|
+
// The backend answers tracked:true only when it holds rows (exposed_hosts > 0), so this
|
|
346
|
+
// branch does not fire today; it is worded for the day it does.
|
|
347
|
+
return `${head} (state: measured_zero) The radar has 0 internet-facing services (distinct ip:port) on record whose banner version maps to ${cve}: a measured zero in the radar's sample. ${cve} is in the tracked set, and the radar looked and found nothing. This is not a lookup failure. It is a zero in a sample, not an internet-wide zero: the radar reads at most the first 100 Shodan results per tracked-product query, so a vulnerable service outside those results is not counted. ${kev} ${how}`;
|
|
348
|
+
}
|
|
349
|
+
if (hosts === 0) {
|
|
350
|
+
return `${head} (state: tracking_unknown) EchelonGraph reports 0 exposed services (distinct ip:port) on record for ${cve}. The API does not say whether ${cve} is in the radar's tracked set: it is an API older than that field, or the radar cannot decide for this CVE (for example a CISA-KEV or high-EPSS CVE in a tracked product that it has no service on record for). The radar only looks for a tracked set of CISA-KEV and high-EPSS CVEs in specific products, so this 0 is not evidence either way: it neither shows that nothing is exposed nor that ${cve} went unassessed. This is not a lookup failure. ${kev} ${how}`;
|
|
351
|
+
}
|
|
352
|
+
const countries = numAt(d, "countries");
|
|
353
|
+
const last = strAt(d, "last_seen");
|
|
354
|
+
const seen = realInstant(last)
|
|
355
|
+
? ` Their latest last_seen is ${last}: when one of them was last seen listening on its port, not when its vulnerable version was last confirmed.`
|
|
356
|
+
: "";
|
|
357
|
+
return `${head} (state: exposed) The radar has ${hosts} internet-facing services (distinct ip:port, the exposed_hosts field) on record whose banner version maps to ${cve}${countries === undefined ? "" : ` across ${countries} countries`}.${seen} ${kev} ${how}`;
|
|
358
|
+
}
|
|
359
|
+
server.tool("cve_exposure", `Internet-exposure footprint for one CVE from EchelonGraph's KEV-exposure radar: how many internet-facing services (distinct ip:port, returned as exposed_hosts; a machine answering on two ports counts twice) the radar has on record running a version its CVE matcher maps to this CVE, with a country/product breakdown and a ransomware flag. Aggregate and host-redacted; free and keyless. Method: exposure counts are derived from Shodan data. ${SHODAN_OWNERSHIP} Every 12 h, when Shodan query credits allow, the radar runs one Shodan query per tracked product, reads up to 100 ip:port services per query, and keeps a service when its banner version matches a CISA-KEV or high-EPSS CVE; a service not seen on its port for 21 days is dropped. last_seen is when a service was last seen listening on its port, not when its vulnerable version was last confirmed: between searches a re-check that finds the port still listed refreshes it without re-reading the banner, so a patched service can stay counted while its port stays open. A count is therefore a banner-version inference over a sample, not an exploit test and not an internet-wide census. The radar only looks for its tracked set of CVEs: for a CVE outside that set the result says NOT ASSESSED, and its 0 is not a measurement.`, { cve_id: z.string().describe("a CVE ID, e.g. CVE-2023-44487") }, async ({ cve_id }) => {
|
|
360
|
+
const tool = "cve_exposure";
|
|
361
|
+
const id = cve_id.trim();
|
|
362
|
+
if (!id)
|
|
363
|
+
return badInput(tool, "cve_id is required");
|
|
364
|
+
// Refused here rather than sent: an API older than the id check answers a malformed id
|
|
365
|
+
// with a 200 and zeros, which would read as an answer about a CVE that does not exist.
|
|
366
|
+
if (!CVE_ID.test(id)) {
|
|
367
|
+
return badInput(tool, `cve_id ${JSON.stringify(id.slice(0, 32))} is not a CVE id (expected the form CVE-2023-44487)`);
|
|
368
|
+
}
|
|
369
|
+
// The canonical form, which is how the API stores and answers it.
|
|
370
|
+
const cve = id.toUpperCase();
|
|
75
371
|
try {
|
|
76
|
-
|
|
372
|
+
const r = await api(`/api/v1/public/kev-exposure/cve/${encodeURIComponent(cve)}`);
|
|
373
|
+
if (!r.ok)
|
|
374
|
+
return failed(tool, r);
|
|
375
|
+
return succeeded(r.data, exposureNote(okHead(tool, r.status), cve, r.data));
|
|
77
376
|
}
|
|
78
377
|
catch (e) {
|
|
79
|
-
return
|
|
378
|
+
return crashed(tool, e);
|
|
80
379
|
}
|
|
81
380
|
});
|
|
82
|
-
|
|
381
|
+
// Every quadrant that finds its services through Shodan, attributed. The exposed-database
|
|
382
|
+
// radar searches Shodan dorks and falls back to LeakIX when the Shodan query budget is low
|
|
383
|
+
// (core-backend/internal/exposeddb/poller.go).
|
|
384
|
+
const RADAR_SOURCES = "KEV-exposure, exposed-database and shadow-AI discovery use Shodan data (shadow AI also uses Certificate Transparency logs; exposed databases fall back to LeakIX when Shodan query credits run low).";
|
|
385
|
+
const RADARS = [
|
|
386
|
+
["kev_exposure", "/api/v1/public/kev-exposure/stats"],
|
|
387
|
+
["exposed_databases", "/api/v1/public/exposed-databases/stats"],
|
|
388
|
+
["leaked_credentials", "/api/v1/public/leaked-credentials/stats"],
|
|
389
|
+
["shadow_ai", "/api/v1/public/shadow-ai-radar/stats"],
|
|
390
|
+
];
|
|
391
|
+
// The shadow-AI stats answer, read honestly (#2307).
|
|
392
|
+
//
|
|
393
|
+
// stats.total counts EVERY observation, whatever its verification state (35,473 on
|
|
394
|
+
// 2026-09-26); the confirmed-exposed count is the sum of stats.visible_by_category (1,983
|
|
395
|
+
// that day). Only the second may be called exposed.
|
|
396
|
+
//
|
|
397
|
+
// Its `poller` block's running and last_run_at describe the radar's fleet, not the instance
|
|
398
|
+
// that answered (core-backend shadowctlog Poller.Status): last_run_at is when the fleet's
|
|
399
|
+
// leader last COMPLETED a crt.sh (Certificate Transparency) cycle, and running is true only
|
|
400
|
+
// when that was within pollerSilentAfter, 30 minutes, of the answer. The API sends both or
|
|
401
|
+
// neither, and omits both when it cannot tell; it never sends the Go zero time. An API older
|
|
402
|
+
// than that answered from the serving instance's own memory, so a follower sent running:false
|
|
403
|
+
// and last_run_at 0001-01-01T00:00:00Z, which reads as "the radar is down and has never run".
|
|
404
|
+
// So the note reads three cases: running with a real completion time, stopped since a real
|
|
405
|
+
// completion time, and unknown. Only the first two are relayed; an unknown block (no real
|
|
406
|
+
// completion time and running verdict) is dropped, so no running:false or zero time reaches
|
|
407
|
+
// the model as freshness.
|
|
408
|
+
const SHADOW_AI_SILENT_AFTER = "30 minutes";
|
|
409
|
+
function readShadowAI(sa) {
|
|
410
|
+
const sentences = [];
|
|
411
|
+
const byCategory = field(sa, "stats", "visible_by_category");
|
|
412
|
+
let confirmed;
|
|
413
|
+
if (byCategory !== null && typeof byCategory === "object" && !Array.isArray(byCategory)) {
|
|
414
|
+
const counts = Object.values(byCategory);
|
|
415
|
+
if (counts.every((n) => typeof n === "number" && Number.isFinite(n))) {
|
|
416
|
+
confirmed = counts.reduce((a, b) => a + b, 0);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const last24 = numAt(sa, "stats", "last_24h_visible_count");
|
|
420
|
+
sentences.push(confirmed === undefined
|
|
421
|
+
? "Shadow AI: the answer carries no usable stats.visible_by_category, so it does not say how many services are confirmed exposed."
|
|
422
|
+
: `Shadow AI: ${confirmed} confirmed exposed (the sum of stats.visible_by_category: services EchelonGraph's probes found answering without an authentication gate)${last24 === undefined ? "" : `, ${last24} of them observed in the last 24 h (last_24h_visible_count)`}.`);
|
|
423
|
+
const total = numAt(sa, "stats", "total");
|
|
424
|
+
if (total !== undefined) {
|
|
425
|
+
sentences.push(`stats.total (${total}) counts every observation whatever its verification state: that many observed, not that many exposed.`);
|
|
426
|
+
}
|
|
427
|
+
let data = sa;
|
|
428
|
+
const lastRun = strAt(sa, "poller", "last_run_at");
|
|
429
|
+
const running = boolAt(sa, "poller", "running");
|
|
430
|
+
if (realInstant(lastRun) && running === true) {
|
|
431
|
+
sentences.push(`Shadow-AI radar freshness: the radar's leader last completed a Certificate Transparency (crt.sh) discovery cycle at ${lastRun} (poller.running is true: that was within ${SHADOW_AI_SILENT_AFTER} of the API's answer).`);
|
|
432
|
+
}
|
|
433
|
+
else if (realInstant(lastRun) && running === false) {
|
|
434
|
+
sentences.push(`Shadow-AI radar freshness: no Certificate Transparency (crt.sh) discovery cycle has completed since ${lastRun}, when the radar's leader last completed one (poller.running is false: that was more than ${SHADOW_AI_SILENT_AFTER} before the API's answer), so the shadow-AI figures may be stale.`);
|
|
435
|
+
}
|
|
436
|
+
else if (field(sa, "poller") === undefined) {
|
|
437
|
+
sentences.push("Shadow-AI radar freshness is unknown: the answer carries no poller block.");
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
const rest = { ...sa };
|
|
441
|
+
delete rest.poller;
|
|
442
|
+
data = rest;
|
|
443
|
+
sentences.push("Shadow-AI radar freshness is unknown: the answer's poller block does not give both a real time at which the radar's leader last completed a cycle (poller.last_run_at) and a running verdict (poller.running), so that block was left out of the data above.");
|
|
444
|
+
}
|
|
445
|
+
const lastObservation = strAt(sa, "stats", "last_observation");
|
|
446
|
+
if (realInstant(lastObservation))
|
|
447
|
+
sentences.push(`Latest shadow-AI observation on record: ${lastObservation}.`);
|
|
448
|
+
return { data, sentences };
|
|
449
|
+
}
|
|
450
|
+
server.tool("exposure_radar", `Aggregate totals from EchelonGraph's internet-exposure radars, each refreshed on its own schedule: shadow AI services found through Certificate Transparency logs and Shodan and then checked by EchelonGraph's identified probes; 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); and leaked credentials sampled from public GitHub push events. The kev_exposure and exposed_databases distinct_hosts figures count distinct ip:port services, so a machine answering on two ports counts twice. For shadow AI, stats.total counts every observation whatever its verification state (observed, not exposed); the confirmed-exposed count is the sum of stats.visible_by_category, and last_24h_visible_count is its last-24-hour slice. The shadow-AI poller block's 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. ${SHODAN_OWNERSHIP}`, {}, async () => {
|
|
451
|
+
const tool = "exposure_radar";
|
|
83
452
|
try {
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
453
|
+
const results = await Promise.all(RADARS.map(([, path]) => api(path)));
|
|
454
|
+
const data = {};
|
|
455
|
+
const failures = [];
|
|
456
|
+
RADARS.forEach(([name], i) => {
|
|
457
|
+
const r = results[i];
|
|
458
|
+
if (r.ok)
|
|
459
|
+
data[name] = r.data;
|
|
460
|
+
else
|
|
461
|
+
failures.push([name, r]);
|
|
462
|
+
});
|
|
463
|
+
if (failures.length) {
|
|
464
|
+
// All or nothing: a radar picture with one quadrant missing reads as the whole one,
|
|
465
|
+
// and the missing quadrant reads as zero. Name every failure; withhold the rest.
|
|
466
|
+
const answered = Object.keys(data);
|
|
467
|
+
const lines = failures.map(([name, f]) => `- ${name}: ${describeFailure(f)}`);
|
|
468
|
+
const withheld = answered.length
|
|
469
|
+
? ` The ${answered.length} radar(s) that did answer (${answered.join(", ")}) are withheld: a partial radar picture would be read as the whole one.`
|
|
470
|
+
: "";
|
|
471
|
+
return {
|
|
472
|
+
content: [text(`${tool} FAILED: ${failures.length} of ${RADARS.length} radars could not be read from ${SHOWN_BASE}.\n${lines.join("\n")}\n${NOT_A_FINDING}${withheld}`)],
|
|
473
|
+
isError: true,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
const shadow = readShadowAI(data.shadow_ai);
|
|
477
|
+
data.shadow_ai = shadow.data;
|
|
478
|
+
return succeeded(data, `${okHead(tool)} All ${RADARS.length} radars answered: ${RADARS.map(([name]) => name).join(", ")}. ${shadow.sentences.join(" ")} ${RADAR_SOURCES} ${SHODAN_OWNERSHIP} Each radar refreshes on its own schedule.`);
|
|
91
479
|
}
|
|
92
480
|
catch (e) {
|
|
93
|
-
return
|
|
481
|
+
return crashed(tool, e);
|
|
94
482
|
}
|
|
95
483
|
});
|
|
96
484
|
const transport = new StdioServerTransport();
|
|
97
485
|
await server.connect(transport);
|
|
98
486
|
// MCP uses stdout for the protocol — diagnostics go to stderr.
|
|
99
|
-
console.error(`EchelonGraph CVE MCP server running (API: ${
|
|
487
|
+
console.error(`EchelonGraph CVE MCP server running (API: ${SHOWN_BASE}, timeout ${TIMEOUT_MS} ms)`);
|
package/package.json
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "echelongraph-mcp",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"mcpName": "io.echelongraph/echelongraph-mcp",
|
|
5
|
+
"description": "EchelonGraph CVE and internet-exposure data as an MCP server: CVEs with EchelonGraph multi-source scores, CISA-KEV and EPSS, plus a per-CVE internet-exposure footprint derived from Shodan data. Shodan data is owned by Shodan, which holds its copyright (© Shodan). Free and keyless, for Claude, Cursor and any MCP client.",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"bin": {
|
|
7
8
|
"echelongraph-mcp": "dist/index.js"
|
|
8
9
|
},
|
|
9
10
|
"files": [
|
|
10
11
|
"dist",
|
|
11
|
-
"README.md"
|
|
12
|
+
"README.md",
|
|
13
|
+
"server.json"
|
|
12
14
|
],
|
|
13
15
|
"scripts": {
|
|
14
16
|
"build": "tsc",
|
|
15
17
|
"start": "node dist/index.js",
|
|
16
|
-
"smoke": "node test/smoke.mjs"
|
|
18
|
+
"smoke": "node test/smoke.mjs",
|
|
19
|
+
"test": "npm run build && node --test test/tools.test.mjs"
|
|
17
20
|
},
|
|
18
21
|
"keywords": [
|
|
19
22
|
"mcp",
|
|
@@ -33,7 +36,7 @@
|
|
|
33
36
|
"homepage": "https://echelongraph.io/pulse/mcp",
|
|
34
37
|
"repository": {
|
|
35
38
|
"type": "git",
|
|
36
|
-
"url": "git+https://github.com/
|
|
39
|
+
"url": "git+https://github.com/echelongraph/EchelonGraph.git"
|
|
37
40
|
},
|
|
38
41
|
"engines": {
|
|
39
42
|
"node": ">=18"
|
package/server.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.echelongraph/echelongraph-mcp",
|
|
4
|
+
"title": "EchelonGraph CVE & Exposure",
|
|
5
|
+
"description": "CVEs (NVD, CISA-KEV, EPSS, GHSA) and per-CVE exposure from Shodan data (© Shodan). Free, keyless.",
|
|
6
|
+
"websiteUrl": "https://echelongraph.io/pulse/mcp",
|
|
7
|
+
"version": "1.0.2",
|
|
8
|
+
"packages": [
|
|
9
|
+
{
|
|
10
|
+
"registryType": "npm",
|
|
11
|
+
"identifier": "echelongraph-mcp",
|
|
12
|
+
"version": "1.0.2",
|
|
13
|
+
"transport": {
|
|
14
|
+
"type": "stdio"
|
|
15
|
+
},
|
|
16
|
+
"environmentVariables": [
|
|
17
|
+
{
|
|
18
|
+
"name": "ECHELONGRAPH_API_BASE",
|
|
19
|
+
"description": "Override the API base URL (self-host or proxy).",
|
|
20
|
+
"isRequired": false,
|
|
21
|
+
"format": "string",
|
|
22
|
+
"default": "https://app.echelongraph.io"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"name": "ECHELONGRAPH_API_TIMEOUT_MS",
|
|
26
|
+
"description": "Per-request timeout in milliseconds. A slower answer is reported as a failed lookup, not as empty data.",
|
|
27
|
+
"isRequired": false,
|
|
28
|
+
"format": "number",
|
|
29
|
+
"default": "15000"
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|