echelongraph-mcp 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 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.
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: false` | **NOT ASSESSED**: outside the radar's tracked set; 0 is not a measurement. |
52
- | `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. |
53
- | no `tracked` field | The API did not say whether the CVE is in the tracked set: an older API, or the radar cannot decide (for example a CISA-KEV or high-EPSS CVE in a tracked product with 0 services on record). 0 exposed services on record, with no claim either way. |
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. When the
173
+ note saying the call succeeded, which base URL answered, and what it found; the third is
174
+ the structured result without `data`, as JSON (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,12 @@ 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. A failure is never rendered as a success with null fields.
194
+ finding; the second text block is the structured result, as JSON (below). A failure is never
195
+ rendered as a success with null fields.
183
196
 
184
- Both shapes also carry a structured result, below. The text blocks are the same as in 1.x, so
185
- a client that reads only `content` sees what it always saw.
197
+ Both shapes also carry a structured result, below, and repeat it in their last text block, so a
198
+ client that passes only `content` to the model still sees how the answer was measured. The
199
+ first text block (the API's JSON, or the failure) and the note after it are where 1.x put them.
186
200
 
187
201
  ## Structured results
188
202
 
@@ -202,9 +216,14 @@ declares its shape as an `outputSchema` in `tools/list`, with a title and the an
202
216
  | `data` | On a success only: the same JSON as the first text block. |
203
217
  | `error` | On a failure only: `kind`, `path`, `status` and `message`. |
204
218
 
219
+ The last text block of every result is this structured result serialized as JSON, without
220
+ `data`: the same `state`, `measured_at`, `method`, `coverage`, `freshness` and `notes` (and on
221
+ a failure `error`), key for key, since a client may pass only `content` to the model. With
222
+ the first text block as `data`, the text carries the whole structured result.
223
+
205
224
  | `state` | What it means |
206
225
  |---|---|
207
- | `measured` | A measurement of what was asked. An exposure count is `measured` only when the answer dates it (`measured_at`) and says how it was produced (`method`). |
226
+ | `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`. |
208
227
  | `not_assessed` | The answer holds no dated measurement of what was asked, so no number in it is a finding: the radar does not look for this CVE, the answer does not say whether it does, or the answer does not say when what it counts was observed. |
209
228
  | `failed` | The lookup did not complete. Not a finding. |
210
229
  | `invalid_input` | The input was refused, by this server or by the API, so nothing was looked up. Not a finding. |
@@ -220,11 +239,14 @@ a note says so. Per tool:
220
239
  `total_counted` is false the total is not a count, and the note does not call it one.
221
240
  - `get_cve` is `measured`, and its `measured_at` is the record's `updated_at`, when EchelonGraph
222
241
  last wrote it.
223
- - `cve_exposure` is `measured` only for services on record with a real `last_seen`, which is its
224
- `measured_at`. Every other answer is `not_assessed`, a tracked zero included, since a zero has
225
- no observation to date it. `exposure_state` is `exposed`, `measured_zero`, `not_assessed` or
226
- `tracking_unknown`, the cases of "How `cve_exposure` counts", and `coverage.in_scope` is the
227
- API's `tracked`.
242
+ - `cve_exposure` is `not_assessed` with `measured_at` `null`, every answer: the per-CVE answer
243
+ carries no time at which the services it counts were observed. Its `last_seen` is when
244
+ EchelonGraph last wrote or refreshed one of their rows, not when any of them was observed,
245
+ so it is never `measured_at`, and a note says so. A tracked zero is `not_assessed` too, since
246
+ a zero has no observation to date it. The count is still relayed, labelled: `exposure_state`
247
+ is `exposed`, `measured_zero`, `not_assessed` or `tracking_unknown`, the cases of "How
248
+ `cve_exposure` counts", and `coverage.in_scope` is the API's `tracked`. It becomes `measured`
249
+ only when the API serves a time at which the counted services were observed.
228
250
  - `exposure_radar` is `not_assessed` with `measured_at` `null`: every radar answered, but no
229
251
  stats answer says when the services or records it counts were observed, so no count is
230
252
  presented as a dated measurement. Its numbers keep the labels above. `freshness` holds each
@@ -255,8 +277,9 @@ carry the package's name and version (`serverInfo`), and every API request carri
255
277
  User-Agent, `echelongraph-mcp/<version>`.
256
278
 
257
279
  Both handshakes also carry the server instructions: the data is public; what `state`,
258
- `measured_at` and `freshness` mean; that exposure numbers are aggregate counts of ip:port
259
- services, not an internet-wide census; and that Shodan data is Shodan's.
280
+ `measured_at` and `freshness` mean, and that the last text block repeats them; that exposure
281
+ numbers are aggregate counts of ip:port services, not an internet-wide census; and that Shodan
282
+ data is Shodan's.
260
283
 
261
284
  ## Install
262
285
 
package/dist/index.js CHANGED
@@ -13,9 +13,10 @@
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.
17
17
  // - 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.
18
+ // timeout, or a 2xx whose body is not a JSON object — named by tool, cause and base URL,
19
+ // in content[0]; content[1] is the envelope below, as JSON.
19
20
  // Never a success with null fields: a model handed one of those tells its user "no
20
21
  // exposure found", and an outage becomes an all-clear.
21
22
  //
@@ -23,8 +24,15 @@
23
24
  // structuredContent: one envelope that says how the answer was measured — state (measured,
24
25
  // not_assessed, failed, invalid_input), measured_at, method, coverage, freshness and notes —
25
26
  // 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. The text blocks above are unchanged
27
- // from 1.x, so a client that reads only `content` sees what it always saw.
27
+ // Each tool advertises the envelope as its outputSchema.
28
+ //
29
+ // The envelope in the text (#2440). Many clients pass only `content` to the model, so the last
30
+ // text block of every result is structuredContent serialized as JSON, without `data` (which
31
+ // content[0] already carries verbatim): the same state, measured_at, method, coverage,
32
+ // freshness, notes and, on a failure, error, key for key. content[0] + that block is the whole
33
+ // of structuredContent. The MCP spec (2025-06-18, Tools, Structured Content) asks a tool that
34
+ // returns structured content to return it serialized in a text block too; the JSON block stays
35
+ // first and the note second, as in 1.x, so a client that parses content[0] is unaffected.
28
36
  //
29
37
  // Protocol eras (#2311). serveStdio answers both: a 2026-07-28 client's server/discover and
30
38
  // per-request `_meta` envelope, and a 2025-era client's `initialize` handshake. The first
@@ -198,13 +206,21 @@ const NO_RECORD = "The API returned no record for this request; the quoted messa
198
206
  // It is tagged invalid_input, the same state a request this server refuses locally carries.
199
207
  const INVALID_INPUT = "(state: invalid_input)";
200
208
  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
- // A failure result: the #1874 text, and an envelope saying nothing was measured. The error
202
- // quotes only what describeFailure already quotes (a redacted cause, the API's own message).
203
- const failure = (state, message, error, coverage = null) => ({
204
- content: [text(message)],
205
- structuredContent: { state, measured_at: null, method: null, coverage, freshness: null, notes: sentences(message), error },
206
- isError: true,
207
- });
209
+ // The envelope as a text block (#2440): structuredContent without `data`, serialized as JSON,
210
+ // so a client that passes only `content` to the model still sees how the answer was measured.
211
+ // `data` is left out because content[0] carries it verbatim; every other key is copied as is,
212
+ // so the text and the structured result cannot disagree.
213
+ const envelopeText = (structured) => {
214
+ const { data: _data, ...envelope } = structured;
215
+ return text(JSON.stringify(envelope, null, 2));
216
+ };
217
+ // A failure result: the #1874 text, and an envelope saying nothing was measured, in
218
+ // structuredContent and again as the last text block. The error quotes only what
219
+ // describeFailure already quotes (a redacted cause, the API's own message).
220
+ const failure = (state, message, error, coverage = null) => {
221
+ const structuredContent = { state, measured_at: null, method: null, coverage, freshness: null, notes: sentences(message), error };
222
+ return { content: [text(message), envelopeText(structuredContent)], structuredContent, isError: true };
223
+ };
208
224
  const errorOf = (f) => ({ kind: f.kind, path: f.path, status: f.status ?? null, message: f.detail });
209
225
  const failed = (tool, f) => {
210
226
  if (f.kind === "http" && f.status === 400) {
@@ -248,12 +264,12 @@ const checked = (tool, schema, r) => {
248
264
  const p = schema.safeParse(r.structuredContent);
249
265
  return p.success ? r : unexpectedShape(tool, p.error);
250
266
  };
251
- // A success: content[0] is the data, content[1] the note; the envelope carries both, the note
252
- // as sentences after any the envelope adds about itself.
253
- const succeeded = (data, note, env) => ({
254
- content: [text(JSON.stringify(data, null, 2)), text(note)],
255
- structuredContent: { ...env, notes: [...(env.notes ?? []), ...sentences(note)], data },
256
- });
267
+ // A success: content[0] is the data, content[1] the note, content[2] the envelope without data;
268
+ // the envelope carries both, the note as sentences after any the envelope adds about itself.
269
+ const succeeded = (data, note, env) => {
270
+ const structuredContent = { ...env, notes: [...(env.notes ?? []), ...sentences(note)], data };
271
+ return { content: [text(JSON.stringify(data, null, 2)), text(note), envelopeText(structuredContent)], structuredContent };
272
+ };
257
273
  const okHead = (tool, status) => `${tool} OK: EchelonGraph answered${status === undefined ? "" : ` HTTP ${status}`} from ${SHOWN_BASE}.`;
258
274
  // Tolerant readers for the note: a missing or renamed field degrades the sentence, never
259
275
  // the result — content[0] still carries whatever the API sent.
@@ -428,10 +444,16 @@ async function getCVE(cve_id) {
428
444
  // The API says which with `tracked`; the note reads it and, when the field is absent (an older
429
445
  // API, or a CVE the radar cannot decide on), makes no claim either way about the zero.
430
446
  //
431
- // last_seen is not a re-sighting of the vulnerable banner: between searches the radar refreshes
432
- // it when Shodan InternetDB still lists the service's PORT, without re-reading the version, so
433
- // a patched service can stay counted while its port stays open. Every text says "last seen
434
- // listening on its port", never "re-seen" or "most recently seen".
447
+ // last_seen is not an observation time (#2439). It is when EchelonGraph last wrote or refreshed
448
+ // a service's row: core-backend kevexposure store.go Upsert sets it to now() when a search
449
+ // matches the banner, and Touch sets it to now() when the InternetDB re-check (poller.go
450
+ // reconcileStale) finds the service's PORT still listed, without re-reading the version. So a
451
+ // patched service can stay counted while its port stays open, and last_seen is our write time,
452
+ // not the time Shodan saw the service: on 2026-09-27 production's 50 newest rows carried
453
+ // last_seen within 0.78 s of one another, a batch write. Shodan's own banner timestamp is not
454
+ // stored.
455
+ // Every text calls last_seen a write or refresh time, never a sighting ("last seen listening",
456
+ // "re-seen", "most recently seen"), and it is never measured_at (see observedAt).
435
457
  const CVE_ID = /^CVE-\d{4}-\d{4,}$/i;
436
458
  // Quoted when the API does not send its own `method` string.
437
459
  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 +464,7 @@ const SHODAN_ATTRIBUTION = "Exposure counts are derived from Shodan data.";
442
464
  // and copyright in the applicable Shodan materials" (#2306). The attribution above does the
443
465
  // first; this sentence, placed beside it wherever Shodan data is named, does the second.
444
466
  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 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.`;
467
+ 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 result says NOT ASSESSED, and its 0 is not a measurement.`;
446
468
  function exposureNote(head, id, d) {
447
469
  const cve = strAt(d, "cve_id") ?? id;
448
470
  const hosts = numAt(d, "exposed_hosts");
@@ -468,22 +490,24 @@ function exposureNote(head, id, d) {
468
490
  const why = hosts === 0
469
491
  ? "outside the radar's tracked set; 0 is not a measurement, and it says nothing about whether any host is exposed to it."
470
492
  : `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} (state: not_assessed) NOT ASSESSED: ${cve} is ${why} ${kev} ${how}`;
493
+ return `${head} (exposure_state: not_assessed) NOT ASSESSED: ${cve} is ${why} ${kev} ${how}`;
472
494
  }
495
+ // The tag in parentheses is exposure_state, named as such (#2440): it says what the count is,
496
+ // and is not the envelope's state, which the last text block gives.
473
497
  if (hosts === 0 && tracked === true) {
474
498
  // The backend answers tracked:true only when it holds rows (exposed_hosts > 0), so this
475
499
  // branch does not fire today; it is worded for the day it does.
476
- 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}`;
500
+ 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
501
  }
478
502
  if (hosts === 0) {
479
- 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}`;
503
+ 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
504
  }
481
505
  const countries = numAt(d, "countries");
482
506
  const last = strAt(d, "last_seen");
483
507
  const seen = realInstant(last)
484
- ? ` 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.`
508
+ ? ` 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
509
  : "";
486
- 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}`;
510
+ 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
511
  }
488
512
  function cveExposureState(d) {
489
513
  const hosts = numAt(d, "exposed_hosts");
@@ -496,20 +520,36 @@ function cveExposureState(d) {
496
520
  return tracked === true ? "measured_zero" : "tracking_unknown";
497
521
  return "exposed";
498
522
  }
499
- // The envelope of a cve_exposure answer. Only a count of services on record that the API dates
500
- // (last_seen, when one of them was last seen listening on its port) is measured; every other
501
- // answer is not_assessed, and its notes say why. A tracked zero is not_assessed too: it has no
502
- // observation to date it, and the per-CVE answer does not carry the time of the radar's last
503
- // completed check (exposure_radar relays that as kev_exposure.last_run_at).
523
+ // The seam for an observation time (#2439). A cve_exposure count is measured only when the
524
+ // answer says when the services it counts were observed, and this is the one place that reads
525
+ // such a time. The per-CVE answer serves none today: its fields are cve_id, exposed_hosts,
526
+ // countries, tracked, kev_listed, kev_catalog_listed, kev_seen_in_observations, ransomware,
527
+ // top_countries, top_products, method, last_seen and generated_at (production, 2026-09-27), and
528
+ // last_seen is EchelonGraph's write or refresh time (see the comment above CVE_ID), while
529
+ // generated_at is when the API built the answer. So this returns null and every cve_exposure
530
+ // answer is not_assessed. When the API serves an observation time (#2438), read that field here
531
+ // and nowhere else: measured and measured_at follow from this function alone, never from
532
+ // last_seen.
533
+ function observedAt(_d) {
534
+ return null;
535
+ }
536
+ // The envelope of a cve_exposure answer. Only a count of services on record that the answer
537
+ // dates by when they were observed (observedAt) is measured; every other answer is
538
+ // not_assessed, and its notes say why. Today that is every answer: the count is relayed,
539
+ // labelled by exposure_state and the note, as what the radar holds on record. A tracked zero
540
+ // is not_assessed too: it has no observation to date it, and the per-CVE answer does not carry
541
+ // the time of the radar's last completed check (exposure_radar relays that as
542
+ // kev_exposure.last_run_at).
504
543
  function cveExposureEnvelope(cve, d) {
505
544
  const exposureState = cveExposureState(d);
545
+ const observed = observedAt(d);
546
+ const measured = exposureState === "exposed" && realInstant(observed ?? undefined);
506
547
  const last = strAt(d, "last_seen");
507
- const measured = exposureState === "exposed" && realInstant(last);
508
548
  const tracked = boolAt(d, "tracked");
509
549
  const why = {
510
550
  exposed: measured
511
- ? `state is measured: exposed_hosts counts the services the radar has on record for ${cve}, and measured_at is their latest last_seen, when one of them was last seen listening on its port (not when its vulnerable version was last confirmed).`
512
- : `state is not_assessed: the answer gives no real last_seen for the services it counts for ${cve}, so that count is not presented as a dated measurement.`,
551
+ ? `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.`
552
+ : `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
553
  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
554
  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
555
  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 +557,18 @@ function cveExposureEnvelope(cve, d) {
517
557
  };
518
558
  return {
519
559
  state: measured ? "measured" : "not_assessed",
520
- measured_at: measured ? last : null,
560
+ measured_at: measured ? observed : null,
521
561
  method: (strAt(d, "method") ?? EXPOSURE_METHOD).replace(/\s+$/, ""),
522
562
  coverage: { in_scope: tracked ?? null },
523
563
  freshness: null,
524
564
  exposure_state: exposureState,
525
565
  notes: [
526
566
  why[exposureState ?? "none"],
567
+ ...(realInstant(last)
568
+ ? [
569
+ `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.`,
570
+ ]
571
+ : []),
527
572
  tracked === undefined
528
573
  ? "coverage.in_scope is null: the answer does not say whether the CVE is in the radar's tracked set."
529
574
  : `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 +624,8 @@ const RADARS = [
579
624
  // under the API's names, every one of those reads as an exposure count. So the tool relays them
580
625
  // grouped as confirmed_exposed, observed and authentication, with the note labelling each, and
581
626
  // 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, the
583
- // first named in the note.
627
+ // a ranked row's label and count, and the poller block's instance fields are left out, and a
628
+ // field of any of them this version does not know is named in the note.
584
629
  //
585
630
  // The `poller` block's running and last_run_at describe the radar's fleet, not the instance
586
631
  // that answered (core-backend shadowctlog Poller.Status): last_run_at is when the fleet's
@@ -612,6 +657,22 @@ const SHADOW_AI_STATS = new Set([
612
657
  "top_issuers",
613
658
  "trend_30d",
614
659
  ]);
660
+ // The poller block's fields this version knows (core-backend shadowctlog poller.go Status):
661
+ // running and last_run_at, relayed when both are real; the rest describe the instance that
662
+ // answered and are left out by design (see above). Any other key is left out and named, as a
663
+ // stats field is (#2313 item 7, #2440): an injected poller.exposed_now must not vanish unnamed.
664
+ const SHADOW_AI_POLLER = new Set([
665
+ "running",
666
+ "last_run_at",
667
+ "interval_seconds",
668
+ "shodan_enabled",
669
+ "last_new_inserts",
670
+ "last_error",
671
+ "skipped_as_follower",
672
+ "source_health",
673
+ "consecutive_fails",
674
+ "shodan_last_new",
675
+ ]);
615
676
  // The observed rankings and the daily series: API field, and the row field that names each row.
616
677
  const SHADOW_AI_ROWS = [
617
678
  ["trend_30d", "date"],
@@ -1063,13 +1124,19 @@ function readShadowAI(sa) {
1063
1124
  sentences.push(`Shadow AI authentication, over every observation on record: ${authParts.join("; ")}.`);
1064
1125
  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
1126
  }
1066
- // What the answer carried that the tool cannot label: named, and left out.
1127
+ // What the answer carried that the tool cannot label: named, and left out. The poller block's
1128
+ // keys are checked like the stats keys: its known instance fields are left out by design, and
1129
+ // any other key is named.
1130
+ const poller = field(sa, "poller");
1067
1131
  const unknown = [
1068
1132
  ...Object.keys(isPlainObject(sa) ? sa : {}).filter((k) => k !== "stats" && k !== "poller"),
1069
1133
  ...Object.keys(isPlainObject(stats) ? stats : {})
1070
1134
  .filter((k) => !SHADOW_AI_STATS.has(k))
1071
1135
  .map((k) => `stats.${k}`),
1072
1136
  ...rowExtras,
1137
+ ...Object.keys(isPlainObject(poller) ? poller : {})
1138
+ .filter((k) => !SHADOW_AI_POLLER.has(k))
1139
+ .map((k) => `poller.${k}`),
1073
1140
  ];
1074
1141
  const unusable = Object.entries(isPlainObject(stats) ? stats : {})
1075
1142
  .filter(([k, v]) => SHADOW_AI_STATS.has(k) && !used.has(k) && v !== null)
@@ -1094,7 +1161,7 @@ function readShadowAI(sa) {
1094
1161
  data.poller = { running, last_run_at: lastRun };
1095
1162
  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
1163
  }
1097
- else if (field(sa, "poller") === undefined) {
1164
+ else if (poller === undefined) {
1098
1165
  sentences.push("Shadow-AI radar freshness is unknown: the answer carries no poller block.");
1099
1166
  }
1100
1167
  else {
@@ -1383,7 +1450,7 @@ const EXPOSURE_RADAR_OUTPUT = envelopeSchema({
1383
1450
  const INSTRUCTIONS = [
1384
1451
  "EchelonGraph's public CVE and internet-exposure data, read-only and keyless.",
1385
1452
  "Everything these tools return is public: EchelonGraph's CVE Pulse feed, and aggregate, host-redacted totals from its exposure radars.",
1386
- "Every result's structuredContent says how it was measured: state, measured_at, method, coverage, freshness and notes.",
1453
+ "Every result says how it was measured: state, measured_at, method, coverage, freshness and notes, in its structuredContent and again in its last text block, which is that structuredContent as JSON without data (the first text block is the data).",
1387
1454
  "state is measured, not_assessed, failed or invalid_input.",
1388
1455
  "not_assessed means the answer holds no dated measurement of what was asked (for example a CVE outside the KEV-exposure radar's tracked set), so a zero in it is not a finding of no exposure.",
1389
1456
  "failed and invalid_input mean nothing was measured: never report them as zero, none found or unexposed.",
@@ -1397,7 +1464,7 @@ const INSTRUCTIONS = [
1397
1464
  const ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
1398
1465
  const CVE_ID_ARG = z.string().describe("a CVE ID, e.g. CVE-2023-44487");
1399
1466
  // 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.";
1467
+ 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.";
1401
1468
  // One server, built per connection by serveStdio for whichever era the client opens with.
1402
1469
  // tools/list answers in registration order, which is the order below.
1403
1470
  function createServer() {
@@ -1430,14 +1497,14 @@ function createServer() {
1430
1497
  }, async ({ cve_id }) => checked("get_cve", GET_CVE_OUTPUT, await getCVE(cve_id)));
1431
1498
  server.registerTool("cve_exposure", {
1432
1499
  title: "Internet exposure for one CVE",
1433
- description: `${CVE_EXPOSURE_DESCRIPTION} Its structured result's state is measured only for a count of services on record that last_seen dates (measured_at); a tracked zero, a CVE outside the tracked set, or an answer that does not say, is not_assessed. exposure_state 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.`,
1500
+ 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. 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.`,
1434
1501
  inputSchema: z.object({ cve_id: CVE_ID_ARG }),
1435
1502
  outputSchema: CVE_EXPOSURE_OUTPUT,
1436
1503
  annotations: ANNOTATIONS,
1437
1504
  }, async ({ cve_id }) => checked("cve_exposure", CVE_EXPOSURE_OUTPUT, await cveExposure(cve_id)));
1438
1505
  server.registerTool("exposure_radar", {
1439
1506
  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.`,
1507
+ 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. The result's last text block repeats the structured result without data.`,
1441
1508
  outputSchema: EXPOSURE_RADAR_OUTPUT,
1442
1509
  annotations: ANNOTATIONS,
1443
1510
  }, 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.0.0",
3
+ "version": "2.1.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.0.0",
11
+ "version": "2.1.0",
12
12
  "packages": [
13
13
  {
14
14
  "registryType": "npm",
15
15
  "identifier": "echelongraph-mcp",
16
- "version": "2.0.0",
16
+ "version": "2.1.0",
17
17
  "transport": {
18
18
  "type": "stdio"
19
19
  },