echelongraph-mcp 2.0.0 → 2.2.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/README.md +56 -26
- package/dist/index.js +150 -48
- package/package.json +1 -1
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -30,29 +30,38 @@ sources on a schedule; each radar refreshes on its own.
|
|
|
30
30
|
|
|
31
31
|
Every 12 hours, when Shodan query credits allow, the KEV-exposure radar runs one Shodan query
|
|
32
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
|
|
34
|
-
for 21 days is dropped. A count is therefore a banner-version inference
|
|
35
|
-
exploit test, and not an internet-wide census.
|
|
36
|
-
|
|
37
|
-
`last_seen` is when
|
|
38
|
-
version was last confirmed.
|
|
39
|
-
|
|
40
|
-
|
|
33
|
+
banner version matches a CISA-KEV or high-EPSS (>= 0.5) CVE. A service whose row has not been
|
|
34
|
+
written or refreshed for 21 days is dropped. A count is therefore a banner-version inference
|
|
35
|
+
over a sample: not an exploit test, and not an internet-wide census.
|
|
36
|
+
|
|
37
|
+
`last_seen` is when EchelonGraph last wrote or refreshed a service's row, not when the service
|
|
38
|
+
was observed, and not when its vulnerable version was last confirmed. It is set to the time of
|
|
39
|
+
the write when a search matches the banner, and again when a re-check finds the port still
|
|
40
|
+
listed by Shodan InternetDB, without re-reading the banner, so a patched service can stay
|
|
41
|
+
counted while its port stays open. Shodan's own time for the banner is not stored. So
|
|
42
|
+
`last_seen` dates the write, never the sighting, and it is never `measured_at` (see
|
|
43
|
+
"Structured results").
|
|
41
44
|
|
|
42
45
|
The unit is a service, not a machine. Shodan returns one banner per port and the radar keys
|
|
43
46
|
each observation on ip:port, so a machine answering on two ports counts twice. The API's field
|
|
44
47
|
names are kept (`exposed_hosts` here; `distinct_hosts`, `ransomware_hosts` and each ranked
|
|
45
48
|
row's count in `exposure_radar`), but every one of them counts ip:port services.
|
|
46
49
|
|
|
47
|
-
The radar only looks for its **tracked** set of CVEs, so a zero means different things
|
|
50
|
+
The radar only looks for its **tracked** set of CVEs, so a zero means different things. The
|
|
51
|
+
note tags each case with its `exposure_state`, as "(exposure_state: …)"; that tag says what the
|
|
52
|
+
count is, and is not the result's `state`:
|
|
48
53
|
|
|
49
54
|
| API answer | What the tool says |
|
|
50
55
|
|---|---|
|
|
51
|
-
| `tracked:
|
|
52
|
-
| `tracked:
|
|
53
|
-
|
|
|
56
|
+
| `tracked: true`, `exposed_hosts` above 0 | `exposure_state` `exposed`: the count of services the radar has on record for the CVE. |
|
|
57
|
+
| `tracked: false` | `exposure_state` `not_assessed`, **NOT ASSESSED**: outside the radar's tracked set; 0 is not a measurement. |
|
|
58
|
+
| `tracked: true`, `exposed_hosts: 0` | `exposure_state` `measured_zero`, a 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, and undated: the answer does not say when the radar looked. The backend does not send this answer today. |
|
|
59
|
+
| 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 (`exposure_state` `tracking_unknown`). |
|
|
54
60
|
| HTTP 400, or an id that is not `CVE-YYYY-NNNN…` | An error result tagged `invalid_input`; nothing was looked up. |
|
|
55
61
|
|
|
62
|
+
Whatever the `exposure_state`, the result's `state` is `not_assessed` with `measured_at` `null`:
|
|
63
|
+
the answer does not say when any service it counts was observed (see "Structured results").
|
|
64
|
+
|
|
56
65
|
### How `exposure_radar` counts
|
|
57
66
|
|
|
58
67
|
`exposure_radar` relays each radar's answer cut to the fields it can label, and its note labels
|
|
@@ -151,7 +160,9 @@ yet verified, whose hostname no longer resolves, or whose service no longer answ
|
|
|
151
160
|
|
|
152
161
|
The tool relays no number it cannot label. A stats field this version does not know is left
|
|
153
162
|
out, and the note names it; so is a count in an unexpected shape. A ranked row carries only
|
|
154
|
-
its name and its count, and a field added to one is named too.
|
|
163
|
+
its name and its count, and a field added to one is named too. The `poller` block's keys are
|
|
164
|
+
checked the same way: a key this version does not know is left out and named, while the
|
|
165
|
+
instance fields it knows are left out as described below.
|
|
155
166
|
|
|
156
167
|
## What a result means
|
|
157
168
|
|
|
@@ -159,7 +170,8 @@ Every tool answers in one of two shapes, so a model reading the result cannot mi
|
|
|
159
170
|
outage for an all-clear:
|
|
160
171
|
|
|
161
172
|
- **Success** — the first text block is the API's JSON verbatim; the second is a one-line
|
|
162
|
-
note saying the call succeeded, which base URL answered, and what it found
|
|
173
|
+
note saying the call succeeded, which base URL answered, and what it found; the third is
|
|
174
|
+
the structured result as JSON, less what the first two already say (below). When the
|
|
163
175
|
feed genuinely holds nothing for the query the note says so in words ("we looked and
|
|
164
176
|
found nothing … not a lookup failure"), because a measured zero is a measurement. The
|
|
165
177
|
exception to verbatim is `exposure_radar`: each radar is cut to the fields listed above, and
|
|
@@ -179,10 +191,13 @@ outage for an all-clear:
|
|
|
179
191
|
the host could not be reached, it answered non-2xx, it took longer than the timeout, or
|
|
180
192
|
it answered 2xx with a body that is not a JSON object. The text names the tool, the
|
|
181
193
|
cause (status code or error kind), the path, and the base URL, and says it is not a
|
|
182
|
-
finding
|
|
194
|
+
finding; the second text block is the structured result as JSON, less the message's
|
|
195
|
+
sentences (below). A failure is never rendered as a success with null fields.
|
|
183
196
|
|
|
184
|
-
Both shapes also carry a structured result, below
|
|
185
|
-
a client that
|
|
197
|
+
Both shapes also carry a structured result, below, and repeat it in their last text block, less
|
|
198
|
+
what an earlier block already says, so a client that passes only `content` to the model still
|
|
199
|
+
sees how the answer was measured. The first text block (the API's JSON, or the failure) and the
|
|
200
|
+
note after it are where 1.x put them.
|
|
186
201
|
|
|
187
202
|
## Structured results
|
|
188
203
|
|
|
@@ -202,10 +217,21 @@ declares its shape as an `outputSchema` in `tools/list`, with a title and the an
|
|
|
202
217
|
| `data` | On a success only: the same JSON as the first text block. |
|
|
203
218
|
| `error` | On a failure only: `kind`, `path`, `status` and `message`. |
|
|
204
219
|
|
|
220
|
+
The last text block of every result is this structured result serialized as JSON, less what
|
|
221
|
+
an earlier text block already says verbatim, since a client may pass only `content` to the
|
|
222
|
+
model. It carries the same `state`, `measured_at`, `coverage` and `freshness` (and on a failure
|
|
223
|
+
`error`), key for key. It leaves out `data`, which is a success's first text block, and the
|
|
224
|
+
note's sentences, which are the text block just before it (on a failure, the message) and
|
|
225
|
+
with which `notes` ends: its `notes` holds only the sentences the envelope adds about itself,
|
|
226
|
+
and is left out when there are none. It leaves out `method` too where that note quotes it verbatim, as
|
|
227
|
+
`cve_exposure`'s does ("Method: …"). So the text blocks together carry the whole structured
|
|
228
|
+
result, and cannot disagree with it. Before 2.2.0 this block repeated the whole note, so every
|
|
229
|
+
note was sent twice.
|
|
230
|
+
|
|
205
231
|
| `state` | What it means |
|
|
206
232
|
|---|---|
|
|
207
|
-
| `measured` | A measurement of what was asked. An exposure count is `measured` only when the answer
|
|
208
|
-
| `not_assessed` | The answer holds no dated measurement of what was asked, so no
|
|
233
|
+
| `measured` | A measurement of what was asked. An exposure count is `measured` only when the answer says when what it counts was observed (`measured_at`) and how it was produced (`method`). No exposure answer says when today, so neither exposure tool answers `measured`. |
|
|
234
|
+
| `not_assessed` | The answer holds no dated measurement of what was asked, so no count in it is presented as one: 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. It can still relay a count, as what the source holds on record, undated: `cve_exposure`'s `exposed_hosts` when its `exposure_state` is `exposed`, and `exposure_radar`'s labelled totals. The notes, and `exposure_state` where the result carries it, say what each count is. |
|
|
209
235
|
| `failed` | The lookup did not complete. Not a finding. |
|
|
210
236
|
| `invalid_input` | The input was refused, by this server or by the API, so nothing was looked up. Not a finding. |
|
|
211
237
|
|
|
@@ -220,11 +246,14 @@ a note says so. Per tool:
|
|
|
220
246
|
`total_counted` is false the total is not a count, and the note does not call it one.
|
|
221
247
|
- `get_cve` is `measured`, and its `measured_at` is the record's `updated_at`, when EchelonGraph
|
|
222
248
|
last wrote it.
|
|
223
|
-
- `cve_exposure` is `
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
`
|
|
227
|
-
|
|
249
|
+
- `cve_exposure` is `not_assessed` with `measured_at` `null`, every answer: the per-CVE answer
|
|
250
|
+
carries no time at which the services it counts were observed. Its `last_seen` is when
|
|
251
|
+
EchelonGraph last wrote or refreshed one of their rows, not when any of them was observed,
|
|
252
|
+
so it is never `measured_at`, and a note says so. A tracked zero is `not_assessed` too, since
|
|
253
|
+
a zero has no observation to date it. The count is still relayed, labelled: `exposure_state`
|
|
254
|
+
is `exposed`, `measured_zero`, `not_assessed` or `tracking_unknown`, the cases of "How
|
|
255
|
+
`cve_exposure` counts", and `coverage.in_scope` is the API's `tracked`. It becomes `measured`
|
|
256
|
+
only when the API serves a time at which the counted services were observed.
|
|
228
257
|
- `exposure_radar` is `not_assessed` with `measured_at` `null`: every radar answered, but no
|
|
229
258
|
stats answer says when the services or records it counts were observed, so no count is
|
|
230
259
|
presented as a dated measurement. Its numbers keep the labels above. `freshness` holds each
|
|
@@ -255,8 +284,9 @@ carry the package's name and version (`serverInfo`), and every API request carri
|
|
|
255
284
|
User-Agent, `echelongraph-mcp/<version>`.
|
|
256
285
|
|
|
257
286
|
Both handshakes also carry the server instructions: the data is public; what `state`,
|
|
258
|
-
`measured_at` and `freshness` mean
|
|
259
|
-
services, not an internet-wide census; and that Shodan
|
|
287
|
+
`measured_at` and `freshness` mean, and that the last text block repeats them; that exposure
|
|
288
|
+
numbers are aggregate counts of ip:port services, not an internet-wide census; and that Shodan
|
|
289
|
+
data is Shodan's.
|
|
260
290
|
|
|
261
291
|
## Install
|
|
262
292
|
|
package/dist/index.js
CHANGED
|
@@ -13,9 +13,11 @@
|
|
|
13
13
|
// nothing ("we looked and found nothing" is a measurement). One exception: exposure_radar
|
|
14
14
|
// relays each radar's answer cut to the fields it can label by what they count, and names
|
|
15
15
|
// what it left out (readKEVExposure, readExposedDatabases, readLeakedCredentials and
|
|
16
|
-
// readShadowAI; #2307, #2313).
|
|
16
|
+
// readShadowAI; #2307, #2313). content[2] is the envelope below, as JSON, less what
|
|
17
|
+
// content[0] and the note already say.
|
|
17
18
|
// - failure (isError: true): the lookup did not complete — unreachable host, non-2xx,
|
|
18
|
-
// timeout, or a 2xx whose body is not a JSON object — named by tool, cause and base URL
|
|
19
|
+
// timeout, or a 2xx whose body is not a JSON object — named by tool, cause and base URL,
|
|
20
|
+
// in content[0]; content[1] is the envelope below, as JSON, less the message's sentences.
|
|
19
21
|
// Never a success with null fields: a model handed one of those tells its user "no
|
|
20
22
|
// exposure found", and an outage becomes an all-clear.
|
|
21
23
|
//
|
|
@@ -23,8 +25,18 @@
|
|
|
23
25
|
// structuredContent: one envelope that says how the answer was measured — state (measured,
|
|
24
26
|
// not_assessed, failed, invalid_input), measured_at, method, coverage, freshness and notes —
|
|
25
27
|
// with, on a success, `data` equal to what content[0] carries, and on a failure an `error`.
|
|
26
|
-
// Each tool advertises the envelope as its outputSchema.
|
|
27
|
-
//
|
|
28
|
+
// Each tool advertises the envelope as its outputSchema.
|
|
29
|
+
//
|
|
30
|
+
// The envelope in the text (#2440, #2467). Many clients pass only `content` to the model, so the
|
|
31
|
+
// last text block of every result is structuredContent serialized as JSON, less what an earlier
|
|
32
|
+
// text block already carries verbatim: `data` (content[0] on a success), the note's sentences
|
|
33
|
+
// (the block just before, which structuredContent's notes end with), and method where that note
|
|
34
|
+
// quotes it. The rest — state, measured_at, coverage, freshness, the envelope's own notes and,
|
|
35
|
+
// on a failure, error — is copied key for key, so the text blocks together are the whole of
|
|
36
|
+
// structuredContent and cannot disagree with it. The MCP spec (2025-06-18, Tools, Structured
|
|
37
|
+
// Content) asks a tool that returns structured content to return it serialized in a text block
|
|
38
|
+
// too; the JSON block stays first and the note second, as in 1.x, so a client that parses
|
|
39
|
+
// content[0] is unaffected.
|
|
28
40
|
//
|
|
29
41
|
// Protocol eras (#2311). serveStdio answers both: a 2026-07-28 client's server/discover and
|
|
30
42
|
// per-request `_meta` envelope, and a 2025-era client's `initialize` handshake. The first
|
|
@@ -198,13 +210,40 @@ const NO_RECORD = "The API returned no record for this request; the quoted messa
|
|
|
198
210
|
// It is tagged invalid_input, the same state a request this server refuses locally carries.
|
|
199
211
|
const INVALID_INPUT = "(state: invalid_input)";
|
|
200
212
|
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.";
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
213
|
+
// The envelope as a text block (#2440), so a client that passes only `content` to the model
|
|
214
|
+
// still sees how the answer was measured: structuredContent serialized as JSON, less what an
|
|
215
|
+
// earlier text block already carries verbatim (#2467). 2.1.0 sent the whole envelope without
|
|
216
|
+
// `data`, so every note went out twice, once as prose and once as the notes array, and
|
|
217
|
+
// cve_exposure's method a third time.
|
|
218
|
+
// data left out: content[0] carries it verbatim.
|
|
219
|
+
// notes only the envelope's own sentences (`own`), which no text block says; the rest of
|
|
220
|
+
// structuredContent's notes are the sentences of `said`, the text block just before
|
|
221
|
+
// this one (the note, or a failure's message). Left out when there are none.
|
|
222
|
+
// method left out when `said` quotes it verbatim (cve_exposure's note, "Method: …").
|
|
223
|
+
// Every other key is copied as is, in structuredContent's order, so the text blocks together are
|
|
224
|
+
// the whole of structuredContent and cannot disagree with it.
|
|
225
|
+
const envelopeText = (structured, said, own) => {
|
|
226
|
+
const envelope = {};
|
|
227
|
+
for (const [k, v] of Object.entries(structured)) {
|
|
228
|
+
if (k === "data")
|
|
229
|
+
continue;
|
|
230
|
+
if (k === "notes") {
|
|
231
|
+
if (own.length)
|
|
232
|
+
envelope.notes = own;
|
|
233
|
+
}
|
|
234
|
+
else if (!(k === "method" && typeof v === "string" && said.includes(v)))
|
|
235
|
+
envelope[k] = v;
|
|
236
|
+
}
|
|
237
|
+
return text(JSON.stringify(envelope, null, 2));
|
|
238
|
+
};
|
|
239
|
+
// A failure result: the #1874 text, and an envelope saying nothing was measured, in
|
|
240
|
+
// structuredContent and again as the last text block. The error quotes only what
|
|
241
|
+
// describeFailure already quotes (a redacted cause, the API's own message). Every note is a
|
|
242
|
+
// sentence of the message, so the text envelope carries none.
|
|
243
|
+
const failure = (state, message, error, coverage = null) => {
|
|
244
|
+
const structuredContent = { state, measured_at: null, method: null, coverage, freshness: null, notes: sentences(message), error };
|
|
245
|
+
return { content: [text(message), envelopeText(structuredContent, message, [])], structuredContent, isError: true };
|
|
246
|
+
};
|
|
208
247
|
const errorOf = (f) => ({ kind: f.kind, path: f.path, status: f.status ?? null, message: f.detail });
|
|
209
248
|
const failed = (tool, f) => {
|
|
210
249
|
if (f.kind === "http" && f.status === 400) {
|
|
@@ -248,12 +287,13 @@ const checked = (tool, schema, r) => {
|
|
|
248
287
|
const p = schema.safeParse(r.structuredContent);
|
|
249
288
|
return p.success ? r : unexpectedShape(tool, p.error);
|
|
250
289
|
};
|
|
251
|
-
// A success: content[0] is the data, content[1] the note
|
|
252
|
-
// as sentences after any the envelope adds about itself.
|
|
253
|
-
const succeeded = (data, note, env) =>
|
|
254
|
-
|
|
255
|
-
structuredContent
|
|
256
|
-
}
|
|
290
|
+
// A success: content[0] is the data, content[1] the note, content[2] the envelope less both;
|
|
291
|
+
// the envelope carries both, the note as sentences after any the envelope adds about itself.
|
|
292
|
+
const succeeded = (data, note, env) => {
|
|
293
|
+
const own = env.notes ?? [];
|
|
294
|
+
const structuredContent = { ...env, notes: [...own, ...sentences(note)], data };
|
|
295
|
+
return { content: [text(JSON.stringify(data, null, 2)), text(note), envelopeText(structuredContent, note, own)], structuredContent };
|
|
296
|
+
};
|
|
257
297
|
const okHead = (tool, status) => `${tool} OK: EchelonGraph answered${status === undefined ? "" : ` HTTP ${status}`} from ${SHOWN_BASE}.`;
|
|
258
298
|
// Tolerant readers for the note: a missing or renamed field degrades the sentence, never
|
|
259
299
|
// the result — content[0] still carries whatever the API sent.
|
|
@@ -428,10 +468,16 @@ async function getCVE(cve_id) {
|
|
|
428
468
|
// The API says which with `tracked`; the note reads it and, when the field is absent (an older
|
|
429
469
|
// API, or a CVE the radar cannot decide on), makes no claim either way about the zero.
|
|
430
470
|
//
|
|
431
|
-
// last_seen is not
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
//
|
|
471
|
+
// last_seen is not an observation time (#2439). It is when EchelonGraph last wrote or refreshed
|
|
472
|
+
// a service's row: core-backend kevexposure store.go Upsert sets it to now() when a search
|
|
473
|
+
// matches the banner, and Touch sets it to now() when the InternetDB re-check (poller.go
|
|
474
|
+
// reconcileStale) finds the service's PORT still listed, without re-reading the version. So a
|
|
475
|
+
// patched service can stay counted while its port stays open, and last_seen is our write time,
|
|
476
|
+
// not the time Shodan saw the service: on 2026-09-27 production's 50 newest rows carried
|
|
477
|
+
// last_seen within 0.78 s of one another, a batch write. Shodan's own banner timestamp is not
|
|
478
|
+
// stored.
|
|
479
|
+
// Every text calls last_seen a write or refresh time, never a sighting ("last seen listening",
|
|
480
|
+
// "re-seen", "most recently seen"), and it is never measured_at (see observedAt).
|
|
435
481
|
const CVE_ID = /^CVE-\d{4}-\d{4,}$/i;
|
|
436
482
|
// Quoted when the API does not send its own `method` string.
|
|
437
483
|
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";
|
|
@@ -442,7 +488,7 @@ const SHODAN_ATTRIBUTION = "Exposure counts are derived from Shodan data.";
|
|
|
442
488
|
// and copyright in the applicable Shodan materials" (#2306). The attribution above does the
|
|
443
489
|
// first; this sentence, placed beside it wherever Shodan data is named, does the second.
|
|
444
490
|
const SHODAN_OWNERSHIP = "Shodan data is owned by Shodan, which holds its copyright (© Shodan).";
|
|
445
|
-
const CVE_EXPOSURE_DESCRIPTION = `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
|
|
491
|
+
const CVE_EXPOSURE_DESCRIPTION = `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 whose row has not been written or refreshed for 21 days is dropped. last_seen is when EchelonGraph last wrote or refreshed a service's row, not when the service was observed and not when its vulnerable version was last confirmed: it is set to the time of the write when a search matches the banner, and again when a re-check finds the port still listed by Shodan InternetDB, 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 note says NOT ASSESSED (exposure_state not_assessed), and its 0 is not a measurement.`;
|
|
446
492
|
function exposureNote(head, id, d) {
|
|
447
493
|
const cve = strAt(d, "cve_id") ?? id;
|
|
448
494
|
const hosts = numAt(d, "exposed_hosts");
|
|
@@ -468,22 +514,24 @@ function exposureNote(head, id, d) {
|
|
|
468
514
|
const why = hosts === 0
|
|
469
515
|
? "outside the radar's tracked set; 0 is not a measurement, and it says nothing about whether any host is exposed to it."
|
|
470
516
|
: `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.`;
|
|
471
|
-
return `${head} (
|
|
517
|
+
return `${head} (exposure_state: not_assessed) NOT ASSESSED: ${cve} is ${why} ${kev} ${how}`;
|
|
472
518
|
}
|
|
519
|
+
// The tag in parentheses is exposure_state, named as such (#2440): it says what the count is,
|
|
520
|
+
// and is not the envelope's state, which the last text block gives.
|
|
473
521
|
if (hosts === 0 && tracked === true) {
|
|
474
522
|
// The backend answers tracked:true only when it holds rows (exposed_hosts > 0), so this
|
|
475
523
|
// branch does not fire today; it is worded for the day it does.
|
|
476
|
-
return `${head} (
|
|
524
|
+
return `${head} (exposure_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}`;
|
|
477
525
|
}
|
|
478
526
|
if (hosts === 0) {
|
|
479
|
-
return `${head} (
|
|
527
|
+
return `${head} (exposure_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}`;
|
|
480
528
|
}
|
|
481
529
|
const countries = numAt(d, "countries");
|
|
482
530
|
const last = strAt(d, "last_seen");
|
|
483
531
|
const seen = realInstant(last)
|
|
484
|
-
? ` Their latest last_seen is ${last}: when one of
|
|
532
|
+
? ` Their latest last_seen is ${last}: when EchelonGraph last wrote or refreshed one of their rows, not when any of them was observed and not when a vulnerable version was last confirmed.`
|
|
485
533
|
: "";
|
|
486
|
-
return `${head} (
|
|
534
|
+
return `${head} (exposure_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}`;
|
|
487
535
|
}
|
|
488
536
|
function cveExposureState(d) {
|
|
489
537
|
const hosts = numAt(d, "exposed_hosts");
|
|
@@ -496,20 +544,36 @@ function cveExposureState(d) {
|
|
|
496
544
|
return tracked === true ? "measured_zero" : "tracking_unknown";
|
|
497
545
|
return "exposed";
|
|
498
546
|
}
|
|
499
|
-
// The
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
//
|
|
547
|
+
// The seam for an observation time (#2439). A cve_exposure count is measured only when the
|
|
548
|
+
// answer says when the services it counts were observed, and this is the one place that reads
|
|
549
|
+
// such a time. The per-CVE answer serves none today: its fields are cve_id, exposed_hosts,
|
|
550
|
+
// countries, tracked, kev_listed, kev_catalog_listed, kev_seen_in_observations, ransomware,
|
|
551
|
+
// top_countries, top_products, method, last_seen and generated_at (production, 2026-09-27), and
|
|
552
|
+
// last_seen is EchelonGraph's write or refresh time (see the comment above CVE_ID), while
|
|
553
|
+
// generated_at is when the API built the answer. So this returns null and every cve_exposure
|
|
554
|
+
// answer is not_assessed. When the API serves an observation time (#2438), read that field here
|
|
555
|
+
// and nowhere else: measured and measured_at follow from this function alone, never from
|
|
556
|
+
// last_seen.
|
|
557
|
+
function observedAt(_d) {
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
// The envelope of a cve_exposure answer. Only a count of services on record that the answer
|
|
561
|
+
// dates by when they were observed (observedAt) is measured; every other answer is
|
|
562
|
+
// not_assessed, and its notes say why. Today that is every answer: the count is relayed,
|
|
563
|
+
// labelled by exposure_state and the note, as what the radar holds on record. A tracked zero
|
|
564
|
+
// is not_assessed too: it has no observation to date it, and the per-CVE answer does not carry
|
|
565
|
+
// the time of the radar's last completed check (exposure_radar relays that as
|
|
566
|
+
// kev_exposure.last_run_at).
|
|
504
567
|
function cveExposureEnvelope(cve, d) {
|
|
505
568
|
const exposureState = cveExposureState(d);
|
|
569
|
+
const observed = observedAt(d);
|
|
570
|
+
const measured = exposureState === "exposed" && realInstant(observed ?? undefined);
|
|
506
571
|
const last = strAt(d, "last_seen");
|
|
507
|
-
const measured = exposureState === "exposed" && realInstant(last);
|
|
508
572
|
const tracked = boolAt(d, "tracked");
|
|
509
573
|
const why = {
|
|
510
574
|
exposed: measured
|
|
511
|
-
? `state is measured: exposed_hosts counts the services the radar has on record for ${cve}, and measured_at is
|
|
512
|
-
: `state is not_assessed:
|
|
575
|
+
? `state is measured: exposed_hosts counts the services the radar has on record for ${cve}, and measured_at is the time the API gives for when they were observed.`
|
|
576
|
+
: `state is not_assessed: exposed_hosts counts the services the radar has on record for ${cve}, but the answer does not say when any of them was observed, so that count is relayed as what the radar holds on record, not as a dated measurement, and measured_at is null.`,
|
|
513
577
|
measured_zero: `state is not_assessed: the API marks ${cve} as tracked with 0 services on record, but a zero has no observation to date it and the answer does not say when the radar last looked, so it is not presented as a dated measurement.`,
|
|
514
578
|
not_assessed: `state is not_assessed: ${cve} is outside the radar's tracked set, so no number in data is a measurement of its exposure.`,
|
|
515
579
|
tracking_unknown: `state is not_assessed: the API does not say whether ${cve} is in the radar's tracked set, so its 0 is not a measurement.`,
|
|
@@ -517,13 +581,18 @@ function cveExposureEnvelope(cve, d) {
|
|
|
517
581
|
};
|
|
518
582
|
return {
|
|
519
583
|
state: measured ? "measured" : "not_assessed",
|
|
520
|
-
measured_at: measured ?
|
|
584
|
+
measured_at: measured ? observed : null,
|
|
521
585
|
method: (strAt(d, "method") ?? EXPOSURE_METHOD).replace(/\s+$/, ""),
|
|
522
586
|
coverage: { in_scope: tracked ?? null },
|
|
523
587
|
freshness: null,
|
|
524
588
|
exposure_state: exposureState,
|
|
525
589
|
notes: [
|
|
526
590
|
why[exposureState ?? "none"],
|
|
591
|
+
...(realInstant(last)
|
|
592
|
+
? [
|
|
593
|
+
`last_seen (${last}) is not measured_at: it is when EchelonGraph last wrote or refreshed one of these rows (when a Shodan search matched the banner, or a re-check found the port still listed), not when any service was observed, and Shodan's own banner time is not stored.`,
|
|
594
|
+
]
|
|
595
|
+
: []),
|
|
527
596
|
tracked === undefined
|
|
528
597
|
? "coverage.in_scope is null: the answer does not say whether the CVE is in the radar's tracked set."
|
|
529
598
|
: `coverage.in_scope is the API's tracked verdict: ${tracked ? "the CVE is in the radar's tracked set" : "the CVE is outside the radar's tracked set"}.`,
|
|
@@ -579,8 +648,8 @@ const RADARS = [
|
|
|
579
648
|
// under the API's names, every one of those reads as an exposure count. So the tool relays them
|
|
580
649
|
// grouped as confirmed_exposed, observed and authentication, with the note labelling each, and
|
|
581
650
|
// relays nothing it cannot label: a stats field this version does not know, a field other than
|
|
582
|
-
// a ranked row's label and count, and the poller block's instance fields are left out,
|
|
583
|
-
//
|
|
651
|
+
// a ranked row's label and count, and the poller block's instance fields are left out, and a
|
|
652
|
+
// field of any of them this version does not know is named in the note.
|
|
584
653
|
//
|
|
585
654
|
// The `poller` block's running and last_run_at describe the radar's fleet, not the instance
|
|
586
655
|
// that answered (core-backend shadowctlog Poller.Status): last_run_at is when the fleet's
|
|
@@ -612,6 +681,22 @@ const SHADOW_AI_STATS = new Set([
|
|
|
612
681
|
"top_issuers",
|
|
613
682
|
"trend_30d",
|
|
614
683
|
]);
|
|
684
|
+
// The poller block's fields this version knows (core-backend shadowctlog poller.go Status):
|
|
685
|
+
// running and last_run_at, relayed when both are real; the rest describe the instance that
|
|
686
|
+
// answered and are left out by design (see above). Any other key is left out and named, as a
|
|
687
|
+
// stats field is (#2313 item 7, #2440): an injected poller.exposed_now must not vanish unnamed.
|
|
688
|
+
const SHADOW_AI_POLLER = new Set([
|
|
689
|
+
"running",
|
|
690
|
+
"last_run_at",
|
|
691
|
+
"interval_seconds",
|
|
692
|
+
"shodan_enabled",
|
|
693
|
+
"last_new_inserts",
|
|
694
|
+
"last_error",
|
|
695
|
+
"skipped_as_follower",
|
|
696
|
+
"source_health",
|
|
697
|
+
"consecutive_fails",
|
|
698
|
+
"shodan_last_new",
|
|
699
|
+
]);
|
|
615
700
|
// The observed rankings and the daily series: API field, and the row field that names each row.
|
|
616
701
|
const SHADOW_AI_ROWS = [
|
|
617
702
|
["trend_30d", "date"],
|
|
@@ -1063,13 +1148,19 @@ function readShadowAI(sa) {
|
|
|
1063
1148
|
sentences.push(`Shadow AI authentication, over every observation on record: ${authParts.join("; ")}.`);
|
|
1064
1149
|
sentences.push("Neither authentication count is part of confirmed_exposed, and observed.total minus confirmed_exposed.total is not a count of secured services: it also holds observations not yet verified (liveness unverified), whose hostname no longer resolves (unreachable), or whose service no longer answers openly (resolved).");
|
|
1065
1150
|
}
|
|
1066
|
-
// What the answer carried that the tool cannot label: named, and left out.
|
|
1151
|
+
// What the answer carried that the tool cannot label: named, and left out. The poller block's
|
|
1152
|
+
// keys are checked like the stats keys: its known instance fields are left out by design, and
|
|
1153
|
+
// any other key is named.
|
|
1154
|
+
const poller = field(sa, "poller");
|
|
1067
1155
|
const unknown = [
|
|
1068
1156
|
...Object.keys(isPlainObject(sa) ? sa : {}).filter((k) => k !== "stats" && k !== "poller"),
|
|
1069
1157
|
...Object.keys(isPlainObject(stats) ? stats : {})
|
|
1070
1158
|
.filter((k) => !SHADOW_AI_STATS.has(k))
|
|
1071
1159
|
.map((k) => `stats.${k}`),
|
|
1072
1160
|
...rowExtras,
|
|
1161
|
+
...Object.keys(isPlainObject(poller) ? poller : {})
|
|
1162
|
+
.filter((k) => !SHADOW_AI_POLLER.has(k))
|
|
1163
|
+
.map((k) => `poller.${k}`),
|
|
1073
1164
|
];
|
|
1074
1165
|
const unusable = Object.entries(isPlainObject(stats) ? stats : {})
|
|
1075
1166
|
.filter(([k, v]) => SHADOW_AI_STATS.has(k) && !used.has(k) && v !== null)
|
|
@@ -1094,7 +1185,7 @@ function readShadowAI(sa) {
|
|
|
1094
1185
|
data.poller = { running, last_run_at: lastRun };
|
|
1095
1186
|
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.`);
|
|
1096
1187
|
}
|
|
1097
|
-
else if (
|
|
1188
|
+
else if (poller === undefined) {
|
|
1098
1189
|
sentences.push("Shadow-AI radar freshness is unknown: the answer carries no poller block.");
|
|
1099
1190
|
}
|
|
1100
1191
|
else {
|
|
@@ -1117,7 +1208,7 @@ const EXPOSURE_RADAR_DESCRIPTION = `Aggregate totals from EchelonGraph's interne
|
|
|
1117
1208
|
// and freshness carries each radar's last_run_at where the API serves one.
|
|
1118
1209
|
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.";
|
|
1119
1210
|
const RADAR_NAMES = RADARS.map(([name]) => name);
|
|
1120
|
-
const RADAR_STATE_NOTE = "state is not_assessed: every radar answered, but no stats answer says when the services or records it counts were observed, so no count here is presented as a dated measurement and measured_at is null. Each count is what that radar holds on record,
|
|
1211
|
+
const RADAR_STATE_NOTE = "state is not_assessed: every radar answered, but no stats answer says when the services or records it counts were observed, 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.";
|
|
1121
1212
|
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, and freshness.shadow_ai.last_run_at with freshness.shadow_ai.running), and null where it does not.";
|
|
1122
1213
|
async function exposureRadar() {
|
|
1123
1214
|
const tool = "exposure_radar";
|
|
@@ -1195,11 +1286,19 @@ const ErrorSchema = z.strictObject({
|
|
|
1195
1286
|
message: z.string().describe("The cause: the API's own message, or what went wrong."),
|
|
1196
1287
|
});
|
|
1197
1288
|
const NOTES = z.array(z.string()).describe("Caveats, one sentence each.");
|
|
1289
|
+
// The success states (#2465). 2.1.0 described not_assessed as no dated measurement whose numbers
|
|
1290
|
+
// were all denied the status of findings, on the same schema as a cve_exposure answer relaying
|
|
1291
|
+
// services on record for a CISA-KEV CVE, which is not_assessed because its count is undated
|
|
1292
|
+
// (#2439). A client that respects the schema was told that count is not a finding. The count is
|
|
1293
|
+
// what the radar holds on record: not a dated measurement, and not nothing. So the description
|
|
1294
|
+
// says both, and leaves what each count is to the notes and exposure_state, which say it per
|
|
1295
|
+
// answer; it makes no blanket claim about the numbers, in either direction.
|
|
1296
|
+
const SUCCESS_STATE = "measured: a measurement of what was asked; an exposure count is measured only with measured_at and method. not_assessed: the answer holds no dated measurement of what was asked, so no count in it is presented as one; it can still relay a count, as what the source holds on record, undated, and its notes (and exposure_state, where the result carries it) say what each count is.";
|
|
1198
1297
|
function envelopeSchema(o) {
|
|
1199
1298
|
const coverage = o.coverage ? o.coverage.nullable() : z.null();
|
|
1200
1299
|
return z.discriminatedUnion("state", [
|
|
1201
1300
|
z.strictObject({
|
|
1202
|
-
state: z.enum(["measured", "not_assessed"]).describe(
|
|
1301
|
+
state: z.enum(["measured", "not_assessed"]).describe(SUCCESS_STATE),
|
|
1203
1302
|
measured_at: Instant.nullable().describe("When the underlying observation was made, as the API states it; null when the answer does not say or holds no observation."),
|
|
1204
1303
|
method: z.string().describe("How the numbers were produced."),
|
|
1205
1304
|
coverage: coverage.describe("What the answer covers; null where the answer says nothing about it."),
|
|
@@ -1306,7 +1405,7 @@ const CVE_EXPOSURE_OUTPUT = envelopeSchema({
|
|
|
1306
1405
|
exposure_state: z
|
|
1307
1406
|
.enum(["exposed", "measured_zero", "not_assessed", "tracking_unknown"])
|
|
1308
1407
|
.nullable()
|
|
1309
|
-
.describe("What the answer's count is: exposed, a
|
|
1408
|
+
.describe("What the answer's count is, as the note's (exposure_state: …) tag says; not state, which says whether the answer is a dated measurement. exposed: data.exposed_hosts counts the services the radar holds on record for the CVE, above 0. measured_zero: the API marks the CVE as tracked with 0 services, a zero in the radar's sample. not_assessed: the CVE is outside the radar's tracked set, so its count, if any, is not a current measurement. tracking_unknown: the API does not say whether the radar tracks the CVE, so its 0 is not evidence either way. null: the answer carries no count."),
|
|
1310
1409
|
},
|
|
1311
1410
|
});
|
|
1312
1411
|
// exposure_radar's data, from the readers' own specs, so the schema cannot drift from them.
|
|
@@ -1383,9 +1482,12 @@ const EXPOSURE_RADAR_OUTPUT = envelopeSchema({
|
|
|
1383
1482
|
const INSTRUCTIONS = [
|
|
1384
1483
|
"EchelonGraph's public CVE and internet-exposure data, read-only and keyless.",
|
|
1385
1484
|
"Everything these tools return is public: EchelonGraph's CVE Pulse feed, and aggregate, host-redacted totals from its exposure radars.",
|
|
1386
|
-
"Every result
|
|
1485
|
+
"Every result says how it was measured: state, measured_at, method, coverage, freshness and notes, in its structuredContent and again in its text.",
|
|
1486
|
+
"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.",
|
|
1387
1487
|
"state is measured, not_assessed, failed or invalid_input.",
|
|
1388
|
-
"not_assessed means the answer holds no dated measurement of what was asked
|
|
1488
|
+
"not_assessed means the answer holds no dated measurement of what was asked, so no count in it is presented as one.",
|
|
1489
|
+
"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.",
|
|
1490
|
+
"A zero is not a finding of no exposure where the answer holds no measurement for that CVE, for example a CVE outside the KEV-exposure radar's tracked set (exposure_state not_assessed).",
|
|
1389
1491
|
"failed and invalid_input mean nothing was measured: never report them as zero, none found or unexposed.",
|
|
1390
1492
|
"measured_at is when the underlying observation was made, and null when the answer does not say or holds none.",
|
|
1391
1493
|
"freshness gives the producing radar's last_run_at: when it last completed a check whose reads succeeded.",
|
|
@@ -1397,7 +1499,7 @@ const INSTRUCTIONS = [
|
|
|
1397
1499
|
const ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
|
1398
1500
|
const CVE_ID_ARG = z.string().describe("a CVE ID, e.g. CVE-2023-44487");
|
|
1399
1501
|
// What each description says about its structured result, naming only fields its schema holds.
|
|
1400
|
-
const FEED_ENVELOPE = "Its structured result carries state (measured), measured_at, method, coverage, freshness (null: the feed serves no poll-completion time) and notes, with data equal to the API's JSON.";
|
|
1502
|
+
const FEED_ENVELOPE = "Its structured result carries state (measured), measured_at, method, coverage, freshness (null: the feed serves no poll-completion time) and notes, with data equal to the API's JSON; the result's last text block repeats it without data (the first text block) and without the note's sentences (the text block before it), with which notes ends.";
|
|
1401
1503
|
// One server, built per connection by serveStdio for whichever era the client opens with.
|
|
1402
1504
|
// tools/list answers in registration order, which is the order below.
|
|
1403
1505
|
function createServer() {
|
|
@@ -1430,14 +1532,14 @@ function createServer() {
|
|
|
1430
1532
|
}, async ({ cve_id }) => checked("get_cve", GET_CVE_OUTPUT, await getCVE(cve_id)));
|
|
1431
1533
|
server.registerTool("cve_exposure", {
|
|
1432
1534
|
title: "Internet exposure for one CVE",
|
|
1433
|
-
description: `${CVE_EXPOSURE_DESCRIPTION} Its structured result's state is
|
|
1535
|
+
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: …").`,
|
|
1434
1536
|
inputSchema: z.object({ cve_id: CVE_ID_ARG }),
|
|
1435
1537
|
outputSchema: CVE_EXPOSURE_OUTPUT,
|
|
1436
1538
|
annotations: ANNOTATIONS,
|
|
1437
1539
|
}, async ({ cve_id }) => checked("cve_exposure", CVE_EXPOSURE_OUTPUT, await cveExposure(cve_id)));
|
|
1438
1540
|
server.registerTool("exposure_radar", {
|
|
1439
1541
|
title: "Exposure radar totals",
|
|
1440
|
-
description: `${EXPOSURE_RADAR_DESCRIPTION} Its structured result's state is not_assessed with measured_at null: every radar answered, but no stats answer says when the services or records it counts were observed, so no count is presented as a dated measurement. freshness gives each radar's last_run_at (and shadow_ai's running) where the API serves one; coverage names the radars that answered; data is the relayed result above.`,
|
|
1542
|
+
description: `${EXPOSURE_RADAR_DESCRIPTION} Its structured result's state is not_assessed with measured_at null: every radar answered, but no stats answer says when the services or records it counts were observed, 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 shadow_ai's running) 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.`,
|
|
1441
1543
|
outputSchema: EXPOSURE_RADAR_OUTPUT,
|
|
1442
1544
|
annotations: ANNOTATIONS,
|
|
1443
1545
|
}, async () => checked("exposure_radar", EXPOSURE_RADAR_OUTPUT, await exposureRadar()));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "echelongraph-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"mcpName": "io.echelongraph/echelongraph-mcp",
|
|
5
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.",
|
|
6
6
|
"type": "module",
|
package/server.json
CHANGED
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
"url": "https://github.com/echelongraph/echelongraph-mcp",
|
|
9
9
|
"source": "github"
|
|
10
10
|
},
|
|
11
|
-
"version": "2.
|
|
11
|
+
"version": "2.2.0",
|
|
12
12
|
"packages": [
|
|
13
13
|
{
|
|
14
14
|
"registryType": "npm",
|
|
15
15
|
"identifier": "echelongraph-mcp",
|
|
16
|
-
"version": "2.
|
|
16
|
+
"version": "2.2.0",
|
|
17
17
|
"transport": {
|
|
18
18
|
"type": "stdio"
|
|
19
19
|
},
|