echelongraph-mcp 2.1.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 CHANGED
@@ -171,7 +171,7 @@ outage for an all-clear:
171
171
 
172
172
  - **Success** — the first text block is the API's JSON verbatim; the second is a one-line
173
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
174
+ the structured result as JSON, less what the first two already say (below). When the
175
175
  feed genuinely holds nothing for the query the note says so in words ("we looked and
176
176
  found nothing … not a lookup failure"), because a measured zero is a measurement. The
177
177
  exception to verbatim is `exposure_radar`: each radar is cut to the fields listed above, and
@@ -191,12 +191,13 @@ outage for an all-clear:
191
191
  the host could not be reached, it answered non-2xx, it took longer than the timeout, or
192
192
  it answered 2xx with a body that is not a JSON object. The text names the tool, the
193
193
  cause (status code or error kind), the path, and the base URL, and says it is not a
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.
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.
196
196
 
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.
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.
200
201
 
201
202
  ## Structured results
202
203
 
@@ -216,15 +217,21 @@ declares its shape as an `outputSchema` in `tools/list`, with a title and the an
216
217
  | `data` | On a success only: the same JSON as the first text block. |
217
218
  | `error` | On a failure only: `kind`, `path`, `status` and `message`. |
218
219
 
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.
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.
223
230
 
224
231
  | `state` | What it means |
225
232
  |---|---|
226
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`. |
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. |
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. |
228
235
  | `failed` | The lookup did not complete. Not a finding. |
229
236
  | `invalid_input` | The input was refused, by this server or by the API, so nothing was looked up. Not a finding. |
230
237
 
package/dist/index.js CHANGED
@@ -13,10 +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). content[2] is the envelope below, as JSON.
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
19
  // 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.
20
+ // in content[0]; content[1] is the envelope below, as JSON, less the message's sentences.
20
21
  // Never a success with null fields: a model handed one of those tells its user "no
21
22
  // exposure found", and an outage becomes an all-clear.
22
23
  //
@@ -26,13 +27,16 @@
26
27
  // with, on a success, `data` equal to what content[0] carries, and on a failure an `error`.
27
28
  // Each tool advertises the envelope as its outputSchema.
28
29
  //
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.
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.
36
40
  //
37
41
  // Protocol eras (#2311). serveStdio answers both: a 2026-07-28 client's server/discover and
38
42
  // per-request `_meta` envelope, and a 2025-era client's `initialize` handshake. The first
@@ -206,20 +210,39 @@ const NO_RECORD = "The API returned no record for this request; the quoted messa
206
210
  // It is tagged invalid_input, the same state a request this server refuses locally carries.
207
211
  const INVALID_INPUT = "(state: invalid_input)";
208
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.";
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;
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
+ }
215
237
  return text(JSON.stringify(envelope, null, 2));
216
238
  };
217
239
  // A failure result: the #1874 text, and an envelope saying nothing was measured, in
218
240
  // 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).
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.
220
243
  const failure = (state, message, error, coverage = null) => {
221
244
  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 };
245
+ return { content: [text(message), envelopeText(structuredContent, message, [])], structuredContent, isError: true };
223
246
  };
224
247
  const errorOf = (f) => ({ kind: f.kind, path: f.path, status: f.status ?? null, message: f.detail });
225
248
  const failed = (tool, f) => {
@@ -264,11 +287,12 @@ const checked = (tool, schema, r) => {
264
287
  const p = schema.safeParse(r.structuredContent);
265
288
  return p.success ? r : unexpectedShape(tool, p.error);
266
289
  };
267
- // A success: content[0] is the data, content[1] the note, content[2] the envelope without data;
290
+ // A success: content[0] is the data, content[1] the note, content[2] the envelope less both;
268
291
  // the envelope carries both, the note as sentences after any the envelope adds about itself.
269
292
  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 };
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 };
272
296
  };
273
297
  const okHead = (tool, status) => `${tool} OK: EchelonGraph answered${status === undefined ? "" : ` HTTP ${status}`} from ${SHOWN_BASE}.`;
274
298
  // Tolerant readers for the note: a missing or renamed field degrades the sentence, never
@@ -464,7 +488,7 @@ const SHODAN_ATTRIBUTION = "Exposure counts are derived from Shodan data.";
464
488
  // and copyright in the applicable Shodan materials" (#2306). The attribution above does the
465
489
  // first; this sentence, placed beside it wherever Shodan data is named, does the second.
466
490
  const SHODAN_OWNERSHIP = "Shodan data is owned by Shodan, which holds its copyright (© Shodan).";
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.`;
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.`;
468
492
  function exposureNote(head, id, d) {
469
493
  const cve = strAt(d, "cve_id") ?? id;
470
494
  const hosts = numAt(d, "exposed_hosts");
@@ -1184,7 +1208,7 @@ const EXPOSURE_RADAR_DESCRIPTION = `Aggregate totals from EchelonGraph's interne
1184
1208
  // and freshness carries each radar's last_run_at where the API serves one.
1185
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.";
1186
1210
  const RADAR_NAMES = RADARS.map(([name]) => name);
1187
- 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, labelled below by what it counts.";
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.";
1188
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.";
1189
1213
  async function exposureRadar() {
1190
1214
  const tool = "exposure_radar";
@@ -1262,11 +1286,19 @@ const ErrorSchema = z.strictObject({
1262
1286
  message: z.string().describe("The cause: the API's own message, or what went wrong."),
1263
1287
  });
1264
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.";
1265
1297
  function envelopeSchema(o) {
1266
1298
  const coverage = o.coverage ? o.coverage.nullable() : z.null();
1267
1299
  return z.discriminatedUnion("state", [
1268
1300
  z.strictObject({
1269
- state: z.enum(["measured", "not_assessed"]).describe("measured: a measurement of what was asked; an exposure count is measured only with measured_at and method. not_assessed: no dated measurement of what was asked, and no number in it is a finding."),
1301
+ state: z.enum(["measured", "not_assessed"]).describe(SUCCESS_STATE),
1270
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."),
1271
1303
  method: z.string().describe("How the numbers were produced."),
1272
1304
  coverage: coverage.describe("What the answer covers; null where the answer says nothing about it."),
@@ -1373,7 +1405,7 @@ const CVE_EXPOSURE_OUTPUT = envelopeSchema({
1373
1405
  exposure_state: z
1374
1406
  .enum(["exposed", "measured_zero", "not_assessed", "tracking_unknown"])
1375
1407
  .nullable()
1376
- .describe("What the answer's count is: exposed, a measured zero, not assessed, or tracking unknown; null when it carries no count."),
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."),
1377
1409
  },
1378
1410
  });
1379
1411
  // exposure_radar's data, from the readers' own specs, so the schema cannot drift from them.
@@ -1450,9 +1482,12 @@ const EXPOSURE_RADAR_OUTPUT = envelopeSchema({
1450
1482
  const INSTRUCTIONS = [
1451
1483
  "EchelonGraph's public CVE and internet-exposure data, read-only and keyless.",
1452
1484
  "Everything these tools return is public: EchelonGraph's CVE Pulse feed, and aggregate, host-redacted totals from its exposure radars.",
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).",
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.",
1454
1487
  "state is measured, not_assessed, failed or invalid_input.",
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.",
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).",
1456
1491
  "failed and invalid_input mean nothing was measured: never report them as zero, none found or unexposed.",
1457
1492
  "measured_at is when the underlying observation was made, and null when the answer does not say or holds none.",
1458
1493
  "freshness gives the producing radar's last_run_at: when it last completed a check whose reads succeeded.",
@@ -1464,7 +1499,7 @@ const INSTRUCTIONS = [
1464
1499
  const ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
1465
1500
  const CVE_ID_ARG = z.string().describe("a CVE ID, e.g. CVE-2023-44487");
1466
1501
  // What each description says about its structured result, naming only fields its schema holds.
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.";
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.";
1468
1503
  // One server, built per connection by serveStdio for whichever era the client opens with.
1469
1504
  // tools/list answers in registration order, which is the order below.
1470
1505
  function createServer() {
@@ -1497,14 +1532,14 @@ function createServer() {
1497
1532
  }, async ({ cve_id }) => checked("get_cve", GET_CVE_OUTPUT, await getCVE(cve_id)));
1498
1533
  server.registerTool("cve_exposure", {
1499
1534
  title: "Internet exposure for one CVE",
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.`,
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: …").`,
1501
1536
  inputSchema: z.object({ cve_id: CVE_ID_ARG }),
1502
1537
  outputSchema: CVE_EXPOSURE_OUTPUT,
1503
1538
  annotations: ANNOTATIONS,
1504
1539
  }, async ({ cve_id }) => checked("cve_exposure", CVE_EXPOSURE_OUTPUT, await cveExposure(cve_id)));
1505
1540
  server.registerTool("exposure_radar", {
1506
1541
  title: "Exposure radar totals",
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.`,
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.`,
1508
1543
  outputSchema: EXPOSURE_RADAR_OUTPUT,
1509
1544
  annotations: ANNOTATIONS,
1510
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.1.0",
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.1.0",
11
+ "version": "2.2.0",
12
12
  "packages": [
13
13
  {
14
14
  "registryType": "npm",
15
15
  "identifier": "echelongraph-mcp",
16
- "version": "2.1.0",
16
+ "version": "2.2.0",
17
17
  "transport": {
18
18
  "type": "stdio"
19
19
  },