echelongraph-mcp 2.7.0 → 2.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,40 @@ with a provenance attestation by the release workflow of
6
6
  [github.com/echelongraph/echelongraph-mcp](https://github.com/echelongraph/echelongraph-mcp),
7
7
  from the commit tagged `v<version>`.
8
8
 
9
+ ## 2.7.2 — 2026-10-11
10
+
11
+ - exposure_radar flags every radar whose last completed check is past its threshold, not only the
12
+ two whose answer carries a liveness verdict. kev_exposure and exposed_databases (12-hour cycles)
13
+ are flagged after 24 hours, leaked_credentials (a 10-minute cycle) after 1 hour; shadow_ai and
14
+ mcp_servers keep the API's own poller.running and enabled verdicts, and fall back to 30 and 45
15
+ minutes only where the answer gives none. A flagged radar's sentence gives the age and the cycle
16
+ and says its numbers may be stale. The age is taken against the clock when the answer is
17
+ composed, never the stats cache's generated_at. The closing sentence "Each radar refreshes on its
18
+ own schedule." is gone: it said the radars were refreshing while four of the five were stopped.
19
+ It now lists the radars that may be stale, or names each threshold when none is. A test reads
20
+ each radar's cadence from core-backend and fails when a threshold is under twice it (#3010).
21
+ - README: the freshness sentence says a radar refreshes only while it runs (#3010). The setup steps
22
+ say to quit the client fully and reopen it, and no longer give a tool count to look for: `npx`
23
+ runs the newest release and a connector the hosted endpoint's, so a number could differ from a
24
+ working install's and read as a failure (#2813).
25
+ - cve_intel: each exploits row carries `source_published_at`, the source's own date for the
26
+ reference (null where the source gives none), and `eg_first_seen_at`, when EchelonGraph recorded
27
+ it. `first_seen_at` is kept, deprecated, as `source_published_at` under its old key (#2876).
28
+
29
+ ## 2.7.1 — 2026-10-06
30
+
31
+ - README: the server serves five prompts, not four; `workload_triage` (added in 2.7.0) is now in
32
+ the intro and in the Prompts table, and a test holds the README's prompt table and every
33
+ "N prompts" count equal to `prompts/list` (#2922).
34
+ - Every tool description now keeps at least 100 characters under the 2,048-character cut some
35
+ clients apply. check_affected, check_sbom, vendor_advisories_for_cve, get_cve, scan_manifest,
36
+ kev_recent, cve_intel and cve_summary were reworded for length alone: no field, verdict or
37
+ not-assessed statement was removed. A test holds the headroom (#2924).
38
+ - Hosted endpoint: `mcp_handler_error` log lines now carry `error_class`, a label from a fixed list
39
+ matched against the SDK's own error messages, and, for a protocol error, its numeric
40
+ `error_code`, so each can be told apart. The message itself is still never logged, as it can
41
+ quote what a client sent; /privacy Section 11 says so (#2923).
42
+
9
43
  ## 2.7.0 — 2026-10-06
10
44
 
11
45
  - New tool scan_manifest (#2835): a project's lockfiles or pinned manifests checked against the
package/README.md CHANGED
@@ -16,8 +16,8 @@ package version is affected, check an SBOM or a project's lockfiles, and read a
16
16
  it was measured (`state`, `measured_at`, `method`, `coverage`, `freshness`, `notes`), so a model
17
17
  cannot mistake an outage or an unassessed lookup for an all-clear.
18
18
 
19
- It also serves four prompts, ready-made workflows a client can show as slash commands
20
- (`triage_cve`, `kev_weekly_brief`, `am_i_affected`, `sbom_review`; see "Prompts"), and three
19
+ It also serves five prompts, ready-made workflows a client can show as slash commands
20
+ (`triage_cve`, `kev_weekly_brief`, `am_i_affected`, `sbom_review`, `workload_triage`; see "Prompts"), and three
21
21
  resources (`echelongraph://methodology`, `echelongraph://sources` and `cve://{cve_id}`; see
22
22
  "Resources").
23
23
 
@@ -34,9 +34,9 @@ API call a tool needs to answer.
34
34
  ## Quick start
35
35
 
36
36
  The local server runs through `npx` and needs Node.js 20 or later; nothing is installed
37
- globally. Claude Desktop's one-click extension, below, needs neither. Add one of the blocks below to your client, restart it, then ask: *"Is
38
- CVE-2023-44487 actively exploited, and how many exposed services does EchelonGraph's radar
39
- have on record for it?"*
37
+ globally. Claude Desktop's one-click extension, below, needs neither. Add one of the blocks below
38
+ to your client, quit it fully and reopen it, then ask: *"Is CVE-2023-44487 actively exploited, and
39
+ how many exposed services does EchelonGraph's radar have on record for it?"*
40
40
 
41
41
  ### Claude Desktop
42
42
 
@@ -97,8 +97,8 @@ are claude.ai's (see "Remote (no install)" below).
97
97
  Claude → Quit Claude (⌘Q); on Windows right-click the Claude icon in the system tray and choose
98
98
  Quit. The first start can take longer while `npx` downloads the package.
99
99
  5. **Check that it worked.** Settings → Developer lists `echelongraph` as running, and the tools
100
- menu in the chat box lists EchelonGraph's 16 tools. Then ask the question at the top of this
101
- section.
100
+ menu in the chat box lists EchelonGraph's tools, the ones under "Tools" below. Then ask the
101
+ question at the top of this section.
102
102
 
103
103
  #### Troubleshooting Claude Desktop
104
104
 
@@ -181,7 +181,7 @@ and most others) take the same block as Cursor above.
181
181
 
182
182
  ### Remote (no install)
183
183
 
184
- The same 16 tools, four prompts and three resources are served over Streamable HTTP at:
184
+ The same 16 tools, five prompts and three resources are served over Streamable HTTP at:
185
185
 
186
186
  ```text
187
187
  https://mcp.echelongraph.io/mcp
@@ -263,8 +263,10 @@ answer of which EchelonGraph holds none, whose `held_since` is after 1 January o
263
263
  or not known, or whose history is still being read: no advisory from such a vendor is not a finding
264
264
  that it published none.
265
265
 
266
- Every figure is as fresh as the schedule that refreshes it. The CVE feed is polled from its
267
- sources on a schedule; each radar refreshes on its own.
266
+ Every figure is as fresh as the last check that produced it. The CVE feed is polled from its
267
+ sources on a schedule. Each radar has its own schedule and refreshes only while it runs, and
268
+ `exposure_radar`'s `freshness` gives each radar's last completed check (`last_run_at`) where the
269
+ API serves one.
268
270
 
269
271
  ### How `cve_summary` reads its counts
270
272
 
@@ -592,6 +594,7 @@ never "not affected", and each figure cites its `measured_at`. A prompt makes no
592
594
  | `kev_weekly_brief` | `days` (1 to 365, default 7) | Work out the start date from `days`, page through `kev_recent` from it 50 rows a page, and write a brief grouped by vendor with ransomware flags and due dates. |
593
595
  | `am_i_affected` | `product`, or `ecosystem` and `package`; `version` | `check_affected`, read `assessed` before `count`, with the not-assessed wording. |
594
596
  | `sbom_review` | `sbom` (CycloneDX or SPDX JSON text, up to 5,000,000 characters) | Pass the document to `check_sbom` (and, once more, the purls a rate limit or the time budget left unsent: `data.not_sent_purls`, or, where the text cuts that list, the document's purls from the position and in the order its note gives), read each component's ecosystem, package and version from its purl, as `check_sbom` maps them (every purl type it maps, and `deb`, `apk`, `alpine` and `rpm` by a `distro` qualifier naming a Debian, Ubuntu or Alpine release; any other is unknown, and given no fixed version), look up each affected CVE with `get_cve` and `cve_intel`, and write a fix list ordered by CISA-KEV listing, then EPSS, then score, each key stated. A fixed version is given where it is above the component's installed version: the advisory interval in `check_sbom`'s `match_reason` first (B of `[A, B)`, a fixed bound; the B of `[A, B]`, read by its closing bracket alone, is the last affected version, still affected, and neither it nor any version at or below it is given; an interval clipped before its closing bracket gives none), else the `fixed` of the `cve_intel` `fixed_branches` range that holds the installed version, else, for a package without `fixed_branches`, `cve_intel`'s `fixed_version`, which is one per package (the advisory's last range's) and need not be the fix on the component's branch; otherwise the line says the results give no fixed version for that branch and points to the CVE's advisory. |
597
+ | `workload_triage` | `source` (optional: `kubernetes`, `github_actions` or `images`; default: all three are described) | Rank your own pods, CI job containers or images: list the workloads and decide their exposure with your own tools, make an SBOM of each image digest, and pass `check_sbom` the purls alone (no workload, namespace, image, registry name, credential or exposure label is sent; the image's own `pkg:oci`/`pkg:docker` purl is left out). Rows are ordered by CISA-KEV listing, then caller-asserted exposure (unknown is never sorted with not exposed), then EPSS, then score, each key stated; past 30 CVEs it stops calling `get_cve`. `not_assessed` rows are counted and never reported as clean. The prompt takes no workload data. |
595
598
 
596
599
  ## Resources
597
600
 
package/dist/http.js CHANGED
@@ -37,7 +37,7 @@ import http2 from "node:http2";
37
37
  import v8 from "node:v8";
38
38
  import { createMcpHandler } from "@modelcontextprotocol/server";
39
39
  import { forwardToken, markHttpEntrypoint, requestSettled, SYNTHETIC_UA_FAMILY, withClient } from "./runtime.js";
40
- import { Admission, BODY_TOO_LARGE_CODE, BUSY_CODE, DEFAULT_ADMISSION_WAIT_MS, DEFAULT_LARGE_BODY_SLOTS, DEFAULT_MAX_BODY_BYTES, DEFAULT_MAX_IN_FLIGHT, DEFAULT_MAX_SBOM_BODY_BYTES, DEFAULT_MAX_WAITING, DEFAULT_RATE_LIMIT_PER_MIN, LARGE_BODY_TARGETS_TEXT, Slots, FixedWindowLimiter, ORIGIN_REFUSED_CODE, RATE_LIMITED_CODE, bodyFacts, originVerdict, resolveClient, revisionLabel, rpcError, isLargeBodyTarget, isListenStream, largeBodyAdmissible, uaFamily, } from "./httpPolicy.js";
40
+ import { Admission, BODY_TOO_LARGE_CODE, BUSY_CODE, DEFAULT_ADMISSION_WAIT_MS, DEFAULT_LARGE_BODY_SLOTS, DEFAULT_MAX_BODY_BYTES, DEFAULT_MAX_IN_FLIGHT, DEFAULT_MAX_SBOM_BODY_BYTES, DEFAULT_MAX_WAITING, DEFAULT_RATE_LIMIT_PER_MIN, LARGE_BODY_TARGETS_TEXT, Slots, FixedWindowLimiter, ORIGIN_REFUSED_CODE, RATE_LIMITED_CODE, bodyFacts, handlerErrorClass, protocolErrorCode, originVerdict, resolveClient, revisionLabel, rpcError, isLargeBodyTarget, isListenStream, largeBodyAdmissible, uaFamily, } from "./httpPolicy.js";
41
41
  // Must run before index.ts is evaluated: it is how index.ts knows not to start stdio.
42
42
  markHttpEntrypoint();
43
43
  const { createServer, NAME, VERSION, SHOWN_BASE, TIMEOUT_MS } = await import("./index.js");
@@ -83,8 +83,9 @@ const handler = createMcpHandler(createServer, {
83
83
  // leg answers over SSE, as a 2025-era streamable HTTP server does.
84
84
  // The largest body readBody can hand on; which request may be that large is decided here first.
85
85
  maxRequestBodySize: MAX_SBOM_BODY_BYTES,
86
- // Names only: an SDK error message can quote what the client sent.
87
- onerror: (e) => log("WARNING", "mcp_handler_error", { error_name: e.name }),
86
+ // Never the message: an SDK error message can quote what the client sent. A fixed label and
87
+ // the JSON-RPC code instead, so each one can be told apart (#2923).
88
+ onerror: (e) => log("WARNING", "mcp_handler_error", { error_name: e.name, error_class: handlerErrorClass(e), error_code: protocolErrorCode(e) }),
88
89
  });
89
90
  const limiter = new FixedWindowLimiter(RATE_LIMIT_PER_MIN);
90
91
  const largeBodies = new Slots(LARGE_BODY_SLOTS);
@@ -478,3 +478,50 @@ export function uaFamily(ua) {
478
478
  const fam = ua.split(/[/\s]/, 1)[0];
479
479
  return UA_FAMILY.test(fam) ? fam : "(other)";
480
480
  }
481
+ // #2923: the SDK's onerror messages can quote what the client sent (a request id, a JSON body, a
482
+ // method name), so the message is never logged. It is matched against the SDK's own fixed
483
+ // prefixes and only the matching label is logged; anything unrecognised is "other". A
484
+ // ProtocolError also carries its numeric JSON-RPC code, which is content-free.
485
+ const HANDLER_ERROR_CLASSES = [
486
+ ["Not Acceptable", "not_acceptable"],
487
+ ["Unsupported Media Type", "unsupported_media_type"],
488
+ ["Method not allowed", "method_not_allowed"],
489
+ ["Invalid Request: Batch must not exceed", "batch_too_large"],
490
+ ["Invalid Request: Server already initialized", "already_initialized"],
491
+ ["Invalid Request: Only one initialization", "duplicate_initialize"],
492
+ ["Invalid Request", "invalid_request"],
493
+ ["Parse error", "parse_error"],
494
+ ["Conflict", "conflict"],
495
+ ["Event store not configured", "no_event_store"],
496
+ ["Invalid event ID format", "bad_event_id"],
497
+ ["Received a response for an unknown", "unknown_response_id"],
498
+ ["Received a progress notification", "unknown_progress_token"],
499
+ ["Unknown message type", "unknown_message_type"],
500
+ ["Era mismatch", "era_mismatch"],
501
+ ["Dropped inbound request", "dropped_wrong_era"],
502
+ ["requestState verification rejected", "request_state_rejected"],
503
+ ["subscriptions/listen refused", "subscription_limit"],
504
+ ["Uncaught error in notification handler", "notification_handler_threw"],
505
+ ["Failed to write to the response stream", "response_stream_write_failed"],
506
+ ["Failed to encode result", "encode_result_failed"],
507
+ ["Failed to send", "send_failed"],
508
+ ];
509
+ export function handlerErrorClass(e) {
510
+ const message = e instanceof Error ? e.message : "";
511
+ const hit = HANDLER_ERROR_CLASSES.find(([prefix]) => message.startsWith(prefix));
512
+ return hit ? hit[1] : "other";
513
+ }
514
+ export function protocolErrorCode(e) {
515
+ const code = e?.code;
516
+ return typeof code === "number" && Number.isInteger(code) ? code : undefined;
517
+ }
518
+ export function classifyHandlerError(e) {
519
+ const out = {
520
+ error_name: e instanceof Error ? e.name : typeof e,
521
+ error_class: handlerErrorClass(e),
522
+ };
523
+ const code = protocolErrorCode(e);
524
+ if (code !== undefined)
525
+ out.error_code = code;
526
+ return out;
527
+ }
package/dist/index.js CHANGED
@@ -1206,6 +1206,86 @@ function lastCheck(radar, data) {
1206
1206
  `${radar}.last_run_at is not the time of every record the ${radar} numbers count, which cover everything still on record, not only what that check found; nor is it ${radar}.generated_at, when the API computed those numbers.`,
1207
1207
  ];
1208
1208
  }
1209
+ // ── Staleness: a radar whose last completed check is older than its cycle allows (#3010) ──
1210
+ //
1211
+ // Through 2.7.1 the note flagged only the two radars whose answer carries a liveness verdict
1212
+ // (shadow_ai's poller.running, mcp_servers.enabled), and every answer ended "Each radar refreshes
1213
+ // on its own schedule.". So during the #2975 scanning pause exposed_databases (77 h since its last
1214
+ // completed check) and leaked_credentials (68 h) read as current. Each radar now has a threshold
1215
+ // keyed to the cadence core-backend runs it at (cmd/server/main.go builds each poller; an interval
1216
+ // of 0 takes the package's defaultInterval):
1217
+ // kev_exposure kevexposure/poller.go defaultInterval, 12 h: 24 h, two cycles
1218
+ // exposed_databases exposeddb/poller.go defaultInterval, 12 h: 24 h, two cycles
1219
+ // leaked_credentials leakedcreds/poller.go defaultInterval, 10 min: 1 h, six cycles, so a stopped
1220
+ // radar shows within the hour and a slow cycle or two never trips it
1221
+ // shadow_ai the API's own poller.running (shadowctlog pollerSilentAfter, 30 min) first;
1222
+ // only when the answer gives no verdict, 30 minutes by poller.last_run_at
1223
+ // mcp_servers the API's own enabled (aiexposure liveWindow, 45 min) first; only when the
1224
+ // answer gives no verdict, 45 minutes by last_run_at
1225
+ // These are copies of numbers core-backend owns, so test/radar-staleness.test.mjs reads the Go files
1226
+ // and fails when a threshold is under twice the cadence it parses, when a cycle named here is not
1227
+ // the one it parses, when a radar has no entry, or when a constant can no longer be parsed.
1228
+ //
1229
+ // The age is taken against this server's clock when the answer is composed, never against a
1230
+ // radar's generated_at: that is when the API's stats cache computed the totals, and the cache is
1231
+ // stale-while-revalidate (core-backend internal/cache/swr.go keeps serving its last value while a
1232
+ // refresh fails), so it can lag the answer by hours and would understate the age. last_run_at is
1233
+ // read fresh (the API omits it when its last good read is more than two minutes old). The note
1234
+ // gives the age and the cycle, says the numbers may be stale, and gives no cause: the answer gives
1235
+ // none.
1236
+ const MINUTE = 60_000;
1237
+ const HOUR = 60 * MINUTE;
1238
+ export const STALE_AFTER = {
1239
+ kev_exposure: { after: 24 * HOUR, cycle: 12 * HOUR },
1240
+ exposed_databases: { after: 24 * HOUR, cycle: 12 * HOUR },
1241
+ leaked_credentials: { after: HOUR, cycle: 10 * MINUTE },
1242
+ shadow_ai: { after: 30 * MINUTE },
1243
+ mcp_servers: { after: 45 * MINUTE },
1244
+ };
1245
+ const counted = (n, unit) => `${n} ${unit}${n === 1 ? "" : "s"}`;
1246
+ // A threshold or a cycle: "24 hours", "1 hour", "45 minutes"; as an adjective, "12-hour".
1247
+ const span = (ms) => (ms % HOUR === 0 ? counted(ms / HOUR, "hour") : counted(Math.round(ms / MINUTE), "minute"));
1248
+ const spanAdj = (ms) => (ms % HOUR === 0 ? `${ms / HOUR}-hour` : `${Math.round(ms / MINUTE)}-minute`);
1249
+ // An age, rounded: minutes under two hours, hours under a week, days past that.
1250
+ const age = (ms) => ms < 2 * HOUR ? counted(Math.round(ms / MINUTE), "minute") : ms < 168 * HOUR ? counted(Math.round(ms / HOUR), "hour") : counted(Math.round(ms / (24 * HOUR)), "day");
1251
+ // How long before `now` the instant `at` was, when that is past the radar's threshold; otherwise
1252
+ // (or when `at` is not a real instant) undefined.
1253
+ function staleBy(radar, at, now) {
1254
+ if (typeof at !== "string" || !realInstant(at))
1255
+ return undefined;
1256
+ const ms = now - Date.parse(at);
1257
+ return ms > STALE_AFTER[radar].after ? ms : undefined;
1258
+ }
1259
+ // The note's sentence for a stats radar whose relayed last_run_at is past its threshold. It does not
1260
+ // repeat the stamp, which lastCheck prints once.
1261
+ function staleCheck(radar, data, now) {
1262
+ const ms = staleBy(radar, data.last_run_at, now);
1263
+ if (ms === undefined)
1264
+ return [];
1265
+ const { after, cycle } = STALE_AFTER[radar];
1266
+ const times = after / cycle === 2 ? "twice" : `${after / cycle} times`;
1267
+ return [`${radar}.last_run_at is about ${age(ms)} before this answer, more than ${times} the radar's ${spanAdj(cycle)} cycle, so the ${radar} numbers may be stale.`];
1268
+ }
1269
+ // The last sentence of every answer. "Each radar refreshes on its own schedule." said the radars
1270
+ // were refreshing whatever their last completed checks said. This says only what the answer
1271
+ // shows: each radar has a cycle of its own (not that it is running on it), and either the radars
1272
+ // that may be stale, the same ones whose own sentences above say so, or that nothing shows one past
1273
+ // its threshold, with each threshold named. "Nothing shows" stays true when the API serves no
1274
+ // last_run_at (#2335: the note then says nothing about a last completed check), and the envelope's
1275
+ // own note (RADAR_FRESHNESS_NOTE) already points to freshness on every answer.
1276
+ function freshnessClose(stale) {
1277
+ if (stale.length)
1278
+ return `Each radar has its own cycle, and ${stale.length} of the ${RADARS.length} radars may be stale: ${listed(stale)}.`;
1279
+ const byAfter = new Map();
1280
+ for (const [name] of RADARS) {
1281
+ const s = STALE_AFTER[name];
1282
+ if (s.cycle !== undefined)
1283
+ byAfter.set(s.after, [...(byAfter.get(s.after) ?? []), name]);
1284
+ }
1285
+ const timed = [...byAfter].map(([after, names]) => `${span(after)} for ${listed(names)}`);
1286
+ const verdicts = `for shadow_ai and mcp_servers, the API's own poller.running and enabled verdicts, or ${span(STALE_AFTER.shadow_ai.after)} and ${span(STALE_AFTER.mcp_servers.after)} where the answer gives none`;
1287
+ return `Each radar has its own cycle, and neither freshness nor anything else in this answer shows a radar past its threshold for calling its numbers stale (${timed.join(", ")}; ${verdicts}).`;
1288
+ }
1209
1289
  // The first row of a relayed ranking, for the note: "(first: http_server, 9120 services)".
1210
1290
  function firstOf(rows, label, count, unit) {
1211
1291
  const top = Array.isArray(rows) ? rows[0] : undefined;
@@ -1288,7 +1368,7 @@ function newestKEVRow(r) {
1288
1368
  row.exposed_hosts = hosts;
1289
1369
  return { row, state, outside: tracked === false };
1290
1370
  }
1291
- function readKEVExposure(body) {
1371
+ function readKEVExposure(body, now) {
1292
1372
  const { data, unknown, unusable, undated } = readRadar(body, KEV_SPEC);
1293
1373
  const sentences = [];
1294
1374
  const n = (k) => numIn(data, k);
@@ -1346,8 +1426,10 @@ function readKEVExposure(body) {
1346
1426
  }
1347
1427
  sentences.push(...windowSentences("kev_exposure", { window: data.window, undated, bad: false }, "when Shodan collected the banners of the oldest and the newest of the service×CVE pairs counted, Shodan's own times, not when EchelonGraph stored or re-checked a pair"));
1348
1428
  sentences.push(...lastCheck("kev_exposure", data));
1429
+ const stale = staleCheck("kev_exposure", data, now);
1430
+ sentences.push(...stale);
1349
1431
  sentences.push(...leftOut("kev_exposure", unknown, unusable));
1350
- return { data, sentences };
1432
+ return { data, sentences, stale: stale.length > 0 };
1351
1433
  }
1352
1434
  // ── exposed_databases: GET /api/v1/public/exposed-databases/stats (#2313 item 7) ──
1353
1435
  //
@@ -1375,7 +1457,7 @@ const EXPOSED_DB_SPEC = {
1375
1457
  instants: ["generated_at", "last_run_at"],
1376
1458
  window: "observed_window",
1377
1459
  };
1378
- function readExposedDatabases(body) {
1460
+ function readExposedDatabases(body, now) {
1379
1461
  const { data, unknown, unusable, undated } = readRadar(body, EXPOSED_DB_SPEC);
1380
1462
  const sentences = [];
1381
1463
  const n = (k) => numIn(data, k);
@@ -1401,8 +1483,10 @@ function readExposedDatabases(body) {
1401
1483
  }
1402
1484
  sentences.push(...windowSentences("exposed_databases", { window: data.window, undated, bad: false }, "when EchelonGraph's check last confirmed the oldest and the newest of the services counted answering without authentication"));
1403
1485
  sentences.push(...lastCheck("exposed_databases", data));
1486
+ const stale = staleCheck("exposed_databases", data, now);
1487
+ sentences.push(...stale);
1404
1488
  sentences.push(...leftOut("exposed_databases", unknown, unusable));
1405
- return { data, sentences };
1489
+ return { data, sentences, stale: stale.length > 0 };
1406
1490
  }
1407
1491
  // ── leaked_credentials: GET /api/v1/public/leaked-credentials/stats (#2313 item 7) ──
1408
1492
  //
@@ -1425,7 +1509,7 @@ const LEAKED_CREDS_SPEC = {
1425
1509
  instants: ["generated_at", "last_run_at"],
1426
1510
  window: "observed_window",
1427
1511
  };
1428
- function readLeakedCredentials(body) {
1512
+ function readLeakedCredentials(body, now) {
1429
1513
  const { data, unknown, unusable, undated } = readRadar(body, LEAKED_CREDS_SPEC);
1430
1514
  const sentences = [];
1431
1515
  const n = (k) => numIn(data, k);
@@ -1448,10 +1532,12 @@ function readLeakedCredentials(body) {
1448
1532
  }
1449
1533
  sentences.push(...windowSentences("leaked_credentials", { window: data.window, undated, bad: false }, "when EchelonGraph's detector last found the oldest and the newest of the (repository, secret) pairs counted in a public commit"));
1450
1534
  sentences.push(...lastCheck("leaked_credentials", data));
1535
+ const stale = staleCheck("leaked_credentials", data, now);
1536
+ sentences.push(...stale);
1451
1537
  sentences.push(...leftOut("leaked_credentials", unknown, unusable));
1452
- return { data, sentences };
1538
+ return { data, sentences, stale: stale.length > 0 };
1453
1539
  }
1454
- function readShadowAI(sa) {
1540
+ function readShadowAI(sa, now) {
1455
1541
  const sentences = [];
1456
1542
  const stats = field(sa, "stats");
1457
1543
  const used = new Set();
@@ -1574,6 +1660,10 @@ function readShadowAI(sa) {
1574
1660
  data.authentication = authentication;
1575
1661
  const lastRun = strAt(sa, "poller", "last_run_at");
1576
1662
  const running = boolAt(sa, "poller", "running");
1663
+ // #3010: running is the API's own verdict and decides; only a block with a real last_run_at and
1664
+ // no verdict is judged by STALE_AFTER.shadow_ai, the API's own window, and that time is still
1665
+ // not relayed (the block stays left out) or repeated: the note gives its age.
1666
+ const unjudged = running === undefined ? staleBy("shadow_ai", lastRun, now) : undefined;
1577
1667
  if (realInstant(lastRun) && running === true) {
1578
1668
  data.poller = { running, last_run_at: lastRun };
1579
1669
  sentences.push(`Shadow-AI radar freshness: the radar's leader last completed a Certificate Transparency (crt.sh) discovery cycle at ${lastRun} (poller.running is true: that was within ${SHADOW_AI_SILENT_AFTER} of the API's answer).`);
@@ -1587,10 +1677,13 @@ function readShadowAI(sa) {
1587
1677
  }
1588
1678
  else {
1589
1679
  sentences.push("Shadow-AI radar freshness is unknown: the answer's poller block does not give both a real time at which the radar's leader last completed a cycle (poller.last_run_at) and a running verdict (poller.running), so that block was left out of the data above.");
1680
+ if (unjudged !== undefined) {
1681
+ sentences.push(`The shadow-AI poller block gives no running verdict, but its last_run_at is about ${age(unjudged)} before this answer, more than the ${span(STALE_AFTER.shadow_ai.after)} after which the API reports the radar not running, so the shadow-AI figures may be stale.`);
1682
+ }
1590
1683
  }
1591
1684
  if (realInstant(lastObservation))
1592
1685
  sentences.push(`Latest shadow-AI observation on record: ${lastObservation}.`);
1593
- return { data, sentences };
1686
+ return { data, sentences, stale: (realInstant(lastRun) && running === false) || unjudged !== undefined };
1594
1687
  }
1595
1688
  // ── mcp_servers: GET /api/v1/public/ai-exposure/stats?service=mcp (#2315) ──
1596
1689
  //
@@ -1716,7 +1809,7 @@ function mcpRefusal(body) {
1716
1809
  }
1717
1810
  return undefined;
1718
1811
  }
1719
- function readMCPServers(src) {
1812
+ function readMCPServers(src, now) {
1720
1813
  const data = {};
1721
1814
  const unusable = [];
1722
1815
  const unsummed = [];
@@ -1807,12 +1900,18 @@ function readMCPServers(src) {
1807
1900
  if (typeof at === "string") {
1808
1901
  sentences.push(`mcp_servers last completed check: ${at} (mcp_servers.last_run_at, a timestamp, not a count).`, "mcp_servers.last_run_at is when the AI-exposure radar, which checks other AI services as well as MCP servers, last finished a cycle whose reads succeeded and whose scan opt-out register answered.", "mcp_servers.last_run_at is not the time of every verdict the mcp_servers numbers count (mcp_servers.window says when those were checked); nor is it mcp_servers.counted_at, when the API read those numbers.");
1809
1902
  }
1903
+ // #3010: enabled is the API's own verdict and decides; only an answer without a usable one is
1904
+ // judged by STALE_AFTER.mcp_servers, the API's own window, on the last_run_at relayed above.
1905
+ const unjudged = data.enabled === undefined ? staleBy("mcp_servers", data.last_run_at, now) : undefined;
1810
1906
  if (data.enabled === true) {
1811
1907
  sentences.push(`mcp_servers.enabled is true: the API reports the AI-exposure radar running, a check having completed within ${MCP_ENABLED_WITHIN} of its answer.`);
1812
1908
  }
1813
1909
  else if (data.enabled === false) {
1814
1910
  sentences.push(`mcp_servers.enabled is false: the API does not report the AI-exposure radar running (no check completed within ${MCP_ENABLED_WITHIN} of its answer), so these numbers may be stale.`);
1815
1911
  }
1912
+ else if (unjudged !== undefined) {
1913
+ sentences.push(`The answer gives no usable mcp_servers.enabled verdict, but mcp_servers.last_run_at is about ${age(unjudged)} before this answer, more than the ${span(STALE_AFTER.mcp_servers.after)} within which the API reports the AI-exposure radar running, so the mcp_servers numbers may be stale.`);
1914
+ }
1816
1915
  if (typeof data.counted_at === "string") {
1817
1916
  sentences.push(`mcp_servers.counted_at (${data.counted_at}) is when the API read these counts: a timestamp, not a count, and not when any verdict was checked.`);
1818
1917
  }
@@ -1822,7 +1921,7 @@ function readMCPServers(src) {
1822
1921
  if (unnamed) {
1823
1922
  sentences.push(`Also left out of mcp_servers: ${unnamed} field${unnamed === 1 ? "" : "s"} whose name is not shaped like a field name, not repeated here, since a name could itself identify a server.`);
1824
1923
  }
1825
- return { data, sentences };
1924
+ return { data, sentences, stale: data.enabled === false || unjudged !== undefined };
1826
1925
  }
1827
1926
  // #2842: the description is a head that fits the client cut (requiredText.ts); what each radar's
1828
1927
  // numbers count, field by field, is the outputSchema's description of that radar (RADAR_*_FIELDS
@@ -1858,7 +1957,7 @@ const RADAR_MCP_FIELDS = `mcp_servers: counts and timestamps only, no hostname.
1858
1957
  // not_assessed with measured_at null, the window is relayed in data and labelled by the note,
1859
1958
  // and the envelope's own note names it.
1860
1959
  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; mcp_servers, the latest verdict on record per hostname named like an MCP server in EchelonGraph's own Certificate Transparency feed, from EchelonGraph's identified MCP probe (server/discover, and initialize only if that is refused; never tools/call), where protected means the endpoint's RFC 9728 protected-resource metadata validated.";
1861
- const RADAR_NAMES = RADARS.map(([name]) => name);
1960
+ export const RADAR_NAMES = RADARS.map(([name]) => name);
1862
1961
  const RADAR_STATE_NOTE = "state is not_assessed: every radar answered, but none gives one time at which what it counts was observed: kev_exposure, exposed_databases, leaked_credentials, shadow_ai's confirmed_exposed and mcp_servers date what they count at most by a window (kev_exposure.window, exposed_databases.window, leaked_credentials.window, shadow_ai.confirmed_exposed.window, mcp_servers.window), from the oldest observation among the rows counted to the newest, 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.";
1863
1962
  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, freshness.shadow_ai.last_run_at with freshness.shadow_ai.running, and freshness.mcp_servers.last_run_at with freshness.mcp_servers.enabled), and null where it does not.";
1864
1963
  async function exposureRadar() {
@@ -1891,43 +1990,50 @@ async function exposureRadar() {
1891
1990
  const radars = failures.map(([radar, f]) => ({ radar, kind: f.kind, path: f.path, status: f.status ?? null, message: f.detail }));
1892
1991
  return failure("failed", message, { kind: "radars", path: null, status: null, message: `${failures.length} of ${RADARS.length} radars could not be read`, radars }, { radars: RADAR_NAMES, answered, failed: failures.map(([name]) => name) });
1893
1992
  }
1894
- // Each radar's answer is relayed cut to the fields its reader can label, in RADARS order.
1895
- const read = {
1896
- kev_exposure: readKEVExposure(data.kev_exposure),
1897
- exposed_databases: readExposedDatabases(data.exposed_databases),
1898
- leaked_credentials: readLeakedCredentials(data.leaked_credentials),
1899
- shadow_ai: readShadowAI(data.shadow_ai),
1900
- mcp_servers: readMCPServers(data.mcp_servers),
1901
- };
1902
- const relayed = {};
1903
- const said = [];
1904
- for (const [name] of RADARS) {
1905
- relayed[name] = read[name].data;
1906
- said.push(...read[name].sentences);
1907
- }
1908
- // Only what the readers relayed: a last_run_at that is not a real instant never got there.
1909
- const stamp = (radar, ...keys) => instantOrNull(strAt(relayed[radar], ...keys));
1910
- const running = boolAt(relayed.shadow_ai, "poller", "running");
1911
- const enabled = boolAt(relayed.mcp_servers, "enabled");
1912
- return succeeded(relayed, `${okHead(tool)} All ${RADARS.length} radars answered: ${RADARS.map(([name]) => name).join(", ")}. Every number relayed is labelled below by what it counts, and a field this version cannot label is left out and named. ${said.join(" ")} ${RADAR_SOURCES} ${SHODAN_OWNERSHIP} Each radar refreshes on its own schedule.`, {
1913
- state: "not_assessed",
1914
- measured_at: null,
1915
- method: RADAR_METHOD,
1916
- coverage: { radars: RADAR_NAMES, answered: RADAR_NAMES, failed: [] },
1917
- freshness: {
1918
- kev_exposure: { last_run_at: stamp("kev_exposure", "last_run_at") },
1919
- exposed_databases: { last_run_at: stamp("exposed_databases", "last_run_at") },
1920
- leaked_credentials: { last_run_at: stamp("leaked_credentials", "last_run_at") },
1921
- shadow_ai: { last_run_at: stamp("shadow_ai", "poller", "last_run_at"), running: running ?? null },
1922
- mcp_servers: { last_run_at: stamp("mcp_servers", "last_run_at"), enabled: enabled ?? null },
1923
- },
1924
- notes: [RADAR_STATE_NOTE, RADAR_FRESHNESS_NOTE],
1925
- });
1993
+ return radarAnswer(data, Date.now());
1926
1994
  }
1927
1995
  catch (e) {
1928
1996
  return crashed(tool, e);
1929
1997
  }
1930
1998
  }
1999
+ // exposure_radar's answer once every radar has answered (and mcp_servers has passed mcpRefusal):
2000
+ // each radar's answer relayed cut to the fields its reader can label, in RADARS order, and the
2001
+ // note. `now` is the clock the radars' last completed checks are aged against (#3010; see
2002
+ // STALE_AFTER), a parameter so that the suite can set it.
2003
+ export function radarAnswer(data, now) {
2004
+ const read = {
2005
+ kev_exposure: readKEVExposure(data.kev_exposure, now),
2006
+ exposed_databases: readExposedDatabases(data.exposed_databases, now),
2007
+ leaked_credentials: readLeakedCredentials(data.leaked_credentials, now),
2008
+ shadow_ai: readShadowAI(data.shadow_ai, now),
2009
+ mcp_servers: readMCPServers(data.mcp_servers, now),
2010
+ };
2011
+ const relayed = {};
2012
+ const said = [];
2013
+ for (const [name] of RADARS) {
2014
+ relayed[name] = read[name].data;
2015
+ said.push(...read[name].sentences);
2016
+ }
2017
+ const stale = RADAR_NAMES.filter((name) => read[name].stale);
2018
+ // Only what the readers relayed: a last_run_at that is not a real instant never got there.
2019
+ const stamp = (radar, ...keys) => instantOrNull(strAt(relayed[radar], ...keys));
2020
+ const running = boolAt(relayed.shadow_ai, "poller", "running");
2021
+ const enabled = boolAt(relayed.mcp_servers, "enabled");
2022
+ return succeeded(relayed, `${okHead("exposure_radar")} All ${RADARS.length} radars answered: ${RADARS.map(([name]) => name).join(", ")}. Every number relayed is labelled below by what it counts, and a field this version cannot label is left out and named. ${said.join(" ")} ${RADAR_SOURCES} ${SHODAN_OWNERSHIP} ${freshnessClose(stale)}`, {
2023
+ state: "not_assessed",
2024
+ measured_at: null,
2025
+ method: RADAR_METHOD,
2026
+ coverage: { radars: RADAR_NAMES, answered: RADAR_NAMES, failed: [] },
2027
+ freshness: {
2028
+ kev_exposure: { last_run_at: stamp("kev_exposure", "last_run_at") },
2029
+ exposed_databases: { last_run_at: stamp("exposed_databases", "last_run_at") },
2030
+ leaked_credentials: { last_run_at: stamp("leaked_credentials", "last_run_at") },
2031
+ shadow_ai: { last_run_at: stamp("shadow_ai", "poller", "last_run_at"), running: running ?? null },
2032
+ mcp_servers: { last_run_at: stamp("mcp_servers", "last_run_at"), enabled: enabled ?? null },
2033
+ },
2034
+ notes: [RADAR_STATE_NOTE, RADAR_FRESHNESS_NOTE],
2035
+ });
2036
+ }
1931
2037
  // ── outputSchema: the envelope, per tool (#2311, #2313) ──
1932
2038
  //
1933
2039
  // A discriminated union on state: a success (measured, not_assessed) carries data and a method;
@@ -2302,7 +2408,7 @@ export function createServer() {
2302
2408
  const server = new McpServer({ name: NAME, version: VERSION }, { instructions: INSTRUCTIONS, capabilities: { tools: { listChanged: false }, prompts: { listChanged: false }, resources: { listChanged: false } } });
2303
2409
  server.registerTool("cve_summary", {
2304
2410
  title: "CVE feed summary",
2305
- description: `Summary of EchelonGraph's CVE Pulse feed: summary.total active CVEs, their counts by severity band (summary.critical, summary.high, summary.medium, summary.low), the count with no band (summary.none), and summary.last_updated, the newest modification time among those records. ${SUMMARY_NONE_DESCRIPTION} ${SUMMARY_NVD_DESCRIPTION} ${SUMMARY_REJECTED_DESCRIPTION} ${SUMMARY_POLLER_HEAD} The feed is polled from its sources on a schedule, so this is the state as of that update. ${CVE_SUMMARY_ENVELOPE} ${TEXT_BUDGET_DESCRIPTION}`,
2411
+ description: `Summary of EchelonGraph's CVE Pulse feed: summary.total active CVEs, their counts by severity band (summary.critical, summary.high, summary.medium, summary.low), the count with no band (summary.none), and summary.last_updated, the newest modification time among those records. ${SUMMARY_NONE_DESCRIPTION} ${SUMMARY_NVD_DESCRIPTION} ${SUMMARY_REJECTED_DESCRIPTION} ${SUMMARY_POLLER_HEAD} The feed is polled from its sources on a schedule; this is the state as of that update. ${CVE_SUMMARY_ENVELOPE} ${TEXT_BUDGET_DESCRIPTION}`,
2306
2412
  outputSchema: CVE_SUMMARY_OUTPUT,
2307
2413
  annotations: ANNOTATIONS,
2308
2414
  }, async () => checked("cve_summary", CVE_SUMMARY_OUTPUT, await cveSummary()));
@@ -2323,7 +2429,7 @@ export function createServer() {
2323
2429
  }, async (a) => checked("search_cves", SEARCH_CVES_OUTPUT, await searchCVEs(a)));
2324
2430
  server.registerTool("get_cve", {
2325
2431
  title: "CVE detail",
2326
- description: `One CVE's record: description, severity, cvss_v3_score, cvss_v4_score and cvss_v4_severity, echelongraph_score and echelongraph_severity with score_confidence and score_assessed, epss_score and epss_percentile, CISA-KEV status (kev_listed, kev_added_date, kev_due_date, kev_ransomware) and, for a KEV-listed CVE, CISA's own text relayed verbatim (kev_required_action, not EchelonGraph's advice; kev_short_description; kev_notes_urls), ghsa_id, patch_available and patch_evidence, references, cpe_match (one flat list) and cpe_configurations (NVD's configurations as NVD sent them, with each AND/OR operator; absent where EchelonGraph has stored none, which is not a finding that no product is affected), remediation (a capped summary of how it is fixed), published, modified and updated_at; each field only where the record has it. Pass a CVE ID like CVE-2023-44487. ${SCORE_ASSESSED_DESCRIPTION} ${PATCH_AVAILABLE_DESCRIPTION} ${FEED_ENVELOPE} ${TEXT_BUDGET_DESCRIPTION} Cut, each list in the record keeps its first entries, and the note names each list cut with its full length.`,
2432
+ description: `One CVE's record: description, severity, cvss_v3_score, cvss_v4_score and cvss_v4_severity, echelongraph_score, echelongraph_severity and score_confidence, epss_score and epss_percentile, CISA-KEV status (kev_listed, kev_added_date, kev_due_date, kev_ransomware) and, if KEV-listed, CISA's own text relayed verbatim (kev_required_action, not EchelonGraph's advice; kev_short_description; kev_notes_urls), ghsa_id, references, cpe_match (one flat list) and cpe_configurations (NVD's configurations as NVD sent them, with each AND/OR operator; absent where none is stored, which is not a finding that no product is affected), remediation (a capped summary of its fix), published, modified and updated_at; each field only where the record has it. Pass a CVE ID like CVE-2023-44487. ${SCORE_ASSESSED_DESCRIPTION} ${PATCH_AVAILABLE_DESCRIPTION} ${FEED_ENVELOPE} ${TEXT_BUDGET_DESCRIPTION} Cut, each list in the record keeps its first entries, and the note names each list cut with its full length.`,
2327
2433
  inputSchema: z.object({ cve_id: CVE_ID_ARG }),
2328
2434
  outputSchema: GET_CVE_OUTPUT,
2329
2435
  annotations: ANNOTATIONS,
package/dist/resources.js CHANGED
@@ -63,7 +63,7 @@ A count of 0 is a finding of none when the answer measured it. Under assessed: f
63
63
  - check_affected: measured when the answer says assessed true; not_assessed when it says false or does not say. measured_at and freshness are null.
64
64
  - check_sbom: measured when at least one component got a verdict, else not_assessed; measured_at null. not_affected is the one clean verdict; undetermined and not_assessed are not clean.
65
65
  - scan_manifest: as check_sbom, on the purls this server reads from the lockfiles passed; not_assessed, with no API call, when no entry pins one registry version. Each entry not sent (a range, an unresolved version, a local path, a VCS checkout or a line not read) is listed in data.not_checked with its reason and is not clean; a refused file (package.json, go.sum and the other manifests of ranges) is named in the note, and nothing in it is checked.
66
- - cve_intel: measured_at null: each row carries its own first_seen_at or enriched_at. An empty exploits list is not evidence that no public exploit exists.
66
+ - cve_intel: measured_at null: each row carries its own dates. An exploits row carries source_published_at, the source's own date for the reference, null when the source publishes none (EchelonGraph does not supply one), and eg_first_seen_at, when EchelonGraph recorded the reference; first_seen_at is deprecated and holds the same value as source_published_at. A timeline row carries enriched_at. An empty exploits list is not evidence that no public exploit exists.
67
67
  - cve_remediation: measured with measured_at null, or not_assessed for a CVE record rejected by its numbering authority. Every text is its source's, verbatim and untested by EchelonGraph; each vendor item's kind is the vendor's own category. remediation_state none_in_source or not_parsed, and an empty list, are not a finding that no fix exists.
68
68
  - get_cwe: measured_at null: each row carries its own published date. A total of 0 says no CVE in EchelonGraph's feed is classified under that CWE.
69
69
  - vendor_advisories_for_cve, search_vendor_advisories: measured_at null (each row carries vendor_published_at and our_first_seen_at). get_vendor_advisory: measured_at is our_first_seen_at, when EchelonGraph first recorded the advisory.
@@ -130,10 +130,10 @@ const COVERAGE = z.strictObject({
130
130
  // Constant strings, so marketing-site lib/mcpToolClaims.test.ts can fold them should it read
131
131
  // this file.
132
132
  export const CHECK_AFFECTED_TITLE = "Am I affected? (product or package at a version)";
133
- export const CHECK_AFFECTED_DESCRIPTION = "Whether a product or package at a given version is affected by known CVEs, from the same matcher as echelongraph.io/am-i-affected. The CPE path takes product (the NVD CPE product token, such as openssl or nginx) and version, and returns the CVEs whose NVD CPE match criteria name that product with a version range that includes the version; each match names the vendor NVD asserts (cpe_vendor) and whether that vendor was verified (vendor_unknown). The registry path takes ecosystem (npm, PyPI, Maven and other OSV ecosystem names), package and version, and decides each OSV advisory record EchelonGraph holds for that package as affected, not affected or undetermined. count depends on assessed: assessed false means the lookup did not evaluate this component, not_assessed_reason says why, and a count of 0 there is not a finding of not affected. An advisory whose version range cannot be decided at this version is reported as undetermined (undetermined_count, and up to 50 of them in undetermined), never as safe. Each match carries cve_id, kev_listed, ransomware, epss_score, effective_score, effective_severity and score_assessed (false: not yet scored, so echelongraph_score is withheld). A registry match also carries interval, the advisory interval holding this version, and fixed_in, its fixed bound, or null with fixed_in_reason; a CPE match carries no fixed_in. Product, version, ecosystem and package travel in request headers, never in the URL." +
133
+ export const CHECK_AFFECTED_DESCRIPTION = "Whether a product or package at a given version is affected by known CVEs, from the same matcher as echelongraph.io/am-i-affected. The CPE path takes product (an NVD CPE product token, such as openssl or nginx) and version, and returns the CVEs whose NVD CPE match criteria include that product at that version; each match names the vendor NVD asserts (cpe_vendor) and whether it was verified (vendor_unknown). The registry path takes ecosystem (npm, PyPI, Maven and other OSV ecosystem names), package and version, and decides each OSV advisory EchelonGraph holds for that package as affected, not affected or undetermined. count depends on assessed: assessed false means the lookup did not evaluate this component, not_assessed_reason says why, and a count of 0 there is not a finding of not affected. An advisory undecidable at this version is reported as undetermined (undetermined_count, and up to 50 of them in undetermined), never as safe. Each match carries cve_id, kev_listed, ransomware, epss_score, effective_score, effective_severity and score_assessed (false: not yet scored, so echelongraph_score is withheld). A registry match also carries interval, the advisory interval holding this version, and fixed_in, its fixed bound, or null with fixed_in_reason; a CPE match carries no fixed_in. All four inputs travel in request headers, never in the URL." +
134
134
  " " +
135
135
  TEXT_BUDGET_DESCRIPTION +
136
- " Cut, the excluded and undetermined samples keep their first 10 entries, each its cve_id and reason, cve_ids keeps its first 10, and each match keeps fewer fields, cve_id, kev_listed, ransomware, epss_score, effective_score, score_assessed and fixed_in at least. Every CPE match stays in the text; near the cap, a registry list's last matches can leave it, and the note says how many.";
136
+ " Cut, the excluded and undetermined samples keep their first 10 entries, each its cve_id and reason, cve_ids keeps its first 10, and each match keeps cve_id, kev_listed, ransomware, epss_score, effective_score, score_assessed and fixed_in at least. Every CPE match stays in the text; near the cap, a registry list's last matches can leave it, and the note says how many.";
137
137
  export const CHECK_AFFECTED_INPUT = z.object({
138
138
  product: z.string().optional().describe("CPE path: the NVD CPE product token, such as openssl, nginx or linux_kernel. Leave out for the registry path."),
139
139
  ecosystem: z.string().optional().describe("Registry path: the package's ecosystem, such as npm, PyPI or Maven. Give with package, not with product."),
@@ -25,10 +25,10 @@ export const CHECK_SBOM_TITLE = "Check an SBOM against the advisory corpus";
25
25
  export const CHECK_SBOM_ROUTING = "For container images and Kubernetes pods, the input is an SBOM of each image, or its purls.";
26
26
  export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGraph's advisory corpus, one verdict per component. " +
27
27
  CHECK_SBOM_ROUTING +
28
- " Pass purls (package URLs, up to 2,000 distinct) or sbom (a CycloneDX JSON or SPDX JSON document, up to 5,000,000 characters). The purls are read from the document by this MCP server and only they are sent to the API, in POST bodies of at most 200 purls each, never in a URL; the document itself is not sent on. Run from npm, this server is on your machine; over the hosted endpoint (mcp.echelongraph.io) it is EchelonGraph's, and the document is the request body, accepted up to 6 MiB. data.results holds one row per component sent, in order, with verdict (affected, not_affected, undetermined or not_assessed), not_assessed_reason, cve_ids, matched_package and matched_via (a deb or apk purl's upstream qualifier is matched as its source package); each match carries fixed_in, its advisory interval's fixed bound, or null with fixed_in_reason. data.summary counts the verdicts. not_affected is the only clean verdict. not_assessed is no verdict, as for a deb, apk or rpm purl without a distro qualifier naming its release (EchelonGraph does not guess one); neither it nor undetermined is clean, and the note counts both. The API allows 1,200 components a minute per caller; on a 429 the tool waits out Retry-After, up to 50 seconds a call, then answers what it has: data.not_sent_purls lists the unsent purls, unchecked and not clean. A list or document with more than 2,000 distinct purls is refused, not truncated." +
28
+ " Pass purls (package URLs, up to 2,000 distinct) or sbom (a CycloneDX or SPDX JSON document, up to 5,000,000 characters). This server reads the purls from the document; only they are sent to the API, in POST bodies of at most 200, never in a URL; the document itself is not sent on. Run from npm, this server is on your machine; over the hosted endpoint (mcp.echelongraph.io) it is EchelonGraph's, and the document is the request body, accepted up to 6 MiB. data.results holds one row per component sent, in order, with verdict (affected, not_affected, undetermined or not_assessed), not_assessed_reason, cve_ids, matched_package and matched_via (a deb or apk purl's upstream qualifier matches as its source package); each match carries fixed_in (its interval's fixed bound) or null with fixed_in_reason. data.summary counts the verdicts. not_affected is the only clean verdict: not_assessed (no verdict, as for a deb, apk or rpm purl without a distro qualifier naming its release, which EchelonGraph does not guess) and undetermined are not, and the note counts both. The API allows 1,200 components a minute per caller; on a 429 the tool waits out Retry-After, up to 50 seconds a call, then answers what it has, the rest in data.not_sent_purls, unchecked and not clean. Input with more than 2,000 distinct purls is refused, not truncated." +
29
29
  " " +
30
30
  TEXT_BUDGET_DESCRIPTION +
31
- " Cut, each row keeps index, purl, verdict, not_assessed_reason and cve_ids at least, and each match its fixed_in while matches are kept; not_affected rows leave the text first, then not_assessed ones, and not_sent_purls keeps its first 10.";
31
+ " Cut, each row keeps index, purl, verdict, not_assessed_reason and cve_ids at least, each match its fixed_in while kept; not_affected rows leave the text first, then not_assessed ones; not_sent_purls keeps its first 10.";
32
32
  // #2783: a production row is about 2,200 characters as pretty JSON, most of it its matches, each
33
33
  // a CVE with its description, so the 50-component Juice Shop document was 112,820 characters of
34
34
  // text, and 2,000 purls about 1,200,000. Past DATA_TEXT_BUDGET each row in the first text block
@@ -25,6 +25,12 @@
25
25
  // does not say which ran, so no text here says a source is polled: it names the sources the data
26
26
  // model holds, and says coverage depends on which pollers run.
27
27
  //
28
+ // Exploit dates (#2876). Until #2876 the collectors made dates up: a nuclei row's first_seen_at
29
+ // was 1 January of its CVE's year, and a Metasploit row's was the vulnerability's disclosure
30
+ // date. Each row now carries source_published_at (the source's own date, null where it gives
31
+ // none) and eg_first_seen_at (when EchelonGraph recorded it); first_seen_at, deprecated, is
32
+ // source_published_at under its old key. All three are relayed as sent, null included.
33
+ //
28
34
  // Fixed versions (#2817). /enrichment keeps one affected_packages row per (CVE, ecosystem,
29
35
  // package), and its fixed_version is one range's fix: the last range's as the advisory was read
30
36
  // (core-backend cve/backfill_osv_packages.go summariseAffected). For CVE-2021-44228 production
@@ -70,7 +76,10 @@ const TIMELINE_RELAYED = 20;
70
76
  const CVE_INTEL_METHOD = "EchelonGraph's per-CVE enrichment (GET /api/v1/public/cves/:id/enrichment). cwes: CWE classifications stored from NVD, GitHub (GHSA) and CVE.org records, each with the source that gave it, named from the MITRE CWE catalog EchelonGraph embeds. exploits: public exploit references from EchelonGraph's cve_exploits records, whose data model holds Metasploit modules (metasploit), Exploit-DB entries (exploit_db), nuclei templates (nuclei_template), GitHub proof-of-concept repositories (github_poc) and vendor proofs of concept (vendor_poc), filled by a curated seed and by one poller per automated source, each run only where it is enabled, so which sources are current depends on the deployment. affected_packages and fixed_versions: package-ecosystem advisory data, each row naming its source. timeline: EchelonGraph's enrichment history for the CVE, newest first.";
71
77
  const VERIFIED_STATUS_DESCRIPTION = "verified_status is the label stored with each reference: verified for a Metasploit module, for an Exploit-DB entry Exploit-DB marks verified, and for curated seed rows marked so; reported for a public artefact nothing has confirmed works (nuclei templates, GitHub proofs of concept, unverified Exploit-DB entries); unconfirmed where a curated row says so. It is a label from the source, not a guarantee that the exploit works against a given system.";
72
78
  const FIXED_BRANCHES_DESCRIPTION = 'fixed_branches lists each affected range of the package on record: introduced, and fixed (the range\'s fix) or last_affected (its last affected version, where no fix is on record), each null when absent, with advisory_id (the OSV record that published the range) and source (the loader\'s label for that record), both null on a row loaded before they were stored. Ranges with an advisory_id are in the order their record lists them (records in id order); ranges without one are oldest first. To pick the range for an installed version, compare in the ecosystem\'s version order (release numbers part by part, as numbers: 2.4 is below 2.13.0): the version is in a range when it is at or above introduced ("0" is the first version) and below fixed, or at or below last_affected; that range\'s fixed is the fix for it, and a version in no range is outside every range on record. fixed_branches is null where the package\'s ranges were never loaded, and [] where no version range is on record (the advisory gives none, only commit ranges, or more than are stored), which is not a finding that no fix exists: fixed_version can still name one. fixed_version is one range\'s fix, kept for compatibility (one per package: the range the loader kept, the advisory\'s last as a rule), not the fix for every affected range: for CVE-2021-44228, its advisory gives log4j-core three ranges, and fixed_version 2.12.2 is the fix for the one from 2.4, while the range holding 2.14.1 (from 2.13.0) is fixed in 2.15.0.';
73
- export const CVE_INTEL_DESCRIPTION = `Weakness, public exploit code, affected packages and fixed versions for one CVE, from EchelonGraph's per-CVE enrichment. Returns cwes (each cwe_id with its name and source), exploits (each with kind, source_name, source_url, first_seen_at and verified_status; at most 10, verified first) with exploits_total, exploits_capped, exploits_by_kind and exploits_by_status, affected_packages (ecosystem, package_name, version_range, fixed_version, fixed_branches), fixed_versions (one row per fixed version, with the vulnerable_range it fixes) and timeline (the newest enrichment-history rows, with timeline_total). fixed_branches lists each affected range of the package on record (introduced, and fixed or last_affected, with advisory_id (the OSV record that published the range) and source); in the ecosystem's version order, a version is in a range when it is at or above introduced ("0" is the first version) and below fixed, or at or below last_affected; that range's fixed is the fix for it. fixed_version is one range's fix, kept for compatibility, not the fix for every affected range. fixed_branches [] is not a finding that no fix exists. verified_status is a label from the source, not a guarantee that the exploit works against a given system. An empty exploits list is not evidence that no public exploit exists: it covers only the sources EchelonGraph ingests, and which of them are polled depends on the deployment. A section the API could not read is named in coverage.sections_failed and left out of data, never relayed as an empty list. Pass a CVE ID like CVE-2021-44228. Its structured result carries notes, with data, which the first text block holds whole up to 30,000 characters; its coverage names the sections relayed, failed and left out. ${TEXT_BUDGET_DESCRIPTION}`;
79
+ // #2876: the exploit dates are named for what they are. Within the 100 characters of headroom
80
+ // under the 2048-character client cut (prompts-resources.test.mjs), the room came from tighter
81
+ // glosses and from the input hint, which inputSchema's cve_id description carries.
82
+ export const CVE_INTEL_DESCRIPTION = `Weakness, public exploit code, affected packages and fixed versions for one CVE, from EchelonGraph's per-CVE enrichment. Returns cwes (each cwe_id with name and source), exploits (each with kind, source_name, source_url, source_published_at (the source's date, null if none), eg_first_seen_at (when EchelonGraph recorded it) and verified_status; at most 10, verified first) with exploits_total, exploits_capped, exploits_by_kind and exploits_by_status, affected_packages (ecosystem, package_name, version_range, fixed_version, fixed_branches), fixed_versions (each fixed version with the vulnerable_range it fixes) and timeline (the newest enrichment-history rows, with timeline_total). fixed_branches lists each affected range on record (introduced, and fixed or last_affected, with advisory_id (its OSV record) and source); in the ecosystem's version order, a version is in a range when it is at or above introduced ("0" is the first version) and below fixed, or at or below last_affected; that range's fixed is the fix for it. fixed_version is one range's fix, kept for compatibility, not the fix for every affected range. fixed_branches [] is not a finding that no fix exists. verified_status is the source's label, not a guarantee the exploit works on a given system. An empty exploits list is not evidence that no public exploit exists: it covers only the sources EchelonGraph ingests, and which are polled depends on the deployment. A section the API could not read is named in coverage.sections_failed and left out of data, never relayed as an empty list. Its structured result carries notes, with data, which the first text block holds whole up to 30,000 characters; its coverage names the sections relayed, failed and left out. ${TEXT_BUDGET_DESCRIPTION}`;
74
83
  // ── outputSchema ──
75
84
  const Rows = (shape) => z.array(z.looseObject(shape));
76
85
  const CountMap = z.record(z.string(), z.number());
@@ -87,7 +96,11 @@ function schemas() {
87
96
  source_name: opt(z.string()),
88
97
  source_url: opt(z.string()),
89
98
  description: opt(z.string()),
90
- first_seen_at: opt(z.string()),
99
+ source_published_at: opt(z.string()).describe("The source's own date for the reference (Exploit-DB's date_published, a GitHub proof-of-concept repository's created_at, or the first commit of a nuclei template or Metasploit module where EchelonGraph derived it from the source's git history); null where the source gives none, and EchelonGraph supplies no stand-in date."),
100
+ // Optional, not nullable: core-backend always serves it since #2876 (cve_exploits.created_at);
101
+ // an API older than #2876 leaves it out.
102
+ eg_first_seen_at: z.string().optional().describe("When EchelonGraph first recorded the reference, as a UTC timestamp: a date of EchelonGraph's, not the source's."),
103
+ first_seen_at: opt(z.string()).describe("Deprecated: the same value as source_published_at, null when that is."),
91
104
  verified_status: opt(z.string()).describe(VERIFIED_STATUS_DESCRIPTION),
92
105
  }).optional(),
93
106
  exploits_total: z.number().int().nullable().optional().describe("Every exploit reference on record for the CVE, not only those listed in exploits."),
@@ -323,7 +336,7 @@ export async function cveIntel(cve_id) {
323
336
  coverage,
324
337
  freshness: null,
325
338
  notes: [
326
- "measured_at is null: the answer has no single observation time; each row carries its own (first_seen_at, enriched_at).",
339
+ "measured_at is null: the answer has no single observation time; each row carries its own (source_published_at and eg_first_seen_at, enriched_at).",
327
340
  "freshness is null: the answer does not say when each source was last polled.",
328
341
  ],
329
342
  });
@@ -7,10 +7,10 @@ const MAX_VENDOR = 128;
7
7
  const MAX_CURSOR = 128;
8
8
  const KEV_RECENT_METHOD = "EchelonGraph's copy of the CISA KEV catalog: CISA's feed is polled every 5 minutes and each entry is written onto its CVE's record; rows are read from those records, newest kev_added_date first.";
9
9
  const KEV_RECENT_COVERAGE = "CISA KEV catalog";
10
- const KEV_RECENT_DESCRIPTION = "The CVEs CISA has added to its Known Exploited Vulnerabilities (KEV) catalog, newest first, from EchelonGraph's copy of that catalog, which polls CISA's feed every 5 minutes. Each row in kev gives cve_id, kev_added_date (CISA's dateAdded), kev_due_date, kev_vendor, kev_product, kev_vuln_name and kev_ransomware (known ransomware-campaign use), EchelonGraph's severity, cvss_v3_score, epss_score, epss_percentile and eg_kev_tier for the CVE, and our_first_seen_kev, when EchelonGraph's poller first recorded the CVE entering the catalog (null where no such record exists). Filter by since and until (YYYY-MM-DD, inclusive, on kev_added_date), ransomware, and vendor (an exact, case-insensitive match on kev_vendor); page with limit (1 to 200, default 50) and cursor, passing back the previous page's next_cursor with the same filters. total counts the CVEs matching the filters; kev_listed_total counts every CVE EchelonGraph holds as KEV-listed, and catalog.catalog_count is CISA's own count in the catalog last fetched, so a gap between the two is entries not yet in EchelonGraph's CVE table, which no page returns. CISA's requiredAction, shortDescription and notes are not in these rows: each CVE's own record carries them, verbatim and labelled as CISA's. Its structured result's measured_at is our last successful fetch of CISA's feed (null when the API does not give it), coverage counts the CISA KEV catalog (total, returned, kev_listed_total, catalog_count, limit, has_more) and freshness gives last_successful_fetch_at, catalog_version and date_released." +
10
+ const KEV_RECENT_DESCRIPTION = "The CVEs CISA has added to its Known Exploited Vulnerabilities (KEV) catalog, newest first, from EchelonGraph's copy of that catalog, which polls CISA's feed every 5 minutes. Each kev row gives cve_id, kev_added_date (CISA's dateAdded), kev_due_date, kev_vendor, kev_product, kev_vuln_name and kev_ransomware (known ransomware-campaign use), EchelonGraph's severity, cvss_v3_score, epss_score, epss_percentile and eg_kev_tier for the CVE, and our_first_seen_kev, when EchelonGraph's poller first recorded the CVE entering the catalog (null where no such record exists). Filter by since and until (YYYY-MM-DD, inclusive, on kev_added_date), ransomware, and vendor (an exact, case-insensitive match on kev_vendor); page with limit (1 to 200, default 50) and cursor, passing back the previous page's next_cursor with the same filters. total counts the CVEs matching the filters; kev_listed_total counts every CVE EchelonGraph holds as KEV-listed, and catalog.catalog_count is CISA's own count in the catalog last fetched, so a gap between the two is entries not yet in EchelonGraph's CVE table, which no page returns. CISA's requiredAction, shortDescription and notes are not in these rows: each CVE's own record carries them, verbatim and labelled as CISA's. Its structured result's measured_at is our last successful fetch of CISA's feed (null when the API does not say), coverage counts the CISA KEV catalog (total, returned, kev_listed_total, catalog_count, limit, has_more) and freshness gives last_successful_fetch_at, catalog_version and date_released." +
11
11
  " " +
12
12
  TEXT_BUDGET_DESCRIPTION +
13
- " Cut, each row keeps fewer fields, cve_id, kev_added_date, kev_vendor and kev_ransomware at least, or rows are left out, and next_cursor still continues after the last row of the page, not of the text.";
13
+ " Cut, each row keeps cve_id, kev_added_date, kev_vendor and kev_ransomware at least, or rows are left out, and next_cursor still continues after the page's last row, not the text's.";
14
14
  const opt = (t) => t.nullable().optional();
15
15
  const KEVRow = z.looseObject({
16
16
  cve_id: opt(z.string()),
@@ -16,7 +16,7 @@ const TEXT_NOT_CHECKED = 20;
16
16
  const READ_ORDER = "counted in the order this tool read them (the files in the order given, each file's entries in the order the file lists them, each purl at its first place)";
17
17
  const NOT_CHECKED_NOTE = "not_checked entries are not clean: each names a dependency this tool did not send to the API (its version is a range or unresolved, or it comes from a local path, a VCS checkout or a line this tool does not read), so no verdict was looked up for it.";
18
18
  export const SCAN_MANIFEST_TITLE = "Check a lockfile against the advisory corpus";
19
- export const SCAN_MANIFEST_DESCRIPTION = "Check a project's lockfile or pinned manifest against EchelonGraph's advisory corpus, one verdict per dependency. Pass files: 1 to 20 of filename and content (its text), up to 5,000,000 characters in all; the filename gives the format, or format sets it. Read: requirements.txt (== and === pins only), go.mod (require, with replace and exclude applied), package-lock.json and npm-shrinkwrap.json (v1 to v3), Cargo.lock, Gemfile.lock, composer.lock, poetry.lock and gradle.lockfile. package.json, pyproject.toml, Pipfile, Gemfile, Cargo.toml, composer.json and build.gradle hold ranges and are refused, each naming the lockfile to pass; go.sum is refused, as it lists versions the build does not select. The files are read into purls by this MCP server and only the purls are sent to the API, in POST bodies of at most 200 each, never in a URL; filenames and file contents are not sent on. Run from npm, this server is on your machine; over the hosted endpoint (mcp.echelongraph.io) it is EchelonGraph's, and the files are the request body, accepted up to 6 MiB. data.results holds one row per distinct purl, with verdict (affected, not_affected, undetermined or not_assessed), not_assessed_reason, cve_ids and matched_via; each match carries kev_listed, epss_score and fixed_in, or null with fixed_in_reason. not_affected is the only clean verdict. data.not_checked lists each entry not sent, with its file, line and reason: version_unpinned (a range: django>=4), version_unresolved, local_path, vcs_source or unsupported_line; not_checked entries are not clean. At most 2,000 distinct purls per call, within 50 seconds; data.not_sent_purls lists any not sent, which are not checked and not clean." +
19
+ export const SCAN_MANIFEST_DESCRIPTION = "Check a project's lockfile or pinned manifest against EchelonGraph's advisory corpus, one verdict per dependency. Pass files: 1 to 20 of filename and content, up to 5,000,000 characters in all; the filename, or format, gives the format. Read: requirements.txt (== and === pins only), go.mod (require, after replace and exclude), package-lock.json and npm-shrinkwrap.json (v1 to v3), Cargo.lock, Gemfile.lock, composer.lock, poetry.lock and gradle.lockfile. package.json, pyproject.toml, Pipfile, Gemfile, Cargo.toml, composer.json and build.gradle hold ranges and are refused, naming the lockfile to pass; go.sum is refused: it lists versions the build does not select. This server reads the files into purls and only the purls are sent to the API, in POST bodies of at most 200, never in a URL; filenames and file contents are not sent on. Run from npm, this server is on your machine; over the hosted endpoint (mcp.echelongraph.io) it is EchelonGraph's, and the files are the request body, accepted up to 6 MiB. data.results holds one row per distinct purl, with verdict (affected, not_affected, undetermined or not_assessed), not_assessed_reason, cve_ids and matched_via; each match carries kev_listed, epss_score and fixed_in or null with fixed_in_reason. not_affected is the only clean verdict. data.not_checked lists each entry not sent, with file, line and reason: version_unpinned (a range), version_unresolved, local_path, vcs_source or unsupported_line; not_checked entries are not clean. At most 2,000 distinct purls per call, within 50 seconds; data.not_sent_purls lists any not sent, unchecked and not clean." +
20
20
  " " +
21
21
  TEXT_BUDGET_DESCRIPTION +
22
22
  " Cut, each row keeps index, purl, verdict, not_assessed_reason and cve_ids at least; not_checked keeps its first 20.";
@@ -616,7 +616,7 @@ export function registerVendorAdvisoryTools(server, kit) {
616
616
  }
617
617
  server.registerTool("vendor_advisories_for_cve", {
618
618
  title: "Vendor advisories for one CVE",
619
- description: `The vendor-published advisories that name one CVE, newest first, at most 20: for each, vendor, vendor_display_name, vendor_advisory_id, title, severity and cvss_v3_score where the vendor gives them. Covers the vendor feeds EchelonGraph polls, for example Microsoft MSRC, Red Hat, Cisco, Palo Alto Networks and GitHub GHSA; an empty answer means that none of the advisories EchelonGraph holds from those feeds names the CVE, not that no vendor published one. ${DATES_DESCRIPTION} ${REJECTED_CVE_DESCRIPTION} The answer's cve_rejected is true when the CVE record of the CVE asked for was rejected (withdrawn) by its numbering authority. Each row has remediation_kinds and remediation_state (not_parsed is not none). coverage gives returned, cap, at_cap (true: there may be more) and cve_year; vendor_windows, each vendor's window in what EchelonGraph holds: ${WINDOW_HEAD}; and vendors_not_fully_held, the vendors with no advisory in the answer of which EchelonGraph holds none, whose held_since is after 1 January of cve_year or not known, or whose history is still being read, which the note names. ${NOT_HELD_HEAD} ${TEXT_BUDGET_DESCRIPTION}${ROW_CUT_DESCRIPTION}.`,
619
+ description: `The vendor-published advisories that name one CVE, newest first, at most 20: for each, vendor, vendor_display_name, vendor_advisory_id, title, severity and cvss_v3_score where the vendor gives them. Covers the vendor feeds EchelonGraph polls, for example Microsoft MSRC, Red Hat, Cisco, Palo Alto Networks and GitHub GHSA; an empty answer means none of the advisories EchelonGraph holds names the CVE, not that no vendor published one. ${DATES_DESCRIPTION} ${REJECTED_CVE_DESCRIPTION} The answer's cve_rejected is true when the CVE record of the CVE asked for was rejected. Each row has remediation_kinds and remediation_state (not_parsed is not none). coverage gives returned, cap, at_cap (true: there may be more) and cve_year; vendor_windows, each vendor's held window: ${WINDOW_HEAD}; and vendors_not_fully_held, the vendors absent from the answer of which EchelonGraph holds none, whose held_since is after 1 January of cve_year or not known, or whose history is still being read, which the note names. ${NOT_HELD_HEAD} ${TEXT_BUDGET_DESCRIPTION}${ROW_CUT_DESCRIPTION}.`,
620
620
  inputSchema: z.object({ cve_id: z.string().describe("a CVE ID, e.g. CVE-2024-21412") }),
621
621
  outputSchema: FOR_CVE_OUTPUT,
622
622
  annotations: kit.annotations,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "echelongraph-mcp",
3
- "version": "2.7.0",
3
+ "version": "2.7.2",
4
4
  "mcpName": "io.echelongraph/echelongraph-mcp",
5
5
  "description": "MCP server for CVE lookups: EchelonGraph scores, CISA KEV, EPSS, CWE, exploits, version and SBOM checks, vendor advisories, and per-CVE exposure derived from Shodan data. Shodan data is owned by Shodan, which holds its copyright (© Shodan). Keyless.",
6
6
  "type": "module",
package/server.json CHANGED
@@ -17,7 +17,7 @@
17
17
  ]
18
18
  }
19
19
  ],
20
- "version": "2.7.0",
20
+ "version": "2.7.2",
21
21
  "remotes": [
22
22
  {
23
23
  "type": "streamable-http",
@@ -28,7 +28,7 @@
28
28
  {
29
29
  "registryType": "npm",
30
30
  "identifier": "echelongraph-mcp",
31
- "version": "2.7.0",
31
+ "version": "2.7.2",
32
32
  "transport": {
33
33
  "type": "stdio"
34
34
  },