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 +34 -0
- package/README.md +13 -10
- package/dist/http.js +4 -3
- package/dist/httpPolicy.js +47 -0
- package/dist/index.js +151 -45
- package/dist/resources.js +1 -1
- package/dist/tools/check_affected.js +2 -2
- package/dist/tools/check_sbom.js +2 -2
- package/dist/tools/cve_intel.js +16 -3
- package/dist/tools/kev_recent.js +2 -2
- package/dist/tools/scan_manifest.js +1 -1
- package/dist/tools/vendor_advisories.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
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
|
|
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
|
|
38
|
-
CVE-2023-44487 actively exploited, and
|
|
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
|
|
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,
|
|
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
|
|
267
|
-
sources on a schedule
|
|
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
|
-
//
|
|
87
|
-
|
|
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);
|
package/dist/httpPolicy.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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."),
|
package/dist/tools/check_sbom.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
package/dist/tools/cve_intel.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
});
|
package/dist/tools/kev_recent.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
31
|
+
"version": "2.7.2",
|
|
32
32
|
"transport": {
|
|
33
33
|
"type": "stdio"
|
|
34
34
|
},
|