echelongraph-mcp 3.0.2 → 3.1.1

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,675 @@ 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
+ ## 3.1.1 — 2026-10-25
10
+
11
+ A security release (#3345). It moves the runtime dependencies past three advisories, bounds what
12
+ a tool call's arguments may hold, and takes npm out of the hosted endpoint's image. The hosted
13
+ endpoint's 3.1.0 image was refused by its deploy-time Trivy scan (#3018: 13 HIGH with a fixed
14
+ version), so the endpoint kept serving 3.0.2. No `outputSchema` changes, so a client holding
15
+ 3.1.0's tool list does not need to reconnect; only the three `inputSchema` bounds below are new.
16
+
17
+ ### Security
18
+
19
+ - **@modelcontextprotocol/server 2.1.0 to 2.3.0** (CVE-2026-111768, CVSS 7.5; CVE-2026-111770,
20
+ CVSS 7.7), with @modelcontextprotocol/core 2.3.0. Both stay exact pins, as does zod below.
21
+ - CVE-2026-111770 (a server or transport shared between connections delivered one client's
22
+ messages to another): **not affected**, on either transport. The hosted endpoint builds a new
23
+ server for each request (`createMcpHandler` with `createServer` as its factory), and stdio one
24
+ per connection (`serveStdio`), which the advisory names as not affected. 2.3.0 refuses a shared
25
+ server outright (`ALREADY_CONNECTED`); a test holds that 30 clients one after another and 30 at
26
+ once, in both protocol eras, over the hosted endpoint and over stdio, are each answered with
27
+ their own record.
28
+ - CVE-2026-111768 (validating an unbounded array or object in a tool call's arguments exhausts
29
+ memory): **mitigated, now fixed**. Through 3.1.0 only the hosted endpoint's request-body cap
30
+ (64 KiB, or 6 MiB for `check_sbom`, `scan_manifest` and `sbom_review`), the advisory's
31
+ stopgap, bounded it, and nothing did on stdio. The server now sets the SDK's new
32
+ `maxToolInputElements` to 2,500,000 list entries and object members per call, counted at every
33
+ depth: the most any tool accepts, a `check_sbom` document passed as an object at its
34
+ 5,000,000-character cap, so no call a tool would answer is refused by it. A call over it is
35
+ refused by the SDK before any tool runs, with `isError` true and one text block of the SDK's
36
+ own ("Invalid arguments for tool <name>: arguments contain more than the maximum of 2500000
37
+ elements"), and without `structuredContent`: the SDK offers no way to answer it in the
38
+ envelope (docs/structured-results.md).
39
+ - A third advisory 2.3.0 fixes, GHSA-rvq5-wwqv-78pq (bearer authentication accepted a token
40
+ issued for another service), does not apply: this server uses no authentication.
41
+ - **zod 4.6.5 to 4.6.6** (CVE-2023-54404, GHSA-mrhx-c7xc-m3w5, CVSS 7.5). 4.6.6 checks a list's
42
+ bound before it parses an element of it, but only where the list has a bound. Every list and
43
+ record in a tool's `inputSchema` now has one, published in `tools/list` as `maxItems` or
44
+ `maxProperties` and checked before any element is read: `check_sbom`'s `purls` at most 10,000
45
+ entries, its `sbom` passed as an object at most 64 top-level members
46
+ (CycloneDX 1.6 defines 18 and SPDX 2.3 16), and `scan_manifest`'s `files` at most 20 (the
47
+ README's 1 to 20). A call past one is refused with `state` `invalid_input` and a note naming the
48
+ bound: `purls must hold at most 10000 entries`, `sbom must hold at most 64 members`, `files must
49
+ hold at most 20 entries` (through 3.1.0, "21 files; at most 20 are read per call"). The
50
+ `purls` bound counts entries, repeats included, not distinct purls. The README's 2,000 is the
51
+ distinct purls a call checks: that rule is unchanged and still `check_sbom`'s own (more than
52
+ 2,000 distinct, in a list or a document, is refused with the same message as through 3.1.0). A
53
+ list may repeat a purl, as a flattened SBOM does once per install path, so the entry bound is
54
+ five times the distinct one. Of `purls` lists, one kind 3.1.0 accepted is newly refused: a list
55
+ of more than 10,000 entries, which 3.1.0 checked when it held at most 2,000 distinct purls, each
56
+ sent once. That is inherent in bounding a list that had no bound. A list of up to 10,000
57
+ entries is answered as through 3.1.0. Over the hosted endpoint a `check_sbom` call may be a 6 MiB body,
58
+ which 10,000 entries of even the longest purl the API takes (512 characters) fit.
59
+ - **Hosted endpoint only: its image carries no npm** (#3018). The node base bundles npm, npx and
60
+ corepack, which this server never runs, and its npm carried 10 of the 13 HIGH the scan refused
61
+ 3.1.0's image for: brace-expansion 2.0.2 (5), ip-address 10.1.0, pacote 19.0.2 and 20.0.1,
62
+ picomatch 4.0.3 and sigstore 3.1.0; the other 3 were the SDK's two and zod's. The image's
63
+ runtime stage (Dockerfile.http) now removes npm, npx and corepack, and its build fails if any is
64
+ left; its base digest is unchanged. Trivy v0.75.0 (the scan's pinned scanner) finds the 13 on a
65
+ copy of 3.1.0's runtime file system and none on 3.1.1's (2026-10-25). The npm package and the
66
+ stdio image (Dockerfile, for the Docker MCP Catalog) are unchanged by this.
67
+
68
+ ### Changed
69
+
70
+ - **Hosted endpoint only: a 2026-07-28 `subscriptions/listen` stream ends right after its
71
+ acknowledgement**, with a `complete` result. SDK 2.2.0 ends a listen stream that honours none of
72
+ the notification types asked for, and this server sends none (it declares no `listChanged`).
73
+ Through 3.1.0 the stream stayed open, carrying only keepalives, until the client left. A listen
74
+ still takes none of the 64 places an instance serves at once. Over stdio a listen is unchanged.
75
+ - The SDK's packages declare the Apache-2.0 license since 2.3.0 (their LICENSE file carries the
76
+ MIT terms of earlier contributions too); echelongraph-mcp stays MIT.
77
+ - **workload_triage's step 4 sends each purl once** (#3345): it has the agent pass several images'
78
+ purls in one `check_sbom` call, and images share packages, so such a list can repeat purls. The
79
+ step now says to send each purl once, and that a list holds at most 10,000 entries, the new
80
+ `purls` bound above.
81
+
82
+ ## 3.1.0 — 2026-10-25
83
+
84
+ - **cve_summary's `freshness` is the fleet's last completed NVD poll** (#3276). Since #3242 the
85
+ summary answer carries `nvd_poll`: `last_completed_at`, when the fleet of API instances last
86
+ completed an NVD poll, with `scope` `fleet` and the poll's `interval`. cve_summary relayed the
87
+ block in `data` but answered `freshness: null`, and its note, its description and the
88
+ methodology resource said the feed serves no poll-completion time. `freshness.nvd_poll` now
89
+ holds `last_run_at`, the block's `last_completed_at` as sent, `scope` `fleet` and `interval`
90
+ (production, 2026-10-17: `2026-10-17T22:45:09Z`, `fleet`, `20m0s`), and the
91
+ envelope's note says it is the fleet's poll, never the answering instance's
92
+ `poller.last_poll_at`. It is relayed only as a real RFC 3339 time at most 5 minutes after this
93
+ server's clock, on a block whose `scope` is `fleet`; `interval` only as a Go duration, else
94
+ `null`. An answer without `nvd_poll` (an older API, or one holding no fleet poll it could read) keeps
95
+ `freshness` `null`, and the note says this answer carries no `nvd_poll` block, not that the feed
96
+ has no poll time; a block without a usable time keeps it `null` too, says why, and is still
97
+ relayed in `data`. A poll more than three of its intervals old (or more than an hour, with no
98
+ usable `interval`) is said to be old: the note gives its age and says the CVE counts may lag
99
+ NVD by about that much. search_cves' and get_cve's note and description now say their answer carries
100
+ no poll-completion time. The outputSchema's `freshness` admits the new object and still `null`:
101
+ a client that listed the tools under 3.0.2 validates cve_summary against `freshness: null`, so
102
+ it fails cve_summary's answers until it reconnects (Claude Code: `/mcp`, then reconnect).
103
+ - **exposure_radar relays the window over the dated rows, when some counted rows have no time**
104
+ (#2438). A radar's observation window was relayed as `window` only when every counted row
105
+ carried an observation time; otherwise nothing was relayed and the note said how many rows
106
+ carried none. On 2026-10-17, 26,855 of kev_exposure's 66,756 counted pairs were undated
107
+ (stored before banner times were captured), and those pairs can stay undated while the InternetDB re-check
108
+ keeps them on record, so its answers gave no observation time at all. `window` keeps its
109
+ meaning. Beside it, `dated_window` `{from, to, undated}` now carries the span over the dated
110
+ rows only, with the count of those without one, for kev_exposure, exposed_databases,
111
+ leaked_credentials and shadow_ai's confirmed_exposed. The note names both numbers (for
112
+ kev_exposure, the dated pairs out of `correlations`) and says the span does not describe the
113
+ undated rows. `state` stays `not_assessed` and `measured_at` `null`, as the 2026-10-04 decision
114
+ requires. Every count is unchanged. `dated_window` is a new field under objects 3.0.2 publishes
115
+ closed, so a client that listed the tools under 3.0.2 needs to reconnect.
116
+ - **A success's `outputSchema` is open at every level, not only at its root** (#3268). 3.0.2 opened
117
+ the root alone (#3121). Below it, 86 objects still published `additionalProperties: false`: 40 in
118
+ `data` (exposure_radar's and cve_intel's `data` itself among them), 23 in `coverage`, 7 in
119
+ `freshness`, and each tool's `truncated` entry. 12 string enums admitted no other string, among
120
+ them scan_manifest's `manifest.files[].format` and `not_checked[].reason` and exposure_radar's
121
+ `kev_exposure.newest_kev[].exposure_state`. A client holding a release's tool list refused a field
122
+ added under any of those objects, or a value added to any of those enums, on every call of that
123
+ tool until it reconnected, and 2.6.3 and 2.7.0 each added such fields. A success's `outputSchema`
124
+ is open at every level: each object admits fields it does not name, and each enum but `state` any
125
+ string beside the values it lists. The failure branch keeps every object closed, and every enum
126
+ but four, which already accept any string: `coverage.not_assessed_reason` (check_affected),
127
+ `coverage.vendor_windows[].history_backfill` (vendor_advisories_for_cve, search_vendor_advisories)
128
+ and `coverage.vendor_window.history_backfill` (get_vendor_advisory). The server still checks its
129
+ own answers against the closed schema each tool builds, and so does the test suite.
130
+ `test/open-success.test.mjs` fails the release run on any closed object or enum in a success
131
+ branch of `tools/list`, and `test/schema-compat.mjs <old> [<new>]` lists what a candidate adds
132
+ under an object, or to an enum, that the release before it closed. The opened schema is built
133
+ once per process and shared by every server the hosted endpoint builds per request.
134
+ - **A success's null-only `coverage` and `freshness` are published as `null` or an open object**
135
+ (#3268's review). 3.0.2 published `freshness` as `{"type": "null"}` in 14 tools and `coverage` so
136
+ in cve_summary and get_cve, so a client holding a release's list refused the object a later
137
+ release put there, as 3.0.2's clients refuse cve_summary's `freshness` now (#3276). In this
138
+ release those 15 slots (cve_summary's `freshness` now has its own schema) admit an object too,
139
+ while the server still answers `null` in each, as before. `test/open-success.test.mjs` finds a
140
+ success slot that admits only `null`, and `test/schema-compat.mjs` reports a type a candidate
141
+ widens at any node (`null` to `null|object`, a field made nullable, an integer made a number),
142
+ besides a field or value added. `scripts/npm-publish-mcp.sh`'s dry run runs it against npm's
143
+ latest and fails on any finding `test/schema-compat-allow.json` does not accept with a reason.
144
+ - **A client that listed the tools under 3.0.2 or older must reconnect once** (Claude Code:
145
+ `/mcp`, then reconnect `echelongraph`), or it refuses these answers, which its list's closed
146
+ schema does not admit: cve_summary's (`freshness.nvd_poll`, #3276), epss_history's (`coverage`'s
147
+ `runs_undated`, `refresh_runs_truncated` and `days_in_progress`, #3143) and exposure_radar's where
148
+ it relays `dated_window` (under `kev_exposure`, `exposed_databases`, `leaked_credentials` and
149
+ `shadow_ai.confirmed_exposed`, #2438). `test/schema-compat.mjs` against npm 3.0.2 finds these and
150
+ the 15 slots above, which are not refused: they are still answered `null`. From 3.1.0 on, a field
151
+ or value added to a success does not need a reconnect.
152
+ - README Versioning, and the 3.0.2 entry below, said each tool's `data` schema stays closed
153
+ (#3257). In 3.0.2 `data` was open at its root for 14 of 16 tools (closed for exposure_radar and
154
+ cve_intel), and objects below a success's root were closed in and outside `data`. Versioning now
155
+ says what is open and what is closed, in sentences `test/readme.test.mjs` derives from
156
+ `tools/list`, from "Since 3.1.0": 3.0.2 and older publish their success branches closed below the
157
+ root.
158
+ - **cve_intel counts GitHub checker repositories apart from exploit code** (#3221). #3040 defined
159
+ an exploit-reference kind, `github_checker`, for a GitHub repository named or described as a
160
+ checker or scanner and never as an exploit, and EchelonGraph's API does not count a reference of
161
+ that kind as exploit code. cve_intel's note counted every reference but a Nuclei template as a
162
+ public exploit reference, a `github_checker` one included. It now counts each reference the API
163
+ serves as `github_checker` apart, as a GitHub checker repository, with /pulse's note that such a
164
+ repository checks whether a system is affected and is not necessarily exploit code (beside a
165
+ Nuclei template, /pulse's two notes in turn): 1 `github_poc` and 3 `github_checker` references
166
+ read "1 public exploit reference and 3 GitHub checker repositories", where they read "4 public
167
+ exploit references". The counts are the kinds the API serves: a repository it serves as
168
+ `github_poc` is still a public exploit reference here, even where /pulse reads it as a checker.
169
+ So for CVE-2020-35489 (1 proof of concept and 3 checkers on /pulse, 2026-10-11), cve_intel gives
170
+ the 3 as GitHub checker repositories only once the API serves them as `github_checker`. The
171
+ description's head says "detection checks" where it said "detection templates", the `kind`
172
+ field's text splits the exploit-code kinds from `nuclei_template` and `github_checker`, and
173
+ `exploits_by_kind` and the method say the same.
174
+ - **cve_intel never counts a kind it does not classify as exploit code** (#3267). It counted every
175
+ reference whose kind was not one of its two detection kinds as a public exploit reference, so a
176
+ kind EchelonGraph's API serves that this release predates (another scanner or template source)
177
+ would have read "N public exploit references", the claim #3039 took out; `github_checker` did
178
+ that from #3040 to #3221. It now counts exploit code by name (`metasploit`, `exploit_db`,
179
+ `github_poc` and `vendor_poc`, the kinds the API counts as exploit code). A reference whose kind
180
+ is neither one of these nor one of the two detection kinds is "of a kind this server does not
181
+ classify": the note names the kind and counts it as neither exploit code nor a detection check.
182
+ 2 references of an unknown kind `foo_check` read "2 references of a kind this server does not
183
+ classify", where they read "2 public exploit references", and a CVE holding one is never said to
184
+ hold no Metasploit module, Exploit-DB entry or proof of concept. The `kind` field's text says so,
185
+ and so do `method` and `exploits_by_kind`'s description, each now built from the same two lists
186
+ the note counts by: `method` adds "A kind on neither list is one this server does not classify:
187
+ the note counts it as neither.", and `exploits_by_kind` names the exploit-code kinds and what any
188
+ other kind's count is. The kinds the API serves today are all classified, so no count changes.
189
+ - **triage_cve gives GitHub checker repositories apart from exploit code** (#3221). Its step 2
190
+ says a `github_checker` row is a GitHub repository named or described as a checker or scanner,
191
+ which checks whether a system is affected and is not necessarily exploit code, and its
192
+ "Exploited?" line gives them apart, as detection checks, with Nuclei detection templates. It
193
+ named only `nuclei_template`, so "GitHub or vendor proofs of concept" read as taking a checker in.
194
+ - **cve_exposure's outputSchema says what each KEV flag is** (#3016). `kev_listed` is deprecated:
195
+ the same value as `kev_seen_in_observations`, the radar's observation flag, not CISA-KEV catalog
196
+ status, so it is false for a CISA-KEV CVE the radar holds no service for (CVE-2024-3400 on
197
+ 2026-10-10: `kev_listed` false, `kev_catalog_listed` true). `kev_catalog_listed` is the catalog
198
+ flag: true when EchelonGraph's CVE record marks the CVE as CISA-KEV-listed, and false also where
199
+ EchelonGraph holds no record of it. The three fields had no text. `kev_listed` stays, so no
200
+ reader breaks; the values are unchanged.
201
+ - `epss_history` relays `coverage.runs_undated` and `coverage.refresh_runs_truncated` in the
202
+ envelope's `coverage` (#3143), and its note names each when it is not 0 or false. An undated
203
+ refresh is one the run log holds no FIRST score date for: its file named none, or it stopped
204
+ before recording it. The day it applied is not counted (held, partial or in progress), so a day
205
+ the note lists as missing, or does not list, may be held for the CVEs it changed, and its points
206
+ carry no `score_date`. Production has served `runs_undated: 1` since 2026-10-08, when a deploy killed a
207
+ refresh mid-run, and the note said "none missing" without naming it. Both fields are `null` in
208
+ `coverage` when the API does not say, never 0 or false.
209
+ - `epss_history` relays `coverage.days_in_progress` (#3143): the FIRST score dates a refresh that
210
+ has not finished is applying, held for some CVEs so far. core-backend now dates a refresh's run
211
+ row as soon as it reads the file's header, so its date is listed while it runs, and not in
212
+ `days_partial`, which means a refresh of the date failed part-way. A retry of a date whose
213
+ earlier refresh failed part-way is in both lists, and the note names it in both. The note never
214
+ says nothing failed: the API reads "not finished" off the row's status and age, so a refresh
215
+ killed minutes ago reads the same. `null` in `coverage` when the API does not say (an API older
216
+ than #3143).
217
+ - **check_affected and check_sbom answer each CVE once per component** (#3137). OSV can publish one
218
+ CVE for a package in two records, a GHSA and a package repository's own advisory, and
219
+ EchelonGraph's API stored and answered each: `drupal/quick_node_block` 1.0.0 on Packagist came
220
+ back with 4 matches for 2 CVEs, each CVE's GHSA match naming `fixed_in` 2.0.0, which the CVE's
221
+ Drupal advisory still holds affected. The API now decides all of a CVE's stored records for the
222
+ package together: one match per CVE, `fixed_in` 2.0.1 for both, and cve_remediation's
223
+ installed-version answer agrees. `not_affected_count` and `undetermined_count` count CVEs, not
224
+ advisory records (`advisories_considered` still counts the records), and the notes say so. A
225
+ null `fixed_in` is described as no version above this one shown to be unaffected by the CVE's
226
+ advisory records, which is not a finding that no fix exists. A record of the CVE that cannot
227
+ decide `fixed_in` (no version data, a git commit range, or one recorded fix it is at or past)
228
+ does not withhold it, so the same lookup at `fixed_in` can come back undetermined.
229
+ - **get_cve and search_cves type the scorer's three components** (#2879). Scorer v4.0 publishes
230
+ `eg_severity` (the impact severity alone: the CVSS base no KEV listing, EPSS or EG-KEV tier
231
+ raises; `assessed` false, with `unassessed_reason`, where no CVSS is published), `eg_likelihood`
232
+ (FIRST's EPSS probability of exploitation in the next 30 days, not EchelonGraph's own model; null,
233
+ with `null_reason`, where no EPSS is on record; `exploited` reports confirmed exploitation apart,
234
+ never folded in) and `eg_priority` (the SSVC decision, the BOD 26-04 timeline and their inputs).
235
+ The `outputSchema` types all three on get_cve's `data`, where `GET /cves/:id` serves them at the
236
+ top level, and on search_cves' `data.cves[]` rows (the list route carries them in
237
+ `score_factors`, whose description now names them); `echelongraph_score` is described as
238
+ severity escalated by confirmed exploitation, and `echelongraph_risk`'s severity term as
239
+ `eg_severity` from v4.0. get_cve's note adds up to two sentences, each only where the record
240
+ carries what it reads: where the score is above `eg_severity` because a KEV listing floors it at
241
+ 9.0 or EPSS lifts it, that the rise is exploitation, not impact (for a KEV-listed CVE with no
242
+ CVSS, that the score is the floor for confirmed exploitation, not an impact rating); and where
243
+ `eg_likelihood.probability` is null, that the likelihood is unknown, not low. A record scored
244
+ before v4.0 reads as before. Additive: 3.0.2 published get_cve's `data` and search_cves' rows
245
+ open (`additionalProperties` `{}`), so a client holding 3.0.2's list accepts the fields.
246
+ - **Arguments that break a tool's inputSchema answer the full envelope, `invalid_input`** (#3015).
247
+ The MCP SDK checked a call's arguments against the tool's `inputSchema` before the tool ran, and
248
+ answered a failure itself: `search_cves` `{limit: 500}` came back as one text block, "Input
249
+ validation error: Invalid arguments for tool search_cves: limit: Too big: expected number to be
250
+ <=50", `isError` true, with no `structuredContent`, so no `state` and no notes, although the README
251
+ says every result carries `structuredContent`. Every tool now answers such a call as it answers
252
+ any refusal: `state` `invalid_input`, `error.kind` `invalid_input`, the envelope in the last text
253
+ block, and notes naming each argument and the rule it broke (`limit must be at most 50`,
254
+ `severity must be one of "Critical", "High", "Medium" or "Low"`, `cve_id is required (a string)`),
255
+ by JSON type, never quoting a value sent, over stdio and the hosted endpoint, in both protocol
256
+ eras. Nothing is looked up. This change leaves `tools/list` as it was: it publishes every
257
+ `inputSchema` byte for byte as before it. Against 3.0.2, 15 of the 16 are unchanged;
258
+ get_vendor_advisory's differs in its `advisory_id` description alone, which #3011 (below)
259
+ rewrote to name `vendor_advisory_id` and warn off the row's UUID.
260
+ - **get_vendor_advisory says which ID to pass when given a row's `advisory_id`** (#3011). A
261
+ vendor_advisories_for_cve or search_vendor_advisories row carries two IDs: `advisory_id`,
262
+ EchelonGraph's internal UUID, and `vendor_advisory_id`, the vendor's own. get_vendor_advisory's
263
+ argument is named `advisory_id` but takes the vendor's ID, and EchelonGraph's API has no route by
264
+ the UUID, so passing the row's `advisory_id` straight on (CVE-2024-3400's Palo Alto row,
265
+ `a68d033b-…`) answered `failed`, worded as the API holding no record, for an advisory it holds. It
266
+ now answers `invalid_input` naming the field to pass: the row's `vendor_advisory_id`
267
+ (`CVE-2024-3400`) as `advisory_id`. The API is still asked first, so a vendor's own ID shaped like a
268
+ UUID is looked up as before, and any other 404 is still `failed`, as no record. Both list tools'
269
+ `outputSchema` (and get_vendor_advisory's, which echoes it) now describe a row's `advisory_id` and
270
+ `vendor_advisory_id`, get_vendor_advisory's `advisory_id` argument and description name
271
+ `vendor_advisory_id` and warn off the UUID, and triage_cve's step 4 says to call
272
+ get_vendor_advisory with a row's vendor and its `vendor_advisory_id` as `advisory_id`.
273
+ - **scan_manifest states the API's per-minute budget, and its batch numbers are checked** (#3100).
274
+ Its description now says that a call takes at most 2,000 distinct purls and 50 seconds, that the
275
+ API allows 1,200 components a minute per caller, and that on a 429 the tool waits out
276
+ `Retry-After` within those 50 seconds, while a later batch cut off at them, refused or failed
277
+ ends the call, the rest in `data.not_sent_purls`, unchecked and not clean (3.1.0's review: the
278
+ first wording bound only the 429 wait by the 50 seconds, which bound the whole call, as 3.0.2's
279
+ "within 50 seconds" said; the room came from shorter wording elsewhere). So from a fresh minute a
280
+ lockfile of 1,201 to 2,000 purls answers partial by design, which the description did not say;
281
+ started later in the API's minute, a call can wait for the next one and check more. Its list of
282
+ `not_checked` reasons gave way to it: each reason is described in the `outputSchema`.
283
+ The description's numbers (200 per POST, 2,000 purls, 50 seconds, 1,200 a minute) were literals
284
+ no test read; they are now held to the constants the tool sends by, and core-backend's guards
285
+ hold scan_manifest's batch size and budget to the API's own, as they did check_sbom's.
286
+ - **cve_remediation's first text block keeps every advisory before any list beside them** (#3272).
287
+ #3139 fixed the cut order of `structuredContent.data` only. The first text block, which a client
288
+ that passes only `content` reads, still left advisories out from the end while `fixed_branches`,
289
+ `patches` and `fix_references` stayed whole beside them: on the recorded CVE-2023-44487 answer it
290
+ kept 19 of 20 advisories, and with 1, 5, 10 and 16 more `fixed_branches` packages 18, 13, 8 and 2.
291
+ Its TEXT CUT note named `get_vendor_advisory`, which needs the advisory's ID, and never
292
+ `vendor_advisories_for_cve`. The text now cuts in `data`'s order: each advisory to its leaner
293
+ fields first, then `fixed_branches` and then `patches` thinned to the fields `data` keeps, then
294
+ `fix_references`, `patches` and `fixed_branches` rows leave from the end, each list emptied
295
+ before the next loses one, and only then advisories. The note says what each list keeps and,
296
+ when an advisory is left out, names `vendor_advisories_for_cve`, which lists every one. That
297
+ answer keeps all 20 advisories now, beside every package, patch and reference, with 1 to 18
298
+ more packages too. As `data` does, once `fixed_branches` and `patches` are thinned and the text
299
+ still does not fit, each package's ranges are cut to the most that fit, every package kept: a
300
+ package so cut carries `fixed_branches_truncated` with its total and returned, and the note
301
+ names it and says `structuredContent.data` holds every range; only where even one range per
302
+ package does not fit do references, then patches, then packages leave, the room they leave going
303
+ back to the ranges. The note's fix rule, walked over a capped package's kept ranges alone, can
304
+ name a version a range left out still holds, so the text's note and `data`'s (of a package `data`
305
+ caps) say so and to read that package's fix with `full: true`. Before 3.1.0's review the text
306
+ capped no ranges: with one package of N ranges first among CVE-2023-44487's 51 it kept 11 of 20
307
+ references at 150 ranges, none at 200, no patch at 250, 25 packages at 300 and, from 400, no
308
+ package, patch or reference in 6,347 characters. It now keeps all 51 packages, 30 patches and 20
309
+ references from 50 to 600 ranges, the large package's ranges capped at 133 from 150 on (3.0.2's
310
+ every-list cap kept 32 packages, each to 32 ranges, 30 patches and 20 references). `data` keeps
311
+ all 51 throughout; the longer notes cost it a few references there (10 of 20 from 150 ranges, from
312
+ 12 or 13).
313
+ - **cve_remediation's fix rule reads a package listed under Packagist and Packagist:<repository
314
+ URL> as one** (#3285). `fixed_branches` lists a package once per stored ecosystem string, so
315
+ CVE-2025-48013 lists `drupal/quick_node_block` under `Packagist:https://packages.drupal.org/8`
316
+ (fixed 2.0.1) and under `Packagist` (fixed 2.0.0). The note's rule, "the first fixed above it
317
+ that no range of its package holds", read entry by entry, gave a Packagist 1.0.0 the fix 2.0.0,
318
+ which the Drupal advisory still holds affected; the matcher reads both entries as one package
319
+ and answers 2.0.1. Where such entries are listed, the note now counts them as one package, says
320
+ that entries of one `package_name` under Packagist or Maven, bare or with a repository URL, are
321
+ one package, and names them. A release suffix (`Debian:12`) is never folded. `data` is
322
+ unchanged.
323
+ - **cve_intel and the prompts read them the same way.** `cve_intel`'s `affected_packages` lists
324
+ a package once per stored ecosystem string too, and its note's rule ("no range of the row
325
+ holds") and the `sbom_review` and `triage_cve` fix rules took one row at a time, so the same
326
+ 1.0.0 was given 2.0.0. `cve_intel`'s note now says such rows are one package, names them, and
327
+ states the walk over all their ranges; `sbom_review`'s rule reads a Packagist or Maven
328
+ component's rows of its `package_name`, bare and with a repository URL, as one row, and
329
+ `triage_cve` gives their ranges together, never a fixed that another of them still holds.
330
+ Both give 1.0.0 the fix 2.0.1.
331
+ - **check_affected's text keeps KEV-listed and ransomware matches to the last** (#2899). Near the
332
+ cap of 200 registry matches the text left out the last matches by rank (`effective_score`), so
333
+ a KEV-listed match ranked low was in `structuredContent.data` only and the note said only how
334
+ many left. Now a match whose `kev_listed` or `ransomware` is true stays in the text while any
335
+ other match is in it, the note says so, and if one must still leave (every other already out)
336
+ the note names each by its `cve_id`. The structured result's cut rule is unchanged, but by
337
+ default `data` can hold a few entries fewer, because the longer `TEXT CUT` note sits in the same
338
+ envelope budget: at the cap of 200 registry matches check_affected's `data` keeps 131 where 3.0.2
339
+ kept 132 to 133, and cve_remediation's, with 100 packages more than CVE-2023-44487's 50, 78
340
+ `fixed_branches` rows where it kept 80 (and, with one package of 150 to 600 ranges among them, 11
341
+ of 20 `fix_references`, from 12 or 13, since the text names the package whose ranges it caps,
342
+ #3272). `full: true` returns `data` whole, as before.
343
+ - **check_sbom's first text block keeps rows with a KEV-listed or ransomware match to the last**
344
+ (#3284). `data` keeps those rows first, while the text left the other affected rows out from the
345
+ end in input order, so a KEV-bearing row could leave the text and be named in no text block and
346
+ in no note (the `DATA CUT` sentence names only rows left out of `data`). On the reviewer's
347
+ text-only client (the first text block and the note, then `workload_triage`'s step 6), the 36
348
+ realistic Debian 12 images had 95 of 413 KEV-bearing rows in neither; the 2,000-purl images and
349
+ image mixes 23 of 119. Now those rows leave the text only after every other row does, the note
350
+ says so ("the last 123 of the rest without a KEV-listed or ransomware match"), and where some
351
+ must still leave it names each by `index` ("Of the rows left out of it, 30 have a KEV-listed or
352
+ ransomware match: at index 511 to 513, …"). On the same sets, a row the note names so counted as
353
+ named: 0 of 413 and 0 of 119, and the KEV-first-sorted images 0 of 374 (1 at d42d4697). `scan_manifest`'s text, cut the same way, does
354
+ the same. `workload_triage` and `sbom_review`, read from the text alone, list the rows so named
355
+ as KEV-bearing and not checked. The 201-purl probe keeps its 43 rows in `data`. As with #2899,
356
+ the longer note sits in the same envelope budget: on the realistic images `data` keeps 489
357
+ affected rows where it kept 491, and after `workload_triage`'s 5 re-check calls 392 KEV-bearing
358
+ rows are in view where 396 were (KEV-first images 352 of 374, from 356); every re-check call
359
+ still keeps every row it sends.
360
+ - **workload_triage's Kubernetes exposure rule names a container `hostPort` and a Service's
361
+ `externalIPs`** (#3283). Its rule named a LoadBalancer or NodePort Service, an Ingress or Gateway
362
+ route and `hostNetwork`, and "not exposed" needed only the pod's spec and the Services read, so a
363
+ pod reachable on node IPs through a `hostPort` (an ingress controller or agent DaemonSet) or a
364
+ ClusterIP Service with `externalIPs` was labelled not exposed and sorted below the unknown ones
365
+ of its KEV group. Both are now exposed, and "not exposed" requires having read the containers'
366
+ ports and the Services' `externalIPs`. The MCP post's "Triage your pods" section states the same
367
+ rule, held to the prompt's text by marketing-site's prompt-claims test.
368
+ - **sbom_review gives a null `fixed_in`'s reason** (#2834). Where a match's `fixed_in` is null,
369
+ the fix list writes "no fix on record" with the match's `fixed_in_reason`, as given, and reads
370
+ no version from it: a version it names (an interval that ends at `last_affected`) is not a fix.
371
+ It wrote "no fix on record" alone. check_sbom's text keeps `fixed_in_reason` at every level
372
+ that keeps a match. Its structured `data` does not at the compact level (#3269): kept there, the
373
+ reason cost the 201-purl probe a row (43 to 42) and image mixes affected rows at their floor. So
374
+ the clause applies where a match carries `fixed_in_reason`, and a null `fixed_in` without one is
375
+ a reason the cut left out: sbom_review never calls it no fix, and checks the purl again with
376
+ `full: true` or reads the fix from cve_remediation or get_cve.
377
+ - **check_affected relays a CPE match's range end in its own field, never as `fixed_in`** (#2834).
378
+ Once EchelonGraph's API serves it, a CPE match carries the end of the NVD range that matched,
379
+ `cpe_version_end_excluding` (the first version the range no longer holds) or
380
+ `cpe_version_end_including` (the last one it holds). The outputSchema types both, and neither is
381
+ ever given as a fixed version: another range of the same CVE, or a vendor backport, can sit past
382
+ it. Only the first cut level of the text and the first thinned level of `data` keep them; every
383
+ later level leaves them out, so a CPE answer cut past its first level, as typical CPE answers
384
+ are, carries them only with `full: true`. A CPE match still carries no `fixed_in`.
385
+ - check_sbom's and scan_manifest's `matched_package` and `matched_via` are on each row the lookup
386
+ ran for; a row the API refused has neither (#2836). The methodology resource, /pulse/mcp's
387
+ check_sbom row and the SBOM coverage program say so, where they said every row.
388
+ - The `echelongraph://methodology` resource says what check_affected's and check_sbom's matches
389
+ carry for a fix (`fixed_in`, or null with `fixed_in_reason`; a CPE match's range end, which is
390
+ not one), and that a deb, apk or alpine purl's `upstream` qualifier is matched as the source
391
+ package it names (`matched_via` upstream, `matched_package` that name), while a binary package
392
+ sent without it comes back `not_assessed` (#2834, #2836).
393
+ - **check_sbom and scan_manifest drop clean rows before they cut an affected row's matches**
394
+ (#3269). 3.0.2's structured cut (#3132) took every row through its levels together, the clean
395
+ ones with the affected, and only at the last did rows leave. On an ordinary image SBOM, where
396
+ most rows are clean, the affected rows lost their matches to keep the clean ones. Replayed on the
397
+ production synthetic's recorded rows, 150 purls with 40 affected kept all 150 rows, and only 2
398
+ of the 40 affected rows kept their matches. 3.0.0 kept 56 rows, all 40 affected with every
399
+ match. Now the `not_affected` and `not_assessed` rows are cut to `index`, `purl`, `verdict`,
400
+ `not_assessed_reason` and `cve_ids`. They then leave, `not_affected` first, as many as must for
401
+ every other row to keep all its matches. `data.summary` counts them, and the note says how many
402
+ left. Once the cut is settled they come back, cut so, as many as the room left holds. On the same
403
+ replays, 150/40 keeps 52 rows, 150/30 keeps 91 and 150/20 keeps 135, and every affected row has
404
+ every match.
405
+ - **Where the affected rows must be capped anyway, the clean rows stay** (#3269's review). They
406
+ leave first to keep the other rows' matches whole, not to raise a cap those rows are cut to
407
+ anyway. So where every other row cannot keep all its matches even with every clean row gone,
408
+ the clean rows stay, cut as above, while every affected row keeps at least its floor: each of
409
+ its matches used in ransomware or KEV-listed, and 10 more of highest `effective_score`.
410
+ `linux-libc-dev` 6.1.55-1 (200 matches) with 100 clean Debian 12 purls keeps all 101 rows, as
411
+ 3.0.2 did, `linux-libc-dev` with 29 of its matches; sending the clean rows out to raise the cap
412
+ kept 1 row of 101 in testing. Juice Shop 11.1.2 with `magento/community-edition` 2.3.0 keeps all
413
+ 51 rows, every one of its 19 affected rows with matches, magento with 19 of its 200, both
414
+ KEV-listed ones among them (3.0.2 kept it 6, without CVE-2025-54236). Only where even the floor does not fit
415
+ with every clean row do they leave, as many as must for it to: five Debian 12 purls with 200
416
+ clean ones keep 69 rows.
417
+ - **A capped affected row never goes below its floor; past it, rows leave whole** (#3269's second
418
+ review). Where even the floor did not fit with every clean row gone, the cap went on below it,
419
+ to 1 match a row and then none, every row still kept: five Debian 12 purls beside an image's 150
420
+ (40 affected) kept 45 affected rows, 1 with any match, where 3.0.2 kept 26, 22 with every
421
+ match. Now no row's matches are capped below its floor. The other rows leave instead, whole, at
422
+ the compact level and their floor, and the rows kept are chosen those with a KEV-listed or
423
+ ransomware match first, then the others, each in input order, each one that fits beside those
424
+ chosen before it (a row too large to fit leaves, and the rows after it stay). The note names
425
+ the affected and undetermined rows that left by `index`, so a call of those purls alone checks
426
+ them again (those with a KEV-listed or ransomware match first and apart, below). Where not even
427
+ one row fits at its floor (a row whose KEV-listed matches alone pass the budget), it stays alone
428
+ with the most of its matches that fit, ransomware first, and the note says so: 116 of 200
429
+ KEV-listed matches. Over 40 Debian-image mixes (five Debian 12 purls, or `linux-libc-dev`
430
+ 6.1.55-1, beside 100 to 400 more purls, 10 to 60 affected), 820 affected rows keep every match
431
+ or their floor, 720 every match, and none is
432
+ under its floor (round 2: 462 and 408, and 744 rows under it; 3.0.2: 929 and 834, and 116 under
433
+ it). 3.0.2 kept more rows because its compact matches left out `epss_score` and
434
+ `score_assessed` (#3270): with those two fields out, this cut keeps 1,030 and 930. The note's
435
+ list of affected components says that the rest are in `data.results` whole with `full: true`.
436
+ - **The trade against 3.0.2, plainly** (#3269, #3270). On heavy images the first call keeps fewer
437
+ affected rows than 3.0.2 did. Each row it keeps is whole at its floor or more, every match with
438
+ every field `workload_triage` ranks by (`epss_score` and `score_assessed` among them), the rows
439
+ with a KEV-listed or ransomware match kept first, and the rest are named by `index` for checking
440
+ again (next item). Measured on 36 realistic Debian 12 images (2026-10-18): 491 affected rows in
441
+ the first call, against 3.0.2's 686; but 367 with every match and all six ranked fields, against
442
+ 61; 204 with a KEV-listed or ransomware match, against 39; and no KEV-listed match cut while a
443
+ match without one is kept, against 9 rows on 3.0.2. On three 2,000-purl images: 36 rows against
444
+ 86, 28 against 2 with every ranked field, 31 against 4 KEV-bearing. On the 40 image mixes above:
445
+ 820 against 1,045, 720 of them with every match and all six ranked fields, against none. After
446
+ `workload_triage`'s checks again, 396 of the realistic images' 413 KEV-bearing rows are in view.
447
+ Since those checks take the KEV-bearing rows first, 48 affected rows that 3.0.2's call showed,
448
+ on 3 of the 36 images (13 on one 2,000-purl image), are past its 5 calls, and it reports them
449
+ by `index` as unchecked.
450
+ - **The rows left out are named KEV-bearing first, and checked again in calls that keep every
451
+ row** (#3269's third review).
452
+ - **The order.** The note counted the KEV-bearing rows among those left out, but named all of
453
+ them in one list, in `index` order. `workload_triage` checked them again in that order and
454
+ stopped after 5 calls, so on six old unpatched Debian images, where those rows come late, 119
455
+ of 255 KEV-bearing rows were in view after it. The note now names them first and apart: "95
456
+ affected or undetermined rows: the 32 with a KEV-listed or ransomware match at index 18 to 21,
457
+ …; the other 63 at index 0, 2, 4 to 9, …". Both lists are of runs of indices, 100 parts at
458
+ most in all. `workload_triage` checks them again in that order: 238 of the 255 are in view
459
+ after its 5 calls, against 228 with the input itself sorted KEV-bearing first.
460
+ - **The size of each call.** Step 6 sized its calls by the page sentence's N, which holds for a
461
+ call of the first N purls sent. The rows left out are the heavy ones, so those calls dropped
462
+ 504 of 2,540 rows on the realistic images. The note now gives the sizes for these rows, counted
463
+ as the cut would cut a call of them, with that call's own note (the affected components it
464
+ names): "Re-check these 95 purls in this order, in calls of 7, 5, 8, 8 and 16, then of at most
465
+ 17: each such call keeps every row in data." Each of the first five calls takes the most of
466
+ the rows left, in that order, that the cut's own measure counts as fitting at their floor with
467
+ 600 characters held back, a lower bound (a real call of them often keeps more); after those,
468
+ the number is the most such that any that many of the rest in a row count so, and a call of
469
+ fewer rows in a row keeps them all too. One size for every call (the "at most M" alone) put 201 of the 255 in view in
470
+ testing; even the best single size, found by trying real calls, would put 224. Every call these
471
+ sentences give, made in full, kept every row: 551 calls on the 36 realistic images (15 an
472
+ image), 227 on the three 2,000-purl images, 441 on the 19 KEV-first-sorted ones, 28 on the
473
+ image mixes and 18 on the 16 adversarial ones; within the 5-call cap, 165 calls on the
474
+ realistic images.
475
+ - **A row past the budget alone.** A row whose floor alone does not fit (200 KEV-listed
476
+ matches) is sent to a call of its own, which keeps it under its floor and says so: "The purl
477
+ at index 150 goes in a call of its own: …".
478
+ - **sbom_review** had no step for the affected rows a `DATA CUT` leaves out of `data`, which is
479
+ what Claude Code reads. Its step 1 now checks them again the same way, by `index`, those with a
480
+ KEV-listed or ransomware match first, at most 5 calls, and lists the rest as unchecked.
481
+ - **The note's length.** Over the 36 realistic images the note is 151 characters longer on
482
+ average, 236 on the 2,000-purl images, and 20 shorter on the image mixes, where the order the
483
+ rows were kept in is left unsaid when it is plain. The first call keeps 491 affected rows on the
484
+ realistic images (round 3: 490), 820 on the image mixes (819), 36 on the 2,000-purl images
485
+ (38), and the 201-purl probe's 43.
486
+ - **The guard.** A prompt-fields test holds each shape of the note that `workload_triage` and
487
+ `sbom_review` quote to a real cut's note.
488
+ - **What workload_triage and sbom_review do with a cut answer** (#3269's fourth review).
489
+ - **Rows left out of the first text block.** The text leaves rows out from the end, in input
490
+ order, while `data` keeps the KEV-bearing rows first, and `workload_triage` had no step for
491
+ the rows the text left out, so a client reading the text alone lost them unsaid, KEV-bearing
492
+ ones among them. Its last line now says they are in `structuredContent.data`, or, where a
493
+ `DATA CUT` leaves rows out of `data` too, named in its note by index for step 6's re-check,
494
+ and, read from the text alone, counts them from `data.summary` as unchecked, as `sbom_review`
495
+ does.
496
+ - **Indices.** `sbom_review`'s step 1 says that `metadata.component`, which check_sbom does not
497
+ read (Trivy puts the image's `pkg:oci` purl there), is not counted, and has the agent check
498
+ its index-to-purl mapping against the rows `data` keeps, which carry both, before it sends a
499
+ re-check call.
500
+ - **Past 30 CVEs.** `sbom_review`'s step 3 looked up any 30. It now takes the KEV-listed ones
501
+ first (a match with `kev_listed` true, or named in `matches_truncated.kev_listed_cve_ids`),
502
+ then the highest `epss_score`, read from check_sbom's matches, not from a `get_cve` per CVE.
503
+ - **The page sentence promises what holds** (#3269's review, and its second review). With clean
504
+ rows leaving first, the number of rows `data` keeps is no longer a page size at which a call
505
+ keeps them all: in testing, Juice Shop with magento named 19, and a call of its first 19 purls
506
+ kept 14. The sentence now names the most purls N such that a call of the first N, or of fewer,
507
+ keeps every row, counted as the cut would cut that call, and says only that: "A call of the
508
+ first N of these purls, or of fewer, keeps every row in data; a call of other purls says in its
509
+ own note what it leaves out." Round 2's opening, "To read every row in data, check N or fewer
510
+ purls per call", read as a page size for the rest, and calls of the purls N at a time lost rows
511
+ in 55% of the paged mixes in its review. On the replays, 150/40 names 99 and the 201-purl probe
512
+ 42; a call of the first N, or of fewer, kept every row in each of the replayed cases, in 24
513
+ seeded random mixes (the suite), and in the 158 of 160 more, of up to 2,000 purls, that named
514
+ one, run outside the suite.
515
+ - **A capped check_sbom row keeps its KEV-listed matches; every level that keeps matches keeps
516
+ what workload_triage ranks by** (#3270).
517
+ - **Which matches stay.** A row whose matches were capped kept its first ones by
518
+ `effective_score`, so a KEV-listed CVE ranked lower was cut and only
519
+ `matches_truncated.kev_listed_cve_ids` named it. magento's CVE-2025-54236, at position 21 of
520
+ its 200 matches, was cut beside Juice Shop. Such a row now keeps the matches chosen used in
521
+ ransomware first, then its other KEV-listed ones, then those of highest `effective_score`;
522
+ `data` lists them in the API's order, and the note and `matches_truncated`'s text say both.
523
+ - **Which fields stay.** The compact level dropped `epss_score` and `score_assessed`, so the
524
+ 201-purl probe kept 54 rows and not one match with either. It now keeps every field a prompt
525
+ ranks by: 43 rows fit, as on 3.0.0, each match with all six.
526
+ - **workload_triage.** Step 5 now reads `matches_truncated`'s `kev_listed_cve_ids` and
527
+ `ransomware_cve_ids`, and says that at the leanest cut neither list is there, so its `get_cve`
528
+ calls read those CVEs. It calls `get_cve` for every CVE kept without its match, and for every
529
+ match that lacks a field it ranks by. A new step 6 takes each image's counts from
530
+ `data.summary`, which counts every row whatever the cut (a call per image where several
531
+ images' rows were left out). A `not_affected` or `not_assessed` row left out is not checked
532
+ again; the affected and undetermined rows that left, which the note names by `index`, are, in
533
+ calls of their purls alone (KEV-bearing first, at the sizes the note gives, since #3269's
534
+ third review), at most 5, the rest reported as unchecked. (#3269's review first had every
535
+ purl left out checked again at the page size: 332 calls on a realistic Debian SBOM,
536
+ nearly all for clean rows.) sbom_review's fix rule now covers a CVE whose match a cut left out.
537
+ - **The guard.** The #3013 prompt-field test now holds every level that keeps matches, not
538
+ `SBOM_ROW_THIN` alone, with a control: 3.0.2's compact level fails it, naming the two fields.
539
+ - **check_sbom's and scan_manifest's first text block follows the same priority** (#3269). Its
540
+ clean and not-assessed rows left the text only once every row was cut to `index`, `purl`,
541
+ `verdict`, `not_assessed_reason` and `cve_ids`. So on the same replays the text kept all 150 rows
542
+ of the 150/40 mix and the matches of none of the 40 affected rows, on 3.0.0 and 3.0.2 alike. Now
543
+ those rows are cut to that level and leave the text first, as many as must. The 150/40 text keeps
544
+ 58 rows, every affected row with every match. Where that cannot fit, the text is cut as before,
545
+ every row to that level: the 2,000-purl and Juice Shop texts are unchanged, and five Debian 12
546
+ purls with 200 clean ones keep 157 rows. The note says what those rows leave out, and that each
547
+ string in them keeps its first 100 characters where one is longer (a 150-character Maven purl
548
+ with its `repository_url` came back clipped, unsaid, in testing). The text names no page size
549
+ (#3269's second review): "check N or fewer purls per call", N the rows it kept, was none, and a
550
+ call of the first N kept fewer rows in 34 of 89 mixes, on 3.0.2 too. Its note says how many
551
+ rows it left out, of which kinds, and that `structuredContent.data` holds them.
552
+ - **A hosted handler error with JSON-RPC code -32022 is never `error_class` `other`** (#2923). A read
553
+ of production since 3.0.2 found one: the bundled SDK rebuilds a peer's -32022 error response with
554
+ the peer's own message (`ProtocolError.fromError`), which no fixed prefix can know. An error whose
555
+ message no prefix knows is now classified by its code where the code names a cause: -32022
556
+ `unsupported_protocol_version`, -32021 `missing_client_capability`, -32020 `header_mismatch`,
557
+ -32700, -32600, -32601 and -32602 `parse_error`, `invalid_request`, `method_not_found` and
558
+ `invalid_params`. The message is still never logged. Each such line now also names the request it
559
+ arose in by the access line's own labels, `rpc_method` and `protocol_version` (the
560
+ `MCP-Protocol-Version` header as a date, `(other)` or `(none)`), read from the request's own
561
+ context, since the SDK hands its `onerror` only the error; never a param. `rpc_method`, on this
562
+ line, the access line and `mcp_upstream_failed`, is now a closed set of labels: a method MCP
563
+ defines for a client to send (the bundled SDK's client requests and notifications, and its task
564
+ methods), or `(other)` for any other string. It was the client's own string, checked only for its
565
+ shape. The access line's `method` is closed the same way: GET, HEAD, POST, PUT, PATCH, DELETE,
566
+ OPTIONS, CONNECT or TRACE, else `(other)`. Over h2c, the hosted endpoint's protocol, it was any
567
+ `:method` token the client sent. So is `mcp_client_gone`'s `rst_code`, the reset code of a stream
568
+ the client reset: a code HTTP/2 defines (0 to 13), else `(other)`. It was any number the client's
569
+ reset carried.
570
+ - **On the hosted endpoint, a large body that finds both large-body slots busy waits for one** (#2775).
571
+ Behind Cloud Run's front end a client's close reaches the server too late to free the slot of a
572
+ `check_sbom` call it abandoned (a probe on 2026-10-18: both follow-ups sent 1 s after two aborted
573
+ calls were answered 503 while those calls ran on to 200), so a client that timed out and retried
574
+ was refused for the rest of its own abandoned call. Now a `check_sbom` or `scan_manifest` call or
575
+ an `sbom_review` prompt past 64 KiB that finds every slot taken waits up to 15 seconds for one
576
+ (`MCP_LARGE_BODY_WAIT_MS`), at most two at once, first come first served, and is answered 503 with
577
+ `Retry-After`, as before, only when its wait runs out or two already wait. The access line records
578
+ the wait (`slot_wait_ms`), and `mcp_large_body_busy` is written only for a refusal. A body that
579
+ declares its `Content-Length` waits holding nothing; a chunked body (no `Content-Length`) waits
580
+ holding at most the 64 KiB general cap (`MCP_MAX_BODY_BYTES`) plus the chunk that crossed it, so
581
+ memory stays bounded as before.
582
+ - **The hosted endpoint's body reader also reads whether the client had gone before it started**
583
+ (#2775). It learned of a close or an abort only from the listeners it attached, so a reader
584
+ started after the client went would have kept a large body among the waiters to the end of its
585
+ 15 seconds, or taken a free slot and held it for good. No request to `http.js` met this: it starts
586
+ reading each body in the request's own event. On Node 20, 22 and 24, a stream reset or a close
587
+ sent in the same write as a large request's headers was logged 499 (`client_closed`) within a few
588
+ milliseconds, holding no slot and no place among the waiters, whether a slot was free or held. The
589
+ reader now reads the request and its response's signal as they are when it starts.
590
+ - **The hosted endpoint logs every API call that fails** (#2948). On 2026-10-07 a hosted `get_cve`
591
+ answered "EchelonGraph ... could not be reached" in 460 ms, and the hosted service's only line
592
+ for it was its access line, `status` 200: the cause went to the client and nowhere else, and was
593
+ lost. Each failed API call now writes one `mcp_upstream_failed` line: `failure_class` (`timeout`,
594
+ `dns`, `refused`, `reset`, `connect_timeout`, `unreachable`, `tls` or `network` from the error's
595
+ codes; `http` with its `status`; `not_json` or `not_object`; `unexpected_shape` for a 2xx the tool
596
+ could not use), `route` (core-backend's route template, such as `/api/v1/public/cves/:id`: never
597
+ the path, which holds what was looked up, and never the query string), `tool` and `rpc_method` as
598
+ the access line has them, `after_ms`, and `synthetic`. It is WARNING, but INFO for the API's own
599
+ answer about the input (a 400, or a 404 for a named record), which the tool reports as invalid
600
+ input or no record: a departure from #2948's every-failure-WARNING, so that a mistyped CVE ID is
601
+ no outage (the hosted service's monitoring still counts the INFO lines, so a 404 that is really a
602
+ removed route shows); and ERROR for `output_schema`, the tool's own outputSchema refusing the answer
603
+ it built, which is this server's fault, not the API's (#2948's review: it is no upstream failure,
604
+ so the upstream metric, which counts WARNING, leaves it out). A call cancelled because its client
605
+ went writes none. `exposure_radar` writes
606
+ one per radar that failed. The npm package (stdio) writes nothing: stdout is the protocol.
607
+ - README: "Privacy: what is sent where" names every field the hosted endpoint's access line
608
+ records (#2863). It named the method and path, status, duration, body size, JSON-RPC method,
609
+ tool name, protocol version, `Origin` presence and "the family" of the User-Agent; the line also
610
+ records the prompt name for `prompts/get`, how long the request waited for a place and how many
611
+ were served at once, whether the client's address is public (not the address), whether the client
612
+ closed early, and whether the request was throttled or refused, and why, as /privacy Section 11
613
+ says. The User-Agent is described as its first word, which is what is kept. Nothing the endpoint
614
+ logs changed. Each line it writes is now one of `src/logLines.ts`'s, with exactly its keys: a new
615
+ line or key does not build until it is listed there, and the tests hold the README to the list.
616
+ - Repository only, not in the package: the production synthetic now writes what each answer costs
617
+ a client on every probe line whose call was answered, on both the npm package and the hosted
618
+ endpoint: `text_chars` (every text block's characters) and, for a tool call, `structured_chars`
619
+ (`structuredContent` as compact JSON), measured as `test/text-bound.mjs` measures them, sizes
620
+ only (#2781). Until now production's size was measured only when someone ran
621
+ `npm run text-bound:live`. Its loopback forwarder serves under a base as long as the API base it
622
+ stands for, because the package quotes its base and the cut fills its budget to the character,
623
+ so the npm leg's sizes are the ones a client of that API receives. The synthetic's monitoring
624
+ script gains two size distributions, two dashboard tiles for each (each tool's mean, and its
625
+ largest answer of the hour to its 1,000-character bucket), and an alert past #2617's 45,000
626
+ characters of text or #3013's 30,000 of `structuredContent`.
627
+ - Repository only, not in the package: `test/text-bound-live.mjs --answers
628
+ test/fixtures/prod-answers --record` no longer re-dates the production record (#2782). Through
629
+ 3.0.2 it wrote its own time as `measured_at`, so the record said the kept answers were read 6 days
630
+ after production served them. `measured_at` is now when production was read and `read_ms` how
631
+ long that read took, and a re-measure keeps both. The code that measured is recorded apart, as
632
+ `code_measured_at` and `code_version` (package.json's version, `+unreleased` while the CHANGELOG's
633
+ newest section is unreleased). The read also records how old each answer it kept was, by the
634
+ answer's own `generated_at` (`answers_age_ms`): the API serves a radar's aggregate stale past
635
+ its 60-second cache to the first request after it, and the CDN 300 seconds more, so an honest
636
+ read can get answers minutes old (two of 2026-10-05's were over 140 seconds), and no fixed
637
+ allowance holds. The suite fails when `measured_at` less a kept answer's `generated_at` is not
638
+ the age the read recorded for it. The record's `about` is now written by the harness. The suite
639
+ also holds each production-shaped case's `structuredContent` size within 10% of production's
640
+ recorded one, as it held the text (#3273). search_cves' default page now repeats rows of the
641
+ default page itself, and lands where production's size does: 8,927 characters against
642
+ production's 9,064, where it was 28,336. Production's cut level for that call is inferred from
643
+ its size, not measured, until the next `--hosted --record` writes its `truncated`. Two cases
644
+ past 10% are named with what clears each (check_sbom's Juice Shop fixture and
645
+ epss_history's record), and `--record` writes each case's `data_truncated` and `truncated`.
646
+ - Hosted endpoint only: `Dockerfile.http` builds both its stages on
647
+ `node:22.23.3-alpine3.24@sha256:0a7108bf…`, pinned by its index digest, the base `Dockerfile`
648
+ (the stdio image) pins (#3018). It named the moving tag `node:22-alpine` (the same image on
649
+ 2026-10-18), so two deploys of one commit could ship different base images with no record of
650
+ which. A test holds both files to one pinned base. A production deploy of the hosted endpoint
651
+ (EchelonGraph's deploy-all.sh run for it, or for every service) also scans the image it just built
652
+ for vulnerabilities with Trivy, before the push, and only that run does: no other deploy scans
653
+ it, and CI's scan job for it is not running yet. A HIGH or CRITICAL vulnerability with a fixed
654
+ version refuses the deploy; one with no fixed version is named and does not refuse; a scan that
655
+ did not run or cannot be read refuses. The scanner runs only as a pinned Trivy release's binary
656
+ (v0.75.0, by its sha256), from an empty directory with no `TRIVY_*` variable, its severities (HIGH,
657
+ CRITICAL) and an empty ignore file on its command line, so neither the deployer's environment
658
+ nor a stray `.trivyignore` or `trivy.yaml` narrows the scan. Another binary runs only with a
659
+ reason given (`MCP_REMOTE_TRIVY_OVERRIDE_REASON`), after a warning that names both sha256s and
660
+ its version, and the revision is then labelled `mcp-trivy=override`, never `clean`. A binary
661
+ that reports Trivy 0.69.4, 0.69.5 or 0.69.6 (the releases GHSA-69fq-xp46-6x23 names) is refused.
662
+ An operator can deploy anyway with `MCP_REMOTE_SCAN_OK=1` and a reason, and the revision is then
663
+ labelled `mcp-trivy=overridden`. The scan's summary names the image by its local config ID, not the
664
+ registry digest a deployed revision reports; a release revision records that digest
665
+ (`mcp-release-digest`).
666
+ - Repository only, not in the package: the production synthetic compares the hosted endpoint with
667
+ the npm package it runs (#3097). When the endpoint reports the version the npm leg runs, each
668
+ era's tools (every field `tools/list` gives: name, title, description, inputSchema, outputSchema,
669
+ annotations), prompts, resources and instructions are compared, and one more probe line per era,
670
+ tool `(hosted-vs-npm)` on transport `http`, fails naming the first differences (`tool cve_intel:
671
+ description differs at character 221`). On 2026-10-11 the two both reported 2.7.1 and served
672
+ different cve_intel text, and each leg alone passed. When the endpoint reports another version
673
+ than the npm leg runs (a rollback to an older release), it is compared with npm's own package of
674
+ the version it reports, installed once for the run, and an endpoint reporting a version npm does
675
+ not serve fails too (`hosted_version_not_on_npm`); the run-complete line's `hosted_vs_npm` and
676
+ `hosted_vs_npm_at` say what was compared.
677
+
9
678
  ## 3.0.2 — 2026-10-17
10
679
 
11
680
  3.0.1 was tagged but never published to npm: its release run's memory test
@@ -79,9 +748,10 @@ flight, the hosted endpoint needed the whole of its image's 128 MiB heap, and V8
79
748
  again. In 3.0.0 a Claude Code session that listed the tools under 2.7.2 failed every tool's
80
749
  call: "data must NOT have additional properties", from `data_truncated`, which 3.0.0 added to
81
750
  every success. A field added at the top level in a minor release no longer breaks such a client.
82
- The failure branch and each tool's `data` schema stay closed, so a field added inside `data`
83
- still needs a reconnect, and the server still checks its own answers against the closed success
84
- branch (`test/checked-strict.test.mjs`). If you see "does not match the tool's output schema" after an upgrade, reconnect
751
+ The failure branch stays closed, and the server still checks its own answers against the closed
752
+ success branch (`test/checked-strict.test.mjs`). This entry said each tool's `data` schema stays
753
+ closed too: `data` was open at its root for 14 of 16 tools, and 86 objects below a success's
754
+ root were closed, in and outside `data`, until #3268 (3.1.0). If you see "does not match the tool's output schema" after an upgrade, reconnect
85
755
  the client (README, Versioning).
86
756
  - `test/text-bound-live.mjs --hosted` calls a 3.0.0 or later endpoint with `full: true`. It
87
757
  refuses to replay a cut `data` as the API's answer (#3140). Without `full: true`, get_cve
@@ -289,8 +959,9 @@ structured result needs no change; it gets an answer where 2.7.x gave Claude Cod
289
959
  references NVD tagged Patch or Mitigation, and patches. Nothing is generated; every text is its
290
960
  source's, untested by EchelonGraph. none_in_source, not_parsed and an empty list are not a
291
961
  finding that no fix exists; a REJECTED CVE answers not_assessed. 15 tools. Its text for
292
- CVE-2021-44228 on production's data is 20,644 characters (assembled before the route was
293
- deployed; re-measure live).
962
+ CVE-2021-44228 on production's data was 20,644 characters, assembled before the route was
963
+ deployed; measured live on 2026-10-18, 22,571 characters on the hosted endpoint and on npm 3.0.2,
964
+ and 18,293 with an installed version (#2841).
294
965
  - get_cve relays remediation, a capped summary (at most 1,500 characters of JSON) of the same.
295
966
  - vendor_advisories_for_cve rows carry remediation_kinds and remediation_state, and
296
967
  get_vendor_advisory carries remediations, each item with its kind, verbatim text and URL