echelongraph-mcp 2.6.0 → 2.6.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,36 @@ 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.6.2 — 2026-10-04
10
+
11
+ - `kev_recent` reads an RFC 3339 `since` or `until` as the date written in it, dropping the time
12
+ and offset. It used to convert the timestamp to UTC first, so `2024-04-12T00:00:00+02:00` was
13
+ answered for 2024-04-11, and `2024-04-12T23:30:00-05:00` for 2024-04-13, while still reporting
14
+ a measured result. `kev_added_date` is a calendar date, so the written date is the one meant.
15
+ `Z` timestamps, such as those `get_cve` returns, are unaffected (#2755).
16
+ - Hosted endpoint only: a `tools/call` of `check_sbom`, or a `prompts/get` of `sbom_review`, may
17
+ now send a request body of up to 6 MiB, so a real CycloneDX or SPDX document (up to the tool's
18
+ 5,000,000-character cap) is accepted over `https://mcp.echelongraph.io/mcp`; before, any body
19
+ over 64 KiB was refused, and only a purl list fit. Every other request keeps the 64 KiB cap.
20
+ Each server instance serves at most two such large bodies at once; a third is answered HTTP 503
21
+ with `Retry-After` and JSON-RPC error `-32030`. The per-client limit is unchanged (120 requests
22
+ a minute). New settings `MCP_MAX_SBOM_BODY_BYTES` and `MCP_LARGE_BODY_SLOTS`. The `check_sbom`
23
+ description and the README say that over the hosted endpoint the document reaches
24
+ EchelonGraph's server, which reads the purls from it and neither logs nor keeps it (#2747).
25
+ - `cve_summary` says what the answer's `poller` block is: the in-memory counters of the NVD
26
+ poller of the one API instance that answered, since it last started, zeroed on every restart,
27
+ never the feed's size, intake, reliability or freshness. The note says so whenever the block is
28
+ present, quoting `cves_ingested`, `poll_count` and `poll_errors`; the description and the
29
+ outputSchema describe the block and each counter. The JSON is still relayed as sent (#2647).
30
+
31
+ ## 2.6.1 — 2026-10-04
32
+
33
+ - Hosted endpoint only: when the production synthetic calls `https://mcp.echelongraph.io/mcp`, the
34
+ endpoint marks its own API calls with a fixed `echelongraph-mcp-synthetic/1.0` token, so the
35
+ synthetic is never counted as hosted use. The client's own text is never passed through. The
36
+ `mcp_request` log line gains `client_public` (#2737).
37
+ - stdio behaviour is unchanged.
38
+
9
39
  ## 2.6.0 — 2026-10-04
10
40
 
11
41
  - `check_sbom` checks up to 2,000 distinct purls per call, instead of refusing more than 200: it
package/README.md CHANGED
@@ -162,7 +162,7 @@ The example questions are ones a client can answer with that tool alone.
162
162
  | `kev_recent` | Recent CISA KEV additions | The CVEs CISA has added to its Known Exploited Vulnerabilities catalog, newest first (`kev_added_date`), from EchelonGraph's copy of the catalog, polled from CISA every 5 minutes: due date, vendor, product, known ransomware use, EchelonGraph's severity, CVSS, EPSS and `eg_kev_tier`, and `our_first_seen_kev`. Filter by date range, ransomware and vendor; page with `limit` and `next_cursor`. Dated by `last_successful_fetch_at`, our last successful fetch of CISA's feed; the filters travel as request headers, never in the URL. | *Which CVEs has CISA added to KEV since 1 September, and which have known ransomware use?* |
163
163
  | `epss_history` | EPSS change history for one CVE | How one CVE's EPSS score has changed, as EchelonGraph recorded it: one point per recorded change (`series_kind` `change_only`), never a daily series, with the value now and `series_starts_at`, when recording began; before it a missing point means not recorded, not unchanged. | *How has CVE-2023-44487's EPSS score changed, as EchelonGraph recorded it?* |
164
164
  | `check_affected` | Am I affected? (product or package at a version) | Whether a product (its NVD CPE product token) or a registry package (`ecosystem` and `package`) at a given version is affected by known CVEs, from the matcher behind echelongraph.io/am-i-affected: `assessed` first (false: not evaluated, with `not_assessed_reason`, and a count of 0 then is not "not affected"), the matching CVEs with `kev_listed`, `ransomware`, `epss_score`, `effective_score` and `score_assessed`, and advisories it cannot decide counted as `undetermined_count`, never as safe. What you look up travels in request headers, never in the URL. | *Is openssl 3.0.0 affected by known CVEs? Is lodash 4.17.15 on npm?* |
165
- | `check_sbom` | Check an SBOM against the advisory corpus | A dependency list checked against EchelonGraph's advisory corpus (OSV.dev records), one verdict per component: `affected`, `not_affected`, `undetermined` or `not_assessed`, each with its `not_assessed_reason`. Pass up to 2,000 distinct purls, or a CycloneDX JSON or SPDX JSON document: the purls are read from it on your machine and only they are sent, in POST bodies of at most 200 each, one after another; the document is not. When the API's per-component rate limit answers 429, the tool waits the `Retry-After` it names, within 50 seconds per call; past that it answers what it has, with the purls not sent counted in `coverage.not_sent`, the reason in `coverage.not_sent_reason`, and the list in `data.not_sent_purls`, never dropped silently. A deb, apk or rpm purl without a `distro` qualifier naming its release is not assessed (`distro_release_unknown`): EchelonGraph does not guess a release. Only `not_affected` is clean. No ranking, no score. | *Check this CycloneDX SBOM against the advisory corpus.* |
165
+ | `check_sbom` | Check an SBOM against the advisory corpus | A dependency list checked against EchelonGraph's advisory corpus (OSV.dev records), one verdict per component: `affected`, `not_affected`, `undetermined` or `not_assessed`, each with its `not_assessed_reason`. Pass up to 2,000 distinct purls, or a CycloneDX JSON or SPDX JSON document: the purls are read from it by the MCP server (on your machine when it runs from npm) and only they are sent to the API, in POST bodies of at most 200 each, one after another; the document is not. When the API's per-component rate limit answers 429, the tool waits the `Retry-After` it names, within 50 seconds per call; past that it answers what it has, with the purls not sent counted in `coverage.not_sent`, the reason in `coverage.not_sent_reason`, and the list in `data.not_sent_purls`, never dropped silently. A deb, apk or rpm purl without a `distro` qualifier naming its release is not assessed (`distro_release_unknown`): EchelonGraph does not guess a release. Only `not_affected` is clean. No ranking, no score. | *Check this CycloneDX SBOM against the advisory corpus.* |
166
166
  | `cve_intel` | CVE weakness, exploits and packages | Weakness, public exploit code, affected packages and fixed versions for one CVE, from EchelonGraph's per-CVE enrichment: `cwes`, `exploits` (at most 10, verified first) with `exploits_total`, `exploits_capped`, `exploits_by_kind` and `exploits_by_status`, `affected_packages`, `fixed_versions` and `timeline`. `verified_status` is the label stored with each reference, not a guarantee that the exploit works. An empty `exploits` list is not evidence that no public exploit exists; a section the API could not read is named in `coverage.sections_failed`, never relayed as an empty list. | *Is there public exploit code for CVE-2021-44228, and which versions fix it?* |
167
167
  | `get_cwe` | CWE and its CVEs | One CWE (weakness class) and the CVEs classified under it: `name` and `description` from the MITRE CWE catalog EchelonGraph embeds, `total`, and one page of 50 `cves`, ordered as `order` states (CISA-KEV-listed first, then EchelonGraph score). A `total` of 0 says that no CVE in EchelonGraph's feed is classified under that CWE, not that none exists. | *Which CVEs are classified under CWE-79, CISA-KEV-listed first?* |
168
168
  | `vendor_advisories_for_cve` | Vendor advisories for one CVE | The vendor-published advisories (Microsoft MSRC, Red Hat, Cisco, Palo Alto Networks, GitHub GHSA and the other feeds EchelonGraph polls) that name one CVE, newest first, at most 20. | *Which vendor advisories name CVE-2024-3400?* |
@@ -630,7 +630,7 @@ Where each tool's input travels:
630
630
  | `search_vendor_advisories`: `query` | The `X-EG-Advisory-Search` request header, never the URL. Its vendor, severity, CVE filter, limit and offset go in the query string. |
631
631
  | `check_affected`: `product`, `version`, `ecosystem`, `package` | The `X-EG-Product`, `X-EG-Version`, `X-EG-Ecosystem` and `X-EG-Package` request headers, never the URL. |
632
632
  | `kev_recent`: `since`, `until`, `ransomware`, `vendor`, `limit`, `cursor` | The `X-EG-Since`, `X-EG-Until`, `X-EG-Ransomware`, `X-EG-Vendor`, `X-EG-Limit` and `X-EG-Cursor` request headers, never the URL. |
633
- | `check_sbom`: `purls`, or an `sbom` document | The purls, in POST bodies of at most 200 each. A CycloneDX or SPDX document is read on your machine and only the purls in it are sent; the document is not. |
633
+ | `check_sbom`: `purls`, or an `sbom` document | The purls, in POST bodies of at most 200 each. A CycloneDX or SPDX document is read on your machine and only the purls in it are sent; the document is not. (Over the hosted endpoint the document goes to EchelonGraph's server instead: see below.) |
634
634
  | `cve_summary`, `exposure_radar` | No input. |
635
635
 
636
636
  The API marks its answers to header-carried lookups `no-store`, so no shared cache keeps them,
@@ -640,7 +640,11 @@ and request logs and trace spans that record URLs do not hold what was looked up
640
640
  server first, which then calls the same API as above. Per request it logs the HTTP method and
641
641
  path, the status, the duration, the body size, the JSON-RPC method and (for `tools/call`) the
642
642
  tool name, the protocol version, whether an `Origin` header was sent, and the family of your
643
- client's User-Agent. It never logs tool arguments or a query string. When a client passes the
643
+ client's User-Agent. It never logs tool arguments or a query string. A `check_sbom` document (or
644
+ an `sbom_review` prompt's) is a tool argument like any other here: it is in the request body, so
645
+ the whole document reaches EchelonGraph's server, which reads the purls from it in memory, sends
646
+ only those to the API, and neither logs nor keeps the document. To keep the document on your
647
+ machine, run the npm package, or pass `check_sbom` the purls. When a client passes the
644
648
  per-client limit it logs that client's address key. It passes your client's public address to
645
649
  the API, in a header the API trusts only with a token it checks, so the API's rate limit counts
646
650
  you and not the hosted service.
@@ -675,7 +679,10 @@ limits: <https://echelongraph.io/pulse/api>.
675
679
  The hosted endpoint adds its own limit before the API's: 120 MCP requests a minute per client
676
680
  address, counted by each server instance, answered over it with HTTP 429, `Retry-After` and a
677
681
  JSON-RPC error (code `-32029`) that names the limit and the seconds to wait. A request body over
678
- 64 KiB is refused.
682
+ 64 KiB is refused (HTTP 413, code `-32600`), except a `tools/call` of `check_sbom` or a
683
+ `prompts/get` of `sbom_review`, which may be up to 6 MiB: enough for a CycloneDX or SPDX document
684
+ at the tool's 5,000,000-character cap. Each server instance reads at most two such large bodies at
685
+ once; a third is answered HTTP 503 with `Retry-After` and a JSON-RPC error (code `-32030`).
679
686
 
680
687
  ## Configuration
681
688
 
@@ -695,7 +702,9 @@ variables above and:
695
702
  |---|---|---|
696
703
  | `PORT` | `8080` | The port it listens on. |
697
704
  | `MCP_RATE_LIMIT_PER_MIN` | `120` | MCP requests a minute per client address, per instance. |
698
- | `MCP_MAX_BODY_BYTES` | `65536` | The largest request body it reads. |
705
+ | `MCP_MAX_BODY_BYTES` | `65536` | The largest request body it reads, except for an SBOM (below). |
706
+ | `MCP_MAX_SBOM_BODY_BYTES` | `6291456` | The largest body of a `tools/call` of `check_sbom` or a `prompts/get` of `sbom_review`. |
707
+ | `MCP_LARGE_BODY_SLOTS` | `2` | How many bodies over `MCP_MAX_BODY_BYTES` it reads and serves at once; past that, 503 with `Retry-After`. |
699
708
  | `ECHELONGRAPH_FORWARD_TOKEN`, `ECHELONGRAPH_FORWARD_HOST`, `MCP_REQUIRE_FORWARD_TOKEN` | unset | EchelonGraph's hosted deployment only: the token with which it names each client to the API. Leave them unset when you run it yourself; the API then counts every request as coming from your server. |
700
709
 
701
710
  ## Develop
package/dist/http.js CHANGED
@@ -23,8 +23,8 @@
23
23
  // token fatal at start-up, because without it every remote user would share one API budget.
24
24
  import http from "node:http";
25
25
  import { createMcpHandler } from "@modelcontextprotocol/server";
26
- import { forwardToken, markHttpEntrypoint, withClient } from "./runtime.js";
27
- import { BODY_TOO_LARGE_CODE, DEFAULT_RATE_LIMIT_PER_MIN, FixedWindowLimiter, ORIGIN_REFUSED_CODE, RATE_LIMITED_CODE, bodyFacts, originVerdict, resolveClient, revisionLabel, rpcError, uaFamily, } from "./httpPolicy.js";
26
+ import { forwardToken, markHttpEntrypoint, SYNTHETIC_UA_FAMILY, withClient } from "./runtime.js";
27
+ import { BODY_TOO_LARGE_CODE, BUSY_CODE, DEFAULT_LARGE_BODY_SLOTS, DEFAULT_MAX_BODY_BYTES, DEFAULT_MAX_SBOM_BODY_BYTES, DEFAULT_RATE_LIMIT_PER_MIN, LARGE_BODY_TARGETS_TEXT, Slots, FixedWindowLimiter, ORIGIN_REFUSED_CODE, RATE_LIMITED_CODE, bodyFacts, originVerdict, resolveClient, revisionLabel, rpcError, isLargeBodyTarget, largeBodyAdmissible, uaFamily, } from "./httpPolicy.js";
28
28
  // Must run before index.ts is evaluated: it is how index.ts knows not to start stdio.
29
29
  markHttpEntrypoint();
30
30
  const { createServer, NAME, VERSION, SHOWN_BASE, TIMEOUT_MS } = await import("./index.js");
@@ -37,9 +37,12 @@ const PORT = (() => {
37
37
  return Number.isInteger(n) && n >= 0 && n < 65536 && process.env.PORT !== "" && process.env.PORT !== undefined ? n : 8080;
38
38
  })();
39
39
  const RATE_LIMIT_PER_MIN = positiveInt(process.env.MCP_RATE_LIMIT_PER_MIN, DEFAULT_RATE_LIMIT_PER_MIN);
40
- // Tool arguments are a CVE ID or a short search; 64 KiB is far above any real request and far
41
- // below the SDK's 4 MiB default.
42
- const MAX_BODY_BYTES = positiveInt(process.env.MCP_MAX_BODY_BYTES, 64 * 1024);
40
+ // The body caps and the large-body slots (#2747): see httpPolicy.ts "Request-body caps".
41
+ const MAX_BODY_BYTES = positiveInt(process.env.MCP_MAX_BODY_BYTES, DEFAULT_MAX_BODY_BYTES);
42
+ const MAX_SBOM_BODY_BYTES = Math.max(MAX_BODY_BYTES, positiveInt(process.env.MCP_MAX_SBOM_BODY_BYTES, DEFAULT_MAX_SBOM_BODY_BYTES));
43
+ const LARGE_BODY_SLOTS = positiveInt(process.env.MCP_LARGE_BODY_SLOTS, DEFAULT_LARGE_BODY_SLOTS);
44
+ // A large body's wait for a slot is not queued: the client is told to come back.
45
+ const BUSY_RETRY_AFTER_SEC = 5;
43
46
  const MCP_PATH = "/mcp";
44
47
  const HEALTH_PATH = "/health";
45
48
  // One JSON object per line on stdout: Cloud Logging reads `severity` and `message`.
@@ -57,11 +60,13 @@ const handler = createMcpHandler(createServer, {
57
60
  // responseMode stays the SDK default, "auto": a modern request is answered with one JSON body
58
61
  // unless a handler emits something before its result (none of these tools does). The legacy
59
62
  // leg answers over SSE, as a 2025-era streamable HTTP server does.
60
- maxRequestBodySize: MAX_BODY_BYTES,
63
+ // The largest body readBody can hand on; which request may be that large is decided here first.
64
+ maxRequestBodySize: MAX_SBOM_BODY_BYTES,
61
65
  // Names only: an SDK error message can quote what the client sent.
62
66
  onerror: (e) => log("WARNING", "mcp_handler_error", { error_name: e.name }),
63
67
  });
64
68
  const limiter = new FixedWindowLimiter(RATE_LIMIT_PER_MIN);
69
+ const largeBodies = new Slots(LARGE_BODY_SLOTS);
65
70
  // CORS for a browser client on an https Origin (the Origin policy has already admitted it).
66
71
  // A wildcard is safe here because nothing is credentialed: no cookies, no auth.
67
72
  const CORS_HEADERS = {
@@ -79,39 +84,69 @@ function send(res, status, body, headers = {}) {
79
84
  res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store", ...headers });
80
85
  res.end(body);
81
86
  }
82
- // Reads the body up to MAX_BODY_BYTES. Over the cap: "too_large", and the rest is discarded
83
- // unread (the response closes the connection).
84
87
  function readBody(req) {
88
+ const mayBeLarge = largeBodyAdmissible(header(req, "mcp-method"), header(req, "mcp-name"));
89
+ const cap = mayBeLarge ? MAX_SBOM_BODY_BYTES : MAX_BODY_BYTES;
85
90
  const declared = Number(req.headers["content-length"]);
86
- if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
91
+ if (Number.isFinite(declared) && declared > cap) {
87
92
  req.resume();
88
93
  return Promise.resolve("too_large");
89
94
  }
95
+ let slot = false;
96
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
97
+ if (!largeBodies.tryAcquire()) {
98
+ req.resume();
99
+ return Promise.resolve("busy");
100
+ }
101
+ slot = true;
102
+ }
90
103
  return new Promise((resolve, reject) => {
91
104
  const chunks = [];
92
105
  let size = 0;
93
106
  let done = false;
107
+ const stop = (r) => {
108
+ done = true;
109
+ chunks.length = 0;
110
+ if (slot)
111
+ largeBodies.release();
112
+ slot = false;
113
+ resolve(r);
114
+ };
94
115
  req.on("data", (chunk) => {
95
116
  if (done)
96
117
  return;
97
118
  size += chunk.length;
98
- if (size > MAX_BODY_BYTES) {
99
- done = true;
100
- resolve("too_large");
101
- return;
119
+ if (size > cap)
120
+ return stop("too_large");
121
+ if (size > MAX_BODY_BYTES && !slot) {
122
+ if (!largeBodies.tryAcquire())
123
+ return stop("busy");
124
+ slot = true;
102
125
  }
103
126
  chunks.push(chunk);
104
127
  });
105
128
  req.on("end", () => {
106
129
  if (!done)
107
- resolve(Buffer.concat(chunks));
130
+ resolve({ body: Buffer.concat(chunks), slot });
108
131
  });
109
132
  req.on("error", (e) => {
110
- if (!done)
111
- reject(e);
133
+ if (done)
134
+ return;
135
+ done = true;
136
+ if (slot)
137
+ largeBodies.release();
138
+ reject(e);
112
139
  });
113
140
  });
114
141
  }
142
+ function header(req, name) {
143
+ const v = req.headers[name];
144
+ return Array.isArray(v) ? v.join(", ") : v;
145
+ }
146
+ function tooLarge(res, cors) {
147
+ send(res, 413, rpcError(BODY_TOO_LARGE_CODE, `Request body too large: at most ${MAX_BODY_BYTES} bytes, or ${MAX_SBOM_BODY_BYTES} bytes for ${LARGE_BODY_TARGETS_TEXT}. A larger SBOM can be passed to check_sbom as its purls.`, { max_bytes: MAX_BODY_BYTES, max_bytes_sbom: MAX_SBOM_BODY_BYTES }), { ...cors, Connection: "close" });
148
+ return { status: 413, refused: "body_size" };
149
+ }
115
150
  async function serveMcp(req, res, pathname) {
116
151
  const method = req.method ?? "GET";
117
152
  const origin = req.headers.origin;
@@ -135,14 +170,39 @@ async function serveMcp(req, res, pathname) {
135
170
  return { status: 429, throttled: true };
136
171
  }
137
172
  let body;
173
+ let facts;
174
+ let slot = false;
138
175
  if (method === "POST") {
139
176
  const read = await readBody(req);
140
- if (read === "too_large") {
141
- send(res, 413, rpcError(BODY_TOO_LARGE_CODE, `Request body too large: at most ${MAX_BODY_BYTES} bytes.`), { ...cors, Connection: "close" });
142
- return { status: 413, refused: "body_size" };
177
+ if (read === "too_large")
178
+ return tooLarge(res, cors);
179
+ if (read === "busy") {
180
+ send(res, 503, rpcError(BUSY_CODE, `Server busy: this instance is already reading its ${LARGE_BODY_SLOTS} large request bodies at once. Retry after ${BUSY_RETRY_AFTER_SEC} s, or pass check_sbom the SBOM's purls.`, { retry_after_seconds: BUSY_RETRY_AFTER_SEC }), { ...cors, "Retry-After": String(BUSY_RETRY_AFTER_SEC), Connection: "close" });
181
+ log("WARNING", "mcp_large_body_busy", { slots: LARGE_BODY_SLOTS });
182
+ return { status: 503, refused: "large_body_busy" };
183
+ }
184
+ body = read.body;
185
+ slot = read.slot;
186
+ facts = bodyFacts(body);
187
+ // Past the general cap, the body itself must be what the headers (if any) said: one of
188
+ // LARGE_BODY_TARGETS. A 2025-era client sends no Mcp-Method, so this is where its large body
189
+ // is held to that.
190
+ if (body.length > MAX_BODY_BYTES && !isLargeBodyTarget(facts)) {
191
+ if (slot)
192
+ largeBodies.release();
193
+ return { ...tooLarge(res, cors), body, facts };
143
194
  }
144
- body = read;
145
195
  }
196
+ try {
197
+ return { ...(await relay(req, res, pathname, method, cors, client, body)), facts };
198
+ }
199
+ finally {
200
+ if (slot)
201
+ largeBodies.release();
202
+ }
203
+ }
204
+ // Hands one admitted request to the SDK's handler and streams its answer back.
205
+ async function relay(req, res, pathname, method, cors, client, body) {
146
206
  const headers = new Headers();
147
207
  for (const [k, v] of Object.entries(req.headers)) {
148
208
  if (v === undefined || HOP_BY_HOP.has(k))
@@ -158,7 +218,11 @@ async function serveMcp(req, res, pathname) {
158
218
  body: body && body.length > 0 ? new Uint8Array(body) : undefined,
159
219
  signal: abort.signal,
160
220
  });
161
- const response = await withClient(client.forwardAddress, () => handler.fetch(request));
221
+ // #2737: the production synthetic names itself by its user-agent family; its API calls then
222
+ // carry the synthetic's fixed token (runtime.ts upstreamUserAgent), so they are never counted
223
+ // as hosted adoption.
224
+ const synthetic = uaFamily(req.headers["user-agent"]) === SYNTHETIC_UA_FAMILY;
225
+ const response = await withClient(client.forwardAddress, () => handler.fetch(request), { synthetic });
162
226
  const out = { ...cors };
163
227
  response.headers.forEach((v, k) => {
164
228
  out[k] = v;
@@ -169,7 +233,7 @@ async function serveMcp(req, res, pathname) {
169
233
  res.write(chunk);
170
234
  }
171
235
  res.end();
172
- return { status: response.status, body };
236
+ return { status: response.status, body, clientPublic: client.forwardAddress !== undefined };
173
237
  }
174
238
  const server = http.createServer((req, res) => {
175
239
  const start = process.hrtime.bigint();
@@ -183,7 +247,7 @@ const server = http.createServer((req, res) => {
183
247
  }
184
248
  const method = req.method ?? "GET";
185
249
  const finish = (o) => {
186
- const facts = o.body ? bodyFacts(o.body) : { rpc_method: "(none)" };
250
+ const facts = o.facts ?? (o.body ? bodyFacts(o.body) : { rpc_method: "(none)" });
187
251
  log("INFO", "mcp_request", {
188
252
  method,
189
253
  path: pathname === MCP_PATH || pathname === HEALTH_PATH ? pathname : "(other)",
@@ -196,6 +260,7 @@ const server = http.createServer((req, res) => {
196
260
  throttled: o.throttled === true,
197
261
  refused: o.refused ?? "",
198
262
  ua_family: uaFamily(req.headers["user-agent"]),
263
+ client_public: o.clientPublic === true,
199
264
  });
200
265
  };
201
266
  if (pathname === HEALTH_PATH) {
@@ -230,6 +295,8 @@ server.listen(PORT, () => {
230
295
  forward_token_configured: forwardToken() !== "",
231
296
  rate_limit_per_min: RATE_LIMIT_PER_MIN,
232
297
  max_body_bytes: MAX_BODY_BYTES,
298
+ max_sbom_body_bytes: MAX_SBOM_BODY_BYTES,
299
+ large_body_slots: LARGE_BODY_SLOTS,
233
300
  });
234
301
  });
235
302
  const shutdown = () => {
@@ -10,6 +10,7 @@ import { isIP } from "node:net";
10
10
  export const ORIGIN_REFUSED_CODE = -32000; // the SDK's own code for an Origin refusal (originValidationResponse)
11
11
  export const RATE_LIMITED_CODE = -32029; // implementation-defined server error range (-32000..-32099); "429"
12
12
  export const BODY_TOO_LARGE_CODE = -32600; // Invalid Request
13
+ export const BUSY_CODE = -32030; // implementation-defined server error range; "503", no large-body slot free (#2747)
13
14
  export function rpcError(code, message, data) {
14
15
  return JSON.stringify({ jsonrpc: "2.0", id: null, error: { code, message, ...(data ? { data } : {}) } });
15
16
  }
@@ -198,6 +199,80 @@ export class FixedWindowLimiter {
198
199
  }
199
200
  }
200
201
  }
202
+ // ── Request-body caps (#2747) ──
203
+ //
204
+ // Two caps, because one tool's input is a document and every other tool's is a CVE ID or a short
205
+ // search.
206
+ //
207
+ // - 64 KiB for every request: far above any real request but an SBOM's, and the cap the
208
+ // endpoint shipped with (#2316).
209
+ // - 6 MiB for one tools/call of check_sbom, or one prompts/get of sbom_review, which takes the
210
+ // same document to hand to check_sbom (LARGE_BODY_TARGETS). The sbom argument is up to MAX_SBOM_CHARS
211
+ // (5,000,000) characters, and over this endpoint that document IS the JSON-RPC body: JSON
212
+ // text passed as a string is escaped once more (every '"' and newline doubles), which for a
213
+ // pretty-printed CycloneDX document is about 10% (a 4,998,809-character one is a 5,470,320-byte
214
+ // body; test/http.test.mjs holds the cap above it). Below Cloud Run's 32 MiB HTTP/1 request
215
+ // limit. OWASP Juice Shop 11.1.2's 0.74 MB SBOM, which the 64 KiB cap refused, fits eight
216
+ // times over; a 2,000-component one in the same shape is a ~2 MB body.
217
+ //
218
+ // Which request may be large is decided before it is read where the client says so (a
219
+ // 2026-07-28 client sends Mcp-Method and Mcp-Name; another value is held to 64 KiB without
220
+ // reading on), and from the body itself otherwise: a body past 64 KiB that is not one of
221
+ // LARGE_BODY_TARGETS is refused 413 once read.
222
+ //
223
+ // Memory, not the cap, is what bounds this. An instance has 256 MiB and serves up to 250
224
+ // requests at once (deploy-all.sh SVC_MEMORY / SVC_CONCURRENCY); a check_sbom call holds the raw
225
+ // body, its JSON parse here, the SDK's own parse, the tool's parse of the document and the answer.
226
+ // Measured 2026-10-04 on dist/http.js, Node 22, peak RSS (VmHWM) over an idle ~80 MB: one
227
+ // 2,000-purl ~2 MB body +62 MB; one 5.47 MB body +76 MB; two 5.47 MB bodies at once 209 MB in
228
+ // all, also with V8's old space held to 64 MB: ~59 MB of 256 MiB left for everything else, so
229
+ // not a third. So a body past 64 KiB must hold one of DEFAULT_LARGE_BODY_SLOTS (2) slots per
230
+ // instance for as long as it is read and served; with none free it is answered 503 with
231
+ // Retry-After, never queued. The per-client limit still counts each large request as one
232
+ // request, before its body is read.
233
+ export const DEFAULT_MAX_BODY_BYTES = 64 * 1024;
234
+ export const DEFAULT_MAX_SBOM_BODY_BYTES = 6 * 1024 * 1024;
235
+ export const DEFAULT_LARGE_BODY_SLOTS = 2;
236
+ /** The requests whose body may pass DEFAULT_MAX_BODY_BYTES: JSON-RPC method → the one tool or prompt name. */
237
+ export const LARGE_BODY_TARGETS = { "tools/call": "check_sbom", "prompts/get": "sbom_review" };
238
+ /** LARGE_BODY_TARGETS in words, for the refusals. */
239
+ export const LARGE_BODY_TARGETS_TEXT = Object.entries(LARGE_BODY_TARGETS)
240
+ .map(([m, n]) => `a ${m} of ${n}`)
241
+ .join(" or ");
242
+ /** Whether a request's own Mcp-Method / Mcp-Name headers leave it free to be one of LARGE_BODY_TARGETS. Absent headers do (a 2025-era client sends neither). */
243
+ export function largeBodyAdmissible(mcpMethod, mcpName) {
244
+ const name = mcpName?.trim();
245
+ if (mcpMethod === undefined)
246
+ return name === undefined || Object.values(LARGE_BODY_TARGETS).includes(name);
247
+ const want = Object.hasOwn(LARGE_BODY_TARGETS, mcpMethod.trim()) ? LARGE_BODY_TARGETS[mcpMethod.trim()] : undefined;
248
+ return want !== undefined && (name === undefined || name === want);
249
+ }
250
+ /** Whether a read body is one of LARGE_BODY_TARGETS, by its bodyFacts. */
251
+ export function isLargeBodyTarget(facts) {
252
+ const name = facts.rpc_method === "tools/call" ? facts.tool : facts.rpc_method === "prompts/get" ? facts.prompt : undefined;
253
+ return name !== undefined && Object.hasOwn(LARGE_BODY_TARGETS, facts.rpc_method) && LARGE_BODY_TARGETS[facts.rpc_method] === name;
254
+ }
255
+ /** A counting semaphore that never waits: tryAcquire answers at once. */
256
+ export class Slots {
257
+ max;
258
+ used = 0;
259
+ constructor(max) {
260
+ this.max = max;
261
+ }
262
+ tryAcquire() {
263
+ if (this.used >= this.max)
264
+ return false;
265
+ this.used++;
266
+ return true;
267
+ }
268
+ release() {
269
+ if (this.used > 0)
270
+ this.used--;
271
+ }
272
+ get inUse() {
273
+ return this.used;
274
+ }
275
+ }
201
276
  // ── Bounded access-log fields ──
202
277
  //
203
278
  // The access line carries no body, no query string, no argument and no client address: only
@@ -206,7 +281,7 @@ const RPC_METHOD = /^[a-z][a-z/_]{0,39}$/i;
206
281
  const TOOL_NAME = /^[a-z][a-z0-9_]{0,39}$/;
207
282
  const REVISION = /^\d{4}-\d{2}-\d{2}$/;
208
283
  const UA_FAMILY = /^[A-Za-z0-9._-]{1,40}$/;
209
- /** The JSON-RPC method and, for tools/call, the tool name, shape-checked; never anything else from the body. */
284
+ /** The JSON-RPC method and, for tools/call, the tool name (for prompts/get, the prompt name), shape-checked; never anything else from the body. */
210
285
  export function bodyFacts(body) {
211
286
  if (!body || body.length === 0)
212
287
  return { rpc_method: "(none)" };
@@ -226,10 +301,11 @@ export function bodyFacts(body) {
226
301
  return { rpc_method: "(response)" };
227
302
  if (!RPC_METHOD.test(m))
228
303
  return { rpc_method: "(other)" };
229
- if (m !== "tools/call")
304
+ if (m !== "tools/call" && m !== "prompts/get")
230
305
  return { rpc_method: m };
231
306
  const name = parsed.params?.name;
232
- return { rpc_method: m, tool: typeof name === "string" && TOOL_NAME.test(name) ? name : "(other)" };
307
+ const shaped = typeof name === "string" && TOOL_NAME.test(name) ? name : "(other)";
308
+ return m === "tools/call" ? { rpc_method: m, tool: shaped } : { rpc_method: m, prompt: shaped };
233
309
  }
234
310
  export function revisionLabel(v) {
235
311
  if (v === undefined)
package/dist/index.js CHANGED
@@ -52,7 +52,7 @@ import { registerCheckAffected } from "./tools/check_affected.js";
52
52
  import { registerCheckSbom } from "./tools/check_sbom.js";
53
53
  import { registerCveIntel } from "./tools/cve_intel.js";
54
54
  import { registerGetCwe } from "./tools/get_cwe.js";
55
- import { isHttpEntrypoint, upstreamHeaders } from "./runtime.js";
55
+ import { isHttpEntrypoint, upstreamHeaders, upstreamUserAgent } from "./runtime.js";
56
56
  import { registerVendorAdvisoryTools } from "./tools/vendor_advisories.js";
57
57
  import { registerPrompts } from "./prompts.js";
58
58
  import { registerResources } from "./resources.js";
@@ -186,8 +186,9 @@ export async function api(path, init) {
186
186
  method: init?.method,
187
187
  body: init?.body,
188
188
  // upstreamHeaders: empty on stdio; on the hosted endpoint, the forward token and the
189
- // end client's address (runtime.ts, #2316).
190
- headers: { ...upstreamHeaders(`${BASE}${path}`), ...(init?.headers ?? {}), "User-Agent": UA, Accept: "application/json" },
189
+ // end client's address (runtime.ts, #2316). upstreamUserAgent: UA, unless the hosted
190
+ // endpoint is answering the production synthetic (#2737).
191
+ headers: { ...upstreamHeaders(`${BASE}${path}`), ...(init?.headers ?? {}), "User-Agent": upstreamUserAgent(UA), Accept: "application/json" },
191
192
  signal: ctrl.signal,
192
193
  });
193
194
  }
@@ -377,7 +378,8 @@ export const realInstant = (s) => s !== undefined && Date.parse(s) > 0;
377
378
  // get_cve, and none for a search page, whose rows each carry their own. None of them serves a
378
379
  // fleet-wide time at which the feed's pollers last completed, so freshness is null: the
379
380
  // summary's poller.last_poll_at is the answering instance's own NVD poller (cve/poller.go
380
- // Stats, in memory), and is relayed in data but not presented as the feed's freshness.
381
+ // Stats, in memory), and is relayed in data but not presented as the feed's freshness; the note
382
+ // says what the whole poller block is (summaryPollerNote, #2647).
381
383
  const CVE_FEED_METHOD = "EchelonGraph's CVE Pulse feed: CVE records compiled from NVD, MITRE-CNA pre-NVD records, CISA-KEV, EPSS and GitHub GHSA, each polled from its source on a schedule.";
382
384
  const NO_FEED_FRESHNESS = "freshness is null: the CVE feed's answer carries no time at which its pollers last completed a poll for the feed as a whole.";
383
385
  function scoreStateOf(rec) {
@@ -494,6 +496,29 @@ function summaryRejectedNote(d) {
494
496
  return "";
495
497
  return ` summary.rejected (${rejected}) counts CVE records rejected (withdrawn) by their numbering authority, not active CVEs: report them as withdrawn records, never as vulnerabilities.`;
496
498
  }
499
+ // ── poller, one instance's counters (#2647) ──
500
+ //
501
+ // The summary answer carries a poller block beside summary: cves_ingested, cves_skipped,
502
+ // http_retries, interval, last_poll_at, last_poll_dur_ms, poll_count and poll_errors. They are
503
+ // core-backend cve/poller.go Stats: the in-memory counters of the NVD poller of the ONE API
504
+ // instance that answered, since that instance last started, and every restart zeroes them. So
505
+ // cves_ingested 7195 (production, 2026-09-30T10:02:13Z) is what that instance ingested since it
506
+ // started, not the feed's size or intake, and poll_errors is not the feed's reliability. Through
507
+ // 2.6.1 the tool relayed the block with no word about it. The JSON is still relayed as sent; the
508
+ // outputSchema describes the block, and whenever the answer carries one the note says what it is,
509
+ // with the counters a model would quote, and an answer without it gets no sentence.
510
+ const POLLER_QUOTED = ["cves_ingested", "poll_count", "poll_errors"];
511
+ function summaryPollerNote(d) {
512
+ const poller = field(d, "poller");
513
+ if (!isPlainObject(poller) || Object.keys(poller).length === 0)
514
+ return "";
515
+ const quoted = POLLER_QUOTED.flatMap((k) => {
516
+ const n = numAt(poller, k);
517
+ return n === undefined ? [] : [`poller.${k} ${n}`];
518
+ });
519
+ const counters = quoted.length > 0 ? ` (${quoted.join(", ")})` : "";
520
+ return ` poller is one API instance's NVD poller, counted in that instance's memory since it last started and zeroed on every restart: report its counters${counters} as that instance's, never as the feed's size, intake or reliability, and poller.last_poll_at as that instance's last poll, never as the feed's freshness.`;
521
+ }
497
522
  async function cveSummary() {
498
523
  const tool = "cve_summary";
499
524
  try {
@@ -516,8 +541,9 @@ async function cveSummary() {
516
541
  ],
517
542
  };
518
543
  const total = numAt(r.data, "summary", "total");
519
- // What summary.none (#2610), the NVD histogram and summary.rejected (#2641) count.
520
- const labels = `${summaryNoneNote(r.data)}${summaryNVDNote(r.data)}${summaryRejectedNote(r.data)}`;
544
+ // What summary.none (#2610), the NVD histogram and summary.rejected (#2641) count, and what
545
+ // the poller block is (#2647).
546
+ const labels = `${summaryNoneNote(r.data)}${summaryNVDNote(r.data)}${summaryRejectedNote(r.data)}${summaryPollerNote(r.data)}`;
521
547
  if (total === undefined)
522
548
  return succeeded(r.data, `${head}${labels}`, env);
523
549
  if (total === 0) {
@@ -1772,6 +1798,10 @@ const CVERecord = z.looseObject({
1772
1798
  modified: opt(z.string()),
1773
1799
  updated_at: opt(z.string()),
1774
1800
  });
1801
+ // #2647: what cve_summary says the poller block is (summaryPollerNote). Like the #2641 strings
1802
+ // beside SUMMARY_NONE_DESCRIPTION, it uses none of #2610's six words ("any", "not", "rating",
1803
+ // "scored", "source", "yet"). Declared here, before the outputSchema that describes poller with it.
1804
+ const SUMMARY_POLLER_DESCRIPTION = "poller holds the in-memory counters of the NVD poller of the one API instance that answered (cves_ingested, cves_skipped, http_retries, poll_count, poll_errors, last_poll_at, last_poll_dur_ms, interval), counted since that instance last started and zeroed on every restart: they describe that instance, never the feed's size, intake, reliability or freshness. Whenever the answer carries poller the note says so.";
1775
1805
  // One of the NVD histogram's four labelled buckets (#2641).
1776
1806
  const nvdBand = (label) => opt(z.number()).describe(`Of the active CVEs total counts, those whose NVD CVSS severity label is ${label}: NVD's label, as provenance, never EchelonGraph's severity band.`);
1777
1807
  const CVE_SUMMARY_OUTPUT = envelopeSchema({
@@ -1797,6 +1827,21 @@ const CVE_SUMMARY_OUTPUT = envelopeSchema({
1797
1827
  last_updated: opt(z.string()),
1798
1828
  })
1799
1829
  .optional(),
1830
+ // #2647: one instance's counters, never the feed's (see summaryPollerNote).
1831
+ poller: z
1832
+ .looseObject({
1833
+ cves_ingested: opt(z.number()).describe("CVE records this instance's NVD poller wrote since the instance last started: never the feed's size or intake."),
1834
+ cves_skipped: opt(z.number()).describe("CVE records this instance's NVD poller skipped since the instance last started."),
1835
+ http_retries: opt(z.number()).describe("HTTP retries this instance's NVD poller made since the instance last started."),
1836
+ interval: opt(z.string()).describe("How often this instance's NVD poller polls."),
1837
+ last_poll_at: opt(z.string()).describe("When this instance's NVD poller last polled: never the feed's freshness."),
1838
+ last_poll_dur_ms: opt(z.number()).describe("How long that poll took, in milliseconds."),
1839
+ poll_count: opt(z.number()).describe("Polls this instance's NVD poller made since the instance last started."),
1840
+ poll_errors: opt(z.number()).describe("Polls of this instance's NVD poller that failed since the instance last started: never the feed's reliability."),
1841
+ })
1842
+ .nullable()
1843
+ .optional()
1844
+ .describe(SUMMARY_POLLER_DESCRIPTION),
1800
1845
  }),
1801
1846
  coverage: null,
1802
1847
  freshness: null,
@@ -1981,7 +2026,7 @@ export function createServer() {
1981
2026
  const server = new McpServer({ name: NAME, version: VERSION }, { instructions: INSTRUCTIONS, capabilities: { tools: { listChanged: false }, prompts: { listChanged: false }, resources: { listChanged: false } } });
1982
2027
  server.registerTool("cve_summary", {
1983
2028
  title: "CVE feed summary",
1984
- 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} The feed is polled from its sources on a schedule, so this is the state as of that update. ${FEED_ENVELOPE}`,
2029
+ 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_DESCRIPTION} The feed is polled from its sources on a schedule, so this is the state as of that update. ${FEED_ENVELOPE}`,
1985
2030
  outputSchema: CVE_SUMMARY_OUTPUT,
1986
2031
  annotations: ANNOTATIONS,
1987
2032
  }, async () => checked("cve_summary", CVE_SUMMARY_OUTPUT, await cveSummary()));
package/dist/runtime.js CHANGED
@@ -22,8 +22,25 @@ export function isHttpEntrypoint() {
22
22
  // withClient(), and the SDK dispatches the tool handler inside that same async context, so
23
23
  // api() can read it without the tool module passing it through every function.
24
24
  const clientStore = new AsyncLocalStorage();
25
- export function withClient(clientIp, fn) {
26
- return clientStore.run({ clientIp }, fn);
25
+ export function withClient(clientIp, fn, opts = {}) {
26
+ return clientStore.run({ clientIp, synthetic: opts.synthetic === true }, fn);
27
+ }
28
+ // #2737: the production MCP synthetic probes this endpoint too, and its calls must reach the API
29
+ // under its own family, never as hosted MCP adoption. The synthetic names itself to this service
30
+ // with this family as its User-Agent's leading token; http.ts marks such a request (synthetic:
31
+ // true) and upstreamUserAgent() then puts this FIXED token ahead of the service's own user-agent,
32
+ // exactly as ECHELONGRAPH_MCP_UA does for the npm package. The client's text is never relayed: a
33
+ // request either carries this one constant or nothing. Anyone can claim the family, as anyone can
34
+ // send it to the API directly; the consequence is an undercounted adoption number, never a bypass
35
+ // (core-backend: user-agent classification is for counting only).
36
+ export const SYNTHETIC_UA_FAMILY = "echelongraph-mcp-synthetic";
37
+ export const SYNTHETIC_UA_TOKEN = `${SYNTHETIC_UA_FAMILY}/1.0`;
38
+ /** The User-Agent for one API call: `ua`, led by the synthetic's token when the request is the synthetic's. */
39
+ export function upstreamUserAgent(ua) {
40
+ if (!httpEntrypoint || clientStore.getStore()?.synthetic !== true)
41
+ return ua;
42
+ const lead = ua.split(/[/\s]/, 1)[0];
43
+ return lead === SYNTHETIC_UA_FAMILY ? ua : `${SYNTHETIC_UA_TOKEN} ${ua}`;
27
44
  }
28
45
  /** The header that carries the forward token, and the one that names the client (core-backend waf/mcpforward.go). */
29
46
  export const FORWARD_TOKEN_HEADER = "X-EG-MCP-Forward";
@@ -44,7 +44,7 @@ const CLEAN_NOTE = "Only not_affected is a clean verdict, and only for the advis
44
44
  const MEASURED_AT_NOTE = "measured_at is null: the corpus rows are read through a cache whose maximum age the answer states (summary.corpus_cache_max_age_ms), so the answer gives no single time at which they were read.";
45
45
  const FRESHNESS_NOTE = "freshness is null: the answer carries no time at which the advisory corpus was last refreshed.";
46
46
  export const CHECK_SBOM_TITLE = "Check an SBOM against the advisory corpus";
47
- export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGraph's advisory corpus, one verdict per component. Pass purls (package URLs, up to 2,000 distinct) or sbom (a CycloneDX JSON or SPDX JSON document, as JSON text or as an object, up to 5,000,000 characters). The purls are read from the document on this machine and only they are sent, in POST bodies of at most 200 purls each, one after another, never in a URL; the document itself is not sent. A component without a purl is counted and not checked. Each purl is mapped to its OSV ecosystem, package name and version and matched against the affected version ranges EchelonGraph holds from OSV.dev advisory records; there is no ranking and no score. data.results holds one row per component sent, in order (index counts across batches), with verdict (affected, not_affected, undetermined or not_assessed), assessed, not_assessed_reason, cve_ids, matches, count, not_affected_count and undetermined_count; data.summary counts the verdicts, summed over the batches (partial is true when any batch's was). not_affected is the only clean verdict. undetermined: advisories name the package but at least one could not be decided at this version and none matched. not_assessed: no verdict at all, because the package is not in the corpus, the purl type has no OSV ecosystem, a deb, apk or rpm purl carries no distro qualifier naming its release (EchelonGraph does not guess one), the version is missing, the lookup failed, or the batch's time budget ran out first (time_budget). Neither undetermined nor not_assessed is clean, and the note gives their counts. The API allows 1,200 components a minute per caller; when it answers 429 with Retry-After, the tool waits as asked and sends the batch again, within 50 seconds per call. When the next wait would pass that, or a batch after the first fails, the tool stops and answers what it has: coverage.not_sent counts the purls not sent and coverage.not_sent_reason says why (time_budget, rate_limited or request_failed), data.not_sent_purls lists them for a later call, and they are not checked and not clean. A list or document with more than 2,000 distinct purls is refused, not truncated: split it. Its structured result carries state (measured when at least one component got a verdict, else not_assessed), measured_at (null: the corpus is read through a cache, so no single read time exists), method, coverage (what the input held, what was sent in how many batches, and what was not sent and why), freshness (null) and notes, with data equal to the API's JSON (for more than one batch, the batches' answers merged); the result's last text block repeats it without data (the first text block) and without the note's sentences (the text block before it), with which notes ends.";
47
+ export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGraph's advisory corpus, one verdict per component. Pass purls (package URLs, up to 2,000 distinct) or sbom (a CycloneDX JSON or SPDX JSON document, as JSON text or as an object, 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, one after another, 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. A component without a purl is counted and not checked. Each purl is mapped to its OSV ecosystem, package name and version and matched against the affected version ranges EchelonGraph holds from OSV.dev advisory records; there is no ranking and no score. data.results holds one row per component sent, in order (index counts across batches), with verdict (affected, not_affected, undetermined or not_assessed), assessed, not_assessed_reason, cve_ids, matches, count, not_affected_count and undetermined_count; data.summary counts the verdicts, summed over the batches (partial is true when any batch's was). not_affected is the only clean verdict. undetermined: advisories name the package but at least one could not be decided at this version and none matched. not_assessed: no verdict at all, because the package is not in the corpus, the purl type has no OSV ecosystem, a deb, apk or rpm purl carries no distro qualifier naming its release (EchelonGraph does not guess one), the version is missing, the lookup failed, or the batch's time budget ran out first (time_budget). Neither undetermined nor not_assessed is clean, and the note gives their counts. The API allows 1,200 components a minute per caller; when it answers 429 with Retry-After, the tool waits as asked and sends the batch again, within 50 seconds per call. When the next wait would pass that, or a batch after the first fails, the tool stops and answers what it has: coverage.not_sent counts the purls not sent and coverage.not_sent_reason says why (time_budget, rate_limited or request_failed), data.not_sent_purls lists them for a later call, and they are not checked and not clean. A list or document with more than 2,000 distinct purls is refused, not truncated: split it. Its structured result carries state (measured when at least one component got a verdict, else not_assessed), measured_at (null: the corpus is read through a cache, so no single read time exists), method, coverage (what the input held, what was sent in how many batches, and what was not sent and why), freshness (null) and notes, with data equal to the API's JSON (for more than one batch, the batches' answers merged); the result's last text block repeats it without data (the first text block) and without the note's sentences (the text block before it), with which notes ends.";
48
48
  const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
49
49
  function dedupe(all) {
50
50
  const seen = new Set();
@@ -6,7 +6,7 @@ const MAX_VENDOR = 128;
6
6
  const MAX_CURSOR = 128;
7
7
  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.";
8
8
  const KEV_RECENT_COVERAGE = "CISA KEV catalog";
9
- 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; an RFC 3339 timestamp, such as the kev_added_date 2024-04-12T00:00:00Z that CVE records carry, is read as its UTC date, and anything else is refused), 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. Rows within one date are ordered by cve_id. 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 and shortDescription are not returned. Its structured result carries state (measured), measured_at (our last successful fetch of CISA's feed, null when the API does not give it), method, coverage (the CISA KEV catalog: total, returned, kev_listed_total, catalog_count, limit, has_more), freshness (last_successful_fetch_at, catalog_version, date_released) and notes, with data equal to the API's JSON; the result's last text block repeats it without data (the first text block) and without the note's sentences (the text block before it), with which notes ends.";
9
+ 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; an RFC 3339 timestamp, such as the kev_added_date 2024-04-12T00:00:00Z that CVE records carry, is read as the date written in it, with the time and offset dropped, and anything else is refused), 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. Rows within one date are ordered by cve_id. 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 and shortDescription are not returned. Its structured result carries state (measured), measured_at (our last successful fetch of CISA's feed, null when the API does not give it), method, coverage (the CISA KEV catalog: total, returned, kev_listed_total, catalog_count, limit, has_more), freshness (last_successful_fetch_at, catalog_version, date_released) and notes, with data equal to the API's JSON; the result's last text block repeats it without data (the first text block) and without the note's sentences (the text block before it), with which notes ends.";
10
10
  const opt = (t) => t.nullable().optional();
11
11
  const KEVRow = z.looseObject({
12
12
  cve_id: opt(z.string()),
@@ -62,17 +62,18 @@ const Freshness = z.strictObject({
62
62
  const DATE = /^\d{4}-\d{2}-\d{2}$/;
63
63
  const isDate = (s) => DATE.test(s) && new Date(`${s}T00:00:00Z`).toISOString().slice(0, 10) === s;
64
64
  // An RFC 3339 date-time (#2736): get_cve gives kev_added_date as one ("2024-04-12T00:00:00Z"), and a
65
- // model passes it on as given. It is read as its UTC date; the time of day is dropped.
65
+ // model passes it on as given. It is read as the date WRITTEN in it; the time and offset are dropped.
66
+ // kev_added_date is a calendar date (CISA's dateAdded, a DATE column), not an instant, so
67
+ // "2024-04-12T00:00:00+02:00" means 2024-04-12 — converting it to UTC first answered for
68
+ // 2024-04-11 and still said measured (#2755). The time and offset are still
69
+ // validated, so a malformed timestamp is refused rather than read.
66
70
  const RFC3339 = /^(\d{4}-\d{2}-\d{2})T([01]\d|2[0-3]):[0-5]\d:[0-5]\d(\.\d{1,9})?(Z|[+-]([01]\d|2[0-3]):[0-5]\d)$/;
67
71
  // since or until as the YYYY-MM-DD the API takes, or null when it is neither form.
68
72
  export function kevDate(raw) {
69
73
  if (isDate(raw))
70
74
  return raw;
71
75
  const m = RFC3339.exec(raw.toUpperCase());
72
- if (!m || !isDate(m[1]))
73
- return null;
74
- const t = Date.parse(raw.toUpperCase());
75
- return Number.isFinite(t) ? new Date(t).toISOString().slice(0, 10) : null;
76
+ return m && isDate(m[1]) ? m[1] : null;
76
77
  }
77
78
  // A header value must be visible ASCII: fetch refuses others, and that refusal would read as an outage.
78
79
  const PRINTABLE = /^[\x20-\x7e]+$/;
@@ -190,8 +191,8 @@ export function registerKevRecent(server, d) {
190
191
  title: "Recent CISA KEV additions",
191
192
  description: KEV_RECENT_DESCRIPTION,
192
193
  inputSchema: z.object({
193
- since: z.string().optional().describe("earliest kev_added_date to include, YYYY-MM-DD (an RFC 3339 timestamp is read as its UTC date)"),
194
- until: z.string().optional().describe("latest kev_added_date to include, YYYY-MM-DD (an RFC 3339 timestamp is read as its UTC date)"),
194
+ since: z.string().optional().describe("earliest kev_added_date to include, YYYY-MM-DD (an RFC 3339 timestamp is read as the date written in it)"),
195
+ until: z.string().optional().describe("latest kev_added_date to include, YYYY-MM-DD (an RFC 3339 timestamp is read as the date written in it)"),
195
196
  ransomware: z.boolean().optional().describe("true: only CVEs with known ransomware-campaign use; false: only those without"),
196
197
  vendor: z.string().optional().describe("CISA vendorProject, an exact case-insensitive match, e.g. 'Microsoft'"),
197
198
  limit: z.number().int().min(1).max(KEV_RECENT_MAX_LIMIT).optional().describe("page size (default 50, max 200)"),
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "echelongraph-mcp",
3
- "version": "2.6.0",
3
+ "version": "2.6.2",
4
4
  "mcpName": "io.echelongraph/echelongraph-mcp",
5
- "description": "EchelonGraph CVE and internet-exposure data as an MCP server: CVEs with EchelonGraph multi-source scores, CISA-KEV and EPSS, plus a per-CVE internet-exposure footprint derived from Shodan data. Shodan data is owned by Shodan, which holds its copyright (© Shodan). Free and keyless, for Claude, Cursor and any MCP client.",
5
+ "description": "MCP server for CVE and vulnerability lookups: CVE records with EchelonGraph multi-source scores, CISA KEV, EPSS history, CWE weaknesses, public exploits, version and SBOM checks against the vulnerability advisory corpus, vendor security advisories, and a per-CVE internet-exposure footprint derived from Shodan data. Shodan data is owned by Shodan, which holds its copyright (© Shodan). Free and keyless, for Claude, Cursor and any MCP client.",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "echelongraph-mcp": "dist/index.js"
@@ -18,17 +18,29 @@
18
18
  "start": "node dist/index.js",
19
19
  "start:http": "node dist/http.js",
20
20
  "smoke": "node test/smoke.mjs",
21
+ "text-bound:live": "npm run build && node test/text-bound-live.mjs",
21
22
  "test": "npm run build && node --test test/*.test.mjs synthetic/*.test.mjs",
22
23
  "synthetic": "node synthetic/run.mjs"
23
24
  },
24
25
  "keywords": [
25
26
  "mcp",
27
+ "mcp-server",
26
28
  "model-context-protocol",
27
29
  "cve",
28
30
  "vulnerability",
31
+ "vulnerabilities",
29
32
  "security",
33
+ "cybersecurity",
30
34
  "kev",
35
+ "cisa-kev",
31
36
  "epss",
37
+ "nvd",
38
+ "ghsa",
39
+ "cwe",
40
+ "cvss",
41
+ "sbom",
42
+ "security-advisories",
43
+ "threat-intelligence",
32
44
  "exposure",
33
45
  "echelongraph",
34
46
  "claude",
package/server.json CHANGED
@@ -17,7 +17,7 @@
17
17
  ]
18
18
  }
19
19
  ],
20
- "version": "2.6.0",
20
+ "version": "2.6.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.6.0",
31
+ "version": "2.6.2",
32
32
  "transport": {
33
33
  "type": "stdio"
34
34
  },