echelongraph-mcp 2.6.3 → 2.6.5

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/dist/resources.js CHANGED
@@ -5,16 +5,19 @@
5
5
  // docs). No API request.
6
6
  // echelongraph://sources the feeds and what the API itself reports about their polling,
7
7
  // read from the API at request time and never hard-coded: the CVE
8
- // summary's poller block (/api/v1/public/cves/summary) and the KEV
8
+ // summary's poller block (/api/v1/public/cves/summary; its interval,
9
+ // last poll and two counters, the answering instance's) and the KEV
9
10
  // answer's catalog block and method (/api/v1/public/kev/recent).
10
11
  // What no endpoint reports is named as not reported.
11
12
  // cve://{cve_id} get_cve's structured result for one CVE, as JSON: the envelope
12
- // (state, measured_at, method, notes) with data, the API's record.
13
+ // (state, measured_at, method, notes) with data, the API's record,
14
+ // cut past 30,000 characters as get_cve's first text block is.
13
15
  //
14
16
  // A resource read either answers or is a JSON-RPC error; it never answers a failure as data that
15
17
  // reads like a record. sources is the exception by design: each feed it reads carries its own
16
18
  // state, and a feed that could not be read says so (state failed), never an empty block.
17
19
  import { ProtocolError, ProtocolErrorCode, ResourceNotFoundError, ResourceTemplate } from "@modelcontextprotocol/server";
20
+ import { DATA_TEXT_BUDGET, dataText } from "./textBudget.js";
18
21
  export const METHODOLOGY_URI = "echelongraph://methodology";
19
22
  export const SOURCES_URI = "echelongraph://sources";
20
23
  export const CVE_TEMPLATE = "cve://{cve_id}";
@@ -22,7 +25,7 @@ export const METHODOLOGY = `# How EchelonGraph MCP results are measured
22
25
 
23
26
  Every tool result carries an evidence envelope in its structuredContent, and repeats it in its last text block (less what an earlier text block already carries verbatim), so a client that passes text blocks alone still sees it.
24
27
 
25
- A success's first text block is the API's JSON, cut to fit 30,000 characters when it is longer: the note then says what the cut leaves out and how to read it (TEXT CUT), and structuredContent's data holds the answer whole.
28
+ A success's first text block is the API's JSON, cut to fit 30,000 characters when it is longer: the note then says what the cut leaves out and how to read it (TEXT CUT), and structuredContent's data holds the answer whole. The resource ${CVE_TEMPLATE} cuts its data the same way, and its notes say so (DATA CUT).
26
29
 
27
30
  ## Envelope fields
28
31
 
@@ -34,7 +37,7 @@ A success's first text block is the API's JSON, cut to fit 30,000 characters whe
34
37
  | coverage | What the answer covers, where the tool can say: for example in_scope for cve_exposure, the list's account of its own count for search_cves, assessed and not_assessed_reason first for check_affected. |
35
38
  | freshness | The producing radar's or fetcher's last completed check, where the API serves one; null where it serves none. |
36
39
  | notes | The caveats, one sentence each. |
37
- | data | On a success: the API's JSON (exposure_radar and cve_intel relay a labelled selection). |
40
+ | data | On a success: the API's JSON (exposure_radar and cve_intel relay a labelled selection, and cve_summary leaves out a poller field of another JSON type, named in the note). |
38
41
  | error | On a failure: kind, path, status and message. |
39
42
 
40
43
  ## state
@@ -70,9 +73,17 @@ const KEV_PATH = "/api/v1/public/kev/recent";
70
73
  const obj = (v) => (v !== null && typeof v === "object" && !Array.isArray(v) ? v : undefined);
71
74
  const pick = (o, keys) => Object.fromEntries(keys.filter((k) => o !== undefined && o[k] !== undefined).map((k) => [k, o[k]]));
72
75
  const failure = (r) => ({ state: "failed", error: { kind: r.kind, path: r.path, status: r.status ?? null, message: r.detail } });
73
- // What /api/v1/public/cves/summary reports about the NVD poller: its interval and last poll, of
74
- // the server instance that answered (core-backend cve/poller.go Stats, in memory).
76
+ // A JSON value's type in words, for a value that is not the JSON object read here.
77
+ const jsonKind = (v) => Array.isArray(v) ? "an array" : typeof v === "number" ? "a number" : typeof v === "boolean" ? "a boolean" : typeof v === "string" ? "a string" : typeof v;
78
+ // What /api/v1/public/cves/summary reports about the NVD poller: its interval and last poll, and
79
+ // its poll and failed-poll counts, of the server instance that answered (core-backend
80
+ // cve/poller.go Stats, in memory).
75
81
  const POLLER_FIELDS = ["interval", "last_poll_at", "poll_count", "poll_errors"];
82
+ // #2770: poll_count and poll_errors are that instance's counters since it last started, zeroed on
83
+ // every restart (cve_summary's summaryPollerNote, #2647, says the same of the whole block). 2.6.3
84
+ // relayed them here with a note on interval and last_poll_at alone, so poll_errors 0 over
85
+ // poll_count 7 read as the feed's track record. Whenever reported carries either, notes says so.
86
+ const SOURCES_POLLER_COUNTERS_NOTE = "poll_count and poll_errors, where reported carries them, are that one instance's counters since it last started, zeroed on every restart: they count that instance's polls and failed polls, never the feed's reliability.";
76
87
  const CATALOG_FIELDS = ["source", "feed_url", "last_successful_fetch_at", "catalog_version", "date_released", "catalog_count"];
77
88
  export async function readSources(kit) {
78
89
  const [summary, kev] = await Promise.all([kit.api(SUMMARY_PATH), kit.api(KEV_PATH, { headers: { "X-EG-Limit": "1" } })]);
@@ -80,16 +91,29 @@ export async function readSources(kit) {
80
91
  if (!summary.ok)
81
92
  Object.assign(nvd, failure(summary));
82
93
  else {
83
- const poller = obj(obj(summary.data)?.poller);
84
- if (poller === undefined) {
94
+ const sent = obj(summary.data)?.poller;
95
+ const poller = obj(sent);
96
+ if (poller === undefined && sent !== undefined && sent !== null) {
97
+ // #2770's review: a poller of another JSON type (cve_summary leaves it out and names it,
98
+ // #2771) is one the answer carries, so it is never described as missing.
99
+ Object.assign(nvd, {
100
+ state: "not_reported",
101
+ reported: null,
102
+ notes: [`The answer carries poller as ${jsonKind(sent)}, where a JSON object was expected, so it reports neither a poll interval nor a last poll time.`],
103
+ });
104
+ }
105
+ else if (poller === undefined) {
85
106
  Object.assign(nvd, { state: "not_reported", reported: null, notes: ["The answer carries no poller block, so it reports neither a poll interval nor a last poll time."] });
86
107
  }
87
108
  else {
109
+ const reported = pick(poller, POLLER_FIELDS);
110
+ const counted = reported.poll_count !== undefined || reported.poll_errors !== undefined;
88
111
  Object.assign(nvd, {
89
112
  state: "reported",
90
- reported: pick(poller, POLLER_FIELDS),
113
+ reported,
91
114
  notes: [
92
115
  "poller is the NVD poller of the server instance that answered, held in its memory: interval is its configured interval (each cycle adds a start-up jitter), and last_poll_at is that instance's last poll, not a fleet-wide time.",
116
+ ...(counted ? [SOURCES_POLLER_COUNTERS_NOTE] : []),
93
117
  ],
94
118
  });
95
119
  }
@@ -121,6 +145,31 @@ export async function readSources(kit) {
121
145
  };
122
146
  }
123
147
  const json = (uri, v) => ({ contents: [{ uri, mimeType: "application/json", text: JSON.stringify(v, null, 2) }] });
148
+ // cve://{cve_id}'s text (#2801). Through 2.6.3 it was get_cve's structuredContent pretty-printed
149
+ // whole: 86,559 characters for CVE-2021-44228 on the hosted endpoint (2026-10-04), past #2467's
150
+ // ceiling of 60,000 (test/text-bound.mjs), its notes ending with get_cve's TEXT CUT sentences, which
151
+ // describe a first text block the resource does not have ("Every list in it keeps at most its first
152
+ // 64 entries: cpe_match (396)…" beside all 396 of them). A resource is one text, with no
153
+ // structuredContent beside it, so its data is now cut as get_cve's first text block is (dataText:
154
+ // the same answer, cut the same way, laid out as that block lays it out) after the envelope,
155
+ // pretty-printed as before; its notes leave out get_cve's sentences about its first text block and,
156
+ // when data is cut, end with the resource's own, starting DATA CUT, which names get_cve's
157
+ // structuredContent.data as the record whole. A record that fits is the structured result
158
+ // pretty-printed, character for character as before.
159
+ const CVE_RESOURCE_CUT = {
160
+ opens: (chars) => `DATA CUT: the API's answer is ${chars} characters of JSON, more than the ${DATA_TEXT_BUDGET} this resource's data holds, so data here is cut, and get_cve's structuredContent.data carries the answer whole.`,
161
+ };
162
+ export function cveResourceText(structured, sentences) {
163
+ const { data, ...envelope } = structured;
164
+ if (data === null || typeof data !== "object" || Array.isArray(data))
165
+ return JSON.stringify(structured, null, 2);
166
+ const firstBlock = new Set(sentences(dataText(data).said));
167
+ const own = dataText(data, undefined, CVE_RESOURCE_CUT);
168
+ const notes = Array.isArray(envelope.notes) ? [...envelope.notes.filter((n) => !firstBlock.has(n)), ...sentences(own.said)] : envelope.notes;
169
+ const head = JSON.stringify({ ...envelope, ...(notes === undefined ? {} : { notes }) }, null, 2);
170
+ const body = own.text.replace(/\n/g, "\n ");
171
+ return head === "{}" ? `{\n "data": ${body}\n}` : `${head.slice(0, -2)},\n "data": ${body}\n}`;
172
+ }
124
173
  export function registerResources(server, kit) {
125
174
  server.registerResource("methodology", METHODOLOGY_URI, {
126
175
  title: "How results are measured",
@@ -129,12 +178,12 @@ export function registerResources(server, kit) {
129
178
  }, async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: METHODOLOGY }] }));
130
179
  server.registerResource("sources", SOURCES_URI, {
131
180
  title: "Feed sources and polling",
132
- description: "The feeds and what the API reports about their polling, read from the API when the resource is read: the NVD poller's interval and last poll (of the instance that answered) and the CISA KEV catalog's last successful fetch and method. What no endpoint reports is named as not reported.",
181
+ description: "The feeds and what the API reports about their polling, read from the API when the resource is read: the NVD poller's interval, last poll, poll_count and poll_errors, all of the one instance that answered (the two counts since that instance last started, zeroed on every restart, never the feed's reliability), and the CISA KEV catalog's last successful fetch and method. What no endpoint reports is named as not reported.",
133
182
  mimeType: "application/json",
134
183
  }, async (uri) => json(uri.href, await readSources(kit)));
135
184
  server.registerResource("cve", new ResourceTemplate(CVE_TEMPLATE, { list: undefined }), {
136
185
  title: "CVE record",
137
- description: "One CVE's record as get_cve returns it, as JSON: the evidence envelope (state, measured_at, method, notes) with data, the API's record. cve_id is a CVE ID such as CVE-2024-3400.",
186
+ description: "One CVE's record as get_cve returns it, as JSON: the evidence envelope (state, measured_at, method, notes) with data, the API's record. Past 30,000 characters of JSON, data is cut as get_cve's first text block is, and a note starting DATA CUT says what the cut leaves out; get_cve's structured result holds the record whole. cve_id is a CVE ID such as CVE-2024-3400.",
138
187
  mimeType: "application/json",
139
188
  }, async (uri, vars) => {
140
189
  const raw = Array.isArray(vars.cve_id) ? vars.cve_id[0] : vars.cve_id;
@@ -150,6 +199,6 @@ export function registerResources(server, kit) {
150
199
  throw new ResourceNotFoundError(uri.href);
151
200
  throw new ProtocolError(ProtocolErrorCode.InternalError, msg, { uri: uri.href, state: r.structuredContent.state ?? null, error: err ?? null });
152
201
  }
153
- return json(uri.href, r.structuredContent);
202
+ return { contents: [{ uri: uri.href, mimeType: "application/json", text: cveResourceText(r.structuredContent, kit.sentences) }] };
154
203
  });
155
204
  }
package/dist/runtime.js CHANGED
@@ -18,12 +18,34 @@ export function markHttpEntrypoint() {
18
18
  export function isHttpEntrypoint() {
19
19
  return httpEntrypoint;
20
20
  }
21
- // The client a tool call is being answered for. http.ts runs each request inside
22
- // withClient(), and the SDK dispatches the tool handler inside that same async context, so
23
- // api() can read it without the tool module passing it through every function.
24
21
  const clientStore = new AsyncLocalStorage();
25
22
  export function withClient(clientIp, fn, opts = {}) {
26
- return clientStore.run({ clientIp, synthetic: opts.synthetic === true }, fn);
23
+ return clientStore.run({ clientIp, synthetic: opts.synthetic === true, signal: opts.signal, work: opts.work }, fn);
24
+ }
25
+ // #2775: a hosted client can go before its answer is ready (its own timeout, a closed tab, a
26
+ // retry). Nobody will read that answer, so the request's API calls are cancelled (index.ts api()
27
+ // reads this signal) and check_sbom sends no further batch: they would spend the client's API
28
+ // budget and hold the instance's memory for nothing. On stdio there is none: the SDK's own
29
+ // cancellation (notifications/cancelled) reaches check_sbom through its handler's signal.
30
+ export function requestSignal() {
31
+ return httpEntrypoint ? clientStore.getStore()?.signal : undefined;
32
+ }
33
+ /** Registers `p` as the hosted request's work, so its admission and large-body slot are held until p settles; on stdio, p untouched. */
34
+ export function holdRequest(p) {
35
+ const work = httpEntrypoint ? clientStore.getStore()?.work : undefined;
36
+ if (!work)
37
+ return p;
38
+ work.add(p);
39
+ const drop = () => {
40
+ work.delete(p);
41
+ };
42
+ p.then(drop, drop);
43
+ return p;
44
+ }
45
+ /** Resolves once everything held for a request (holdRequest) has settled, including what is held while it waits. */
46
+ export async function requestSettled(work) {
47
+ while (work.size > 0)
48
+ await Promise.allSettled([...work]);
27
49
  }
28
50
  // #2737: the production MCP synthetic probes this endpoint too, and its calls must reach the API
29
51
  // under its own family, never as hosted MCP adoption. The synthetic names itself to this service
@@ -34,9 +34,13 @@
34
34
  // 4. else every list keeps its first n entries, n halving, and then strings are cut too.
35
35
  // Whenever the text is not the JSON whole, the note ends with one sentence, starting TEXT CUT,
36
36
  // that says what the first text block leaves out, that structuredContent.data carries all of it,
37
- // and how to read the rest in the text: the tool that returns one row whole, and the page size or
38
- // offset at which a page fits. The cut is deterministic: the same answer is cut the same way.
37
+ // and how to read the rest in the text: the tool that returns one row, and what of it, and the page
38
+ // size or offset at which a page fits. The cut is deterministic: the same answer is cut the same way.
39
39
  export const DATA_TEXT_BUDGET = 30_000;
40
+ // What a note may say of reading one CVE's record through get_cve (#2802): get_cve's own first text
41
+ // block is cut too past the budget (each list to its first entries, every other field kept), so
42
+ // only its structuredContent.data is the record whole. A row tool's `whole` sentence starts with it.
43
+ export const GET_CVE_WHOLE = "get_cve returns any one of these CVEs' records, whole in its structuredContent.data, and in its first text block with every field (past 30,000 characters, each list cut to its first entries)";
40
44
  // What every tool description says about it, one constant string (marketing-site
41
45
  // lib/mcpToolClaims.test.ts folds only constant strings); the suite holds its number to
42
46
  // DATA_TEXT_BUDGET's. It uses none of the words that check reviews as the page's own prose (how,
@@ -158,8 +162,11 @@ function cutSides(data, sides) {
158
162
  }
159
163
  return { fields, said };
160
164
  }
165
+ export const FIRST_TEXT_BLOCK = {
166
+ opens: (chars) => `TEXT CUT: the API's answer is ${chars} characters of JSON, more than the ${DATA_TEXT_BUDGET} the first text block holds, so that block is cut, and structuredContent.data carries the answer whole.`,
167
+ };
161
168
  /** The first text block for `data` within DATA_TEXT_BUDGET, and the TEXT CUT sentence when it is cut. */
162
- export function dataText(data, cut) {
169
+ export function dataText(data, cut, place = FIRST_TEXT_BLOCK) {
163
170
  const pretty = JSON.stringify(data, null, 2);
164
171
  if (pretty.length <= DATA_TEXT_BUDGET)
165
172
  return { text: pretty, said: "" };
@@ -167,7 +174,7 @@ export function dataText(data, cut) {
167
174
  const all = layout(data);
168
175
  if (all.length <= DATA_TEXT_BUDGET)
169
176
  return { text: all, said: "" };
170
- const head = `TEXT CUT: the API's answer is ${pretty.length} characters of JSON, more than the ${DATA_TEXT_BUDGET} the first text block holds, so that block is cut, and structuredContent.data carries the answer whole.`;
177
+ const head = place.opens(pretty.length);
171
178
  const say = (...xs) => [head, ...xs].filter((x) => Boolean(x)).join(" ");
172
179
  let base = data;
173
180
  let rowsSaid = "";
@@ -27,7 +27,7 @@
27
27
  // The JSON is relayed verbatim as data (#1874): every match keeps kev_listed, ransomware,
28
28
  // epss_score, effective_score, effective_severity and score_assessed as the API sent them.
29
29
  import * as z from "zod";
30
- import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
30
+ import { GET_CVE_WHOLE, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
31
31
  const TOOL = "check_affected";
32
32
  export const MATCH_PATH = "/api/v1/public/cves/match";
33
33
  // handler.go, the four request headers the endpoint reads before the query string.
@@ -129,7 +129,7 @@ export const CHECK_AFFECTED_TITLE = "Am I affected? (product or package at a ver
129
129
  export const CHECK_AFFECTED_DESCRIPTION = "Whether a product or package at a given version is affected by known CVEs, from the same matcher as echelongraph.io/am-i-affected. Two lookup paths. The CPE path takes product (the NVD CPE product token, such as openssl or nginx) and version, and returns the CVEs whose NVD CPE match criteria name that product with a version range that includes the version; it matches the token across vendors, so each match names the vendor NVD asserts (cpe_vendor) and whether that vendor was verified (vendor_unknown). The registry path takes ecosystem (npm, PyPI, Maven and other OSV ecosystem names), package and version, and decides each OSV advisory record EchelonGraph holds for that package as affected, not affected or undetermined. Read assessed before count: assessed false means the lookup did not evaluate this component, not_assessed_reason says why (product_not_in_cpe_corpus, package_not_cpe_nameable, candidate_load_pending, candidate_window_truncated, package_not_in_advisory_corpus, no_decidable_advisory), the structured result's state is not_assessed, and a count of 0 there must never be reported as not affected. An advisory whose version range cannot be decided at this version is reported as undetermined (undetermined_count, and up to 50 of them in undetermined), never as safe. The answer also carries match_layer (which path answered), capped (the match list stopped at its cap), candidates_capped (not every candidate CVE was loaded), excluded_count (CPE candidates suppressed by the vendor or platform gate), not_affected_count (advisories decided in your favour) and degraded (the lookup ran out of time). Each match carries cve_id, kev_listed, ransomware, epss_score, effective_score, effective_severity and score_assessed (false: EchelonGraph has not scored the CVE yet, so its echelongraph_score is withheld). Product, version, ecosystem and package travel in request headers, never in the URL. Its structured result carries state (measured only when the answer says assessed true), measured_at (null), method (which matcher answered), coverage (assessed and not_assessed_reason first, then the lookup path and the counts above), freshness (null) and notes, with data equal to the API's JSON." +
130
130
  " " +
131
131
  TEXT_BUDGET_DESCRIPTION +
132
- " Cut, each entry of the excluded and undetermined samples keeps its cve_id and reason, cve_ids keeps its first 10 (each match carries its cve_id), and each match keeps fewer fields, cve_id, kev_listed, effective_score, effective_severity and score_assessed at least, so that every match stays in the text.";
132
+ " Cut, the excluded and undetermined samples keep their first 10 entries, each its cve_id and reason, cve_ids keeps its first 10 (each match carries its cve_id), and each match keeps fewer fields, cve_id, kev_listed, ransomware, epss_score, effective_score and score_assessed at least, so that every match stays in the text.";
133
133
  export const CHECK_AFFECTED_INPUT = z.object({
134
134
  product: z.string().optional().describe("CPE path: the NVD CPE product token, such as openssl, nginx or linux_kernel. Leave out for the registry path."),
135
135
  ecosystem: z.string().optional().describe("Registry path: the package's ecosystem, such as npm, PyPI or Maven. Give with package, not with product."),
@@ -174,15 +174,23 @@ function matchesNote(matches) {
174
174
  // text block keeps its scores, flags, vendor attribution and match_reason (for a registry match,
175
175
  // the advisory interval its version falls inside, which names the fixed version) with the
176
176
  // description cut to 200 characters; then the same without the description; then only the fields
177
- // the description promises; and last, for a list at the cap, cve_id, the effective score and
178
- // band, kev_listed and score_assessed. The lists beside the matches, the excluded sample (up to 50
179
- // CPE candidates the vendor or platform gate suppressed, each with a sentence of detail, 13,861
180
- // characters for flash_player 10.0.0) and the undetermined sample (up to 50), keep each entry's
181
- // cve_id and reason once the first level does not fit with them whole, before any match field
182
- // that level keeps goes, and the registry path's cve_ids, which is each match's cve_id in the
183
- // order of matches (core-backend cve/handler.go), keeps its first 10, so that every match stays
184
- // in the text, 200 at the cap with 50 undetermined beside them: this tool has no page to read
185
- // more from. structuredContent.data keeps every list whole.
177
+ // the description promises; then, for a list at the cap, without cpe_vendor and vendor_unknown;
178
+ // and last without effective_severity, the band of effective_score. kev_listed, ransomware,
179
+ // epss_score, effective_score and score_assessed stay in every level (#2799): the am_i_affected
180
+ // prompt asks for each match's ransomware and epss_score, and through 2.6.3 the last level left
181
+ // them out of every match of a list at the cap (hosted linux_kernel 5.10.0, 200 matches, 1 with
182
+ // known ransomware use, named nowhere in the text). 200 matches with effective_severity beside
183
+ // them are 33,240 characters, past the budget alone, so the band goes first. The lists beside the
184
+ // matches, the excluded sample (up to 50 CPE candidates the vendor or platform gate suppressed,
185
+ // each with a sentence of detail, 13,861 characters for flash_player 10.0.0) and the undetermined
186
+ // sample (up to 50), keep their first 10 entries, each its cve_id and reason, once the first level
187
+ // does not fit with them whole, before any match field that level keeps goes (50 of them beside
188
+ // 200 matches left the last level 120 characters past the budget for flash_player 10.0.0, and
189
+ // 1,071 for a registry answer at the cap with 50 undetermined); and the registry path's cve_ids,
190
+ // which is each match's cve_id in the order of matches (core-backend cve/handler.go), keeps its
191
+ // first 10, so that every match stays in the text, 200 at the cap: this tool has no page to read
192
+ // more from. excluded_count and undetermined_count count every entry. structuredContent.data keeps
193
+ // every list whole.
186
194
  const CHECK_AFFECTED_TEXT = {
187
195
  rows: "matches",
188
196
  levels: [
@@ -208,14 +216,15 @@ const CHECK_AFFECTED_TEXT = {
208
216
  clip: 100,
209
217
  },
210
218
  { keep: ["cve_id", "kev_listed", "ransomware", "epss_score", "effective_score", "effective_severity", "score_assessed", "cpe_vendor", "vendor_unknown"], clip: 100 },
211
- { keep: ["cve_id", "kev_listed", "effective_score", "effective_severity", "score_assessed"], clip: 100 },
219
+ { keep: ["cve_id", "kev_listed", "ransomware", "epss_score", "effective_score", "effective_severity", "score_assessed"], clip: 100 },
220
+ { keep: ["cve_id", "kev_listed", "ransomware", "epss_score", "effective_score", "score_assessed"], clip: 100 },
212
221
  ],
213
222
  sides: [
214
223
  { list: "cve_ids", cap: 10, said: () => "cve_ids lists each match's cve_id, in the order of matches." },
215
- { list: "excluded", keep: ["cve_id", "reason"], clip: 100 },
216
- { list: "undetermined", keep: ["cve_id", "reason"], clip: 100 },
224
+ { list: "excluded", keep: ["cve_id", "reason"], clip: 100, cap: 10 },
225
+ { list: "undetermined", keep: ["cve_id", "reason"], clip: 100, cap: 10 },
217
226
  ],
218
- whole: "get_cve returns any one of these CVEs' records whole, and cve_intel a CVE's affected packages and fixed versions.",
227
+ whole: `${GET_CVE_WHOLE}, and cve_intel a CVE's affected packages and fixed versions.`,
219
228
  };
220
229
  async function checkAffected(kit, a) {
221
230
  const product = (a.product ?? "").trim();
@@ -42,6 +42,12 @@ const NOTE_AFFECTED_MAX = 25;
42
42
  const NOTE_CVES_MAX = 5;
43
43
  // CycloneDX nests components; this bounds the walk.
44
44
  const MAX_DEPTH = 32;
45
+ // The order in which extract() reads and counts the distinct purls (#2799 review): purls as listed;
46
+ // a CycloneDX document's components depth first, each one's nested components right after it
47
+ // (fromCycloneDX); an SPDX document's packages as listed; each purl trimmed and kept at its first
48
+ // place (dedupe). The note says it beside the position from which the purls not sent run, so a
49
+ // second call can rebuild them from its own input whether or not the text cuts not_sent_purls.
50
+ const READ_ORDER = "counted in the order this tool read them (the order of purls, or of the document's components or packages, each component's nested components right after it and before its next sibling, each purl at its first place)";
45
51
  const METHOD = "EchelonGraph's registry advisory matcher (POST /api/v1/public/cves/match/batch): each component's 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, the same matcher GET /api/v1/public/cves/match uses when given an ecosystem. No CPE matching, no score, no ranking.";
46
52
  // Always in the envelope's notes, whatever the answer: the refusals a model must not read as clean.
47
53
  const DISTRO_NOTE = "A deb, apk or rpm purl without a distro qualifier naming its release (for example ?distro=debian-12 or ?distro=alpine-3.20) is not assessed (distro_release_unknown): the advisory corpus is keyed per distro release, and EchelonGraph does not guess one.";
@@ -68,7 +74,8 @@ export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGr
68
74
  // fit with that list whole, the text keeps its first 10 and the note says which they are: the
69
75
  // input's distinct purls from position `sent` + 1 on, in input order, which is how check_sbom read
70
76
  // them (x.purls.slice(answers.length * MAX_COMPONENTS)), so a second call can send them without
71
- // reading the list.
77
+ // reading the list. noteFor gives that order (READ_ORDER) beside the position, so this sentence
78
+ // points to it.
72
79
  export function sbomText(sent) {
73
80
  const matches = ["cve_id", "severity", "effective_severity", "effective_score", "kev_listed", "ransomware", "epss_score", "score_assessed"];
74
81
  const row = ["index", "purl", "ecosystem", "package", "version", "verdict", "assessed", "not_assessed_reason", "count", "not_affected_count", "undetermined_count", "cve_ids"];
@@ -83,11 +90,13 @@ export function sbomText(sent) {
83
90
  {
84
91
  list: "not_sent_purls",
85
92
  cap: 10,
86
- said: (_inText, total) => `The ${total} purls not sent are the input's distinct purls from position ${sent + 1} on, in the order this tool read them (the order of purls, or of the document's components, nested ones after their parent, each purl at its first place), so a second call can send them without reading the list.`,
93
+ said: (_inText, total) => `The ${total} purls not sent are the input's distinct purls from position ${sent + 1} on, counted in the order the note gives for them, so a second call can send them without reading the list.`,
87
94
  },
88
95
  ],
89
96
  leaveOutFirst: { field: "verdict", values: ["not_affected", "not_assessed"], why: "data.summary counts every verdict" },
90
- whole: "check_affected, given a row's ecosystem, package and version, returns that component's matches whole, and cve_intel a CVE's affected packages and fixed versions.",
97
+ // check_affected cuts its text too past the budget (#2802): only its structuredContent.data is
98
+ // a component's matches whole.
99
+ whole: "check_affected, given a row's ecosystem, package and version, returns that component's matches, whole in its structuredContent.data, and cve_intel a CVE's affected packages and fixed versions.",
91
100
  page: (shown) => `To read every row in the text, check ${shown} or fewer purls per call.`,
92
101
  };
93
102
  }
@@ -236,9 +245,17 @@ function noteFor(head, x, d, g) {
236
245
  if (g.waits > 0) {
237
246
  out.push(`The API's component budget (${API_COMPONENTS_PER_MINUTE} components a minute) ran out ${g.waits === 1 ? "once" : `${g.waits} times`}: the tool waited ${Math.round(g.waited_ms / 1000)} s in all, as its Retry-After asked, and sent the batch again.`);
238
247
  }
248
+ // #2799: the position, not only the list. Past DATA_TEXT_BUDGET the first text block keeps the
249
+ // first 10 of data.not_sent_purls (sbomText), so a model that reads the text alone cannot send
250
+ // "that list"; the purls not sent are always the input's distinct purls from position sent + 1 on
251
+ // (x.purls.slice(answers.length * MAX_COMPONENTS)), which it can rebuild from its own input, given
252
+ // the order they are counted in (READ_ORDER), which this sentence says whenever it gives the
253
+ // position, not only when the text cuts the list.
239
254
  const notSent = g.not_sent_purls.length;
240
255
  if (notSent > 0) {
241
- out.push(`${plural(notSent, "purl was", "purls were")} NOT sent (not_sent_reason ${g.not_sent_reason}: ${g.not_sent_detail}), so ${notSent === 1 ? "it is" : "they are"} not checked and not clean; data.not_sent_purls lists ${notSent === 1 ? "it" : "them"}: call check_sbom again with purls set to that list${g.not_sent_reason === "request_failed" ? "" : " after a minute"}.`);
256
+ const one = notSent === 1;
257
+ const where = one ? `it is the input's distinct purl at position ${g.sent + 1}` : `they are the input's distinct purls from position ${g.sent + 1} on`;
258
+ out.push(`${plural(notSent, "purl was", "purls were")} NOT sent (not_sent_reason ${g.not_sent_reason}: ${g.not_sent_detail}), so ${one ? "it is" : "they are"} not checked and not clean; ${where}, ${READ_ORDER}, and data.not_sent_purls lists ${one ? "it" : "them"}: call check_sbom again with purls set to ${one ? "it" : "them"}${g.not_sent_reason === "request_failed" ? "" : " after a minute"}.`);
242
259
  }
243
260
  out.push(`Of the ${components} components checked: ${affected} affected, ${notAffected} not affected, ${undetermined} undetermined, ${notAssessed} not assessed.`);
244
261
  const by = field(s, "not_assessed_by_reason");
@@ -335,7 +352,17 @@ export function mergeAnswers(answers) {
335
352
  out.results = results;
336
353
  return out;
337
354
  }
338
- const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
355
+ const realSleep = (ms, signal) => new Promise((r) => {
356
+ if (signal?.aborted)
357
+ return r();
358
+ const done = () => {
359
+ clearTimeout(timer);
360
+ signal?.removeEventListener("abort", done);
361
+ r();
362
+ };
363
+ const timer = setTimeout(done, ms);
364
+ signal?.addEventListener("abort", done, { once: true });
365
+ });
339
366
  export async function checkSbom(deps, a) {
340
367
  const tool = CHECK_SBOM;
341
368
  const sleep = deps.sleep ?? realSleep;
@@ -384,12 +411,17 @@ export async function checkSbom(deps, a) {
384
411
  headers: { "Content-Type": "application/json" },
385
412
  body: JSON.stringify({ components: batches[answers.length].map((purl) => ({ purl })) }),
386
413
  timeoutMs: Math.max(0, left),
414
+ signal: deps.signal,
387
415
  });
388
416
  if (r.ok) {
389
417
  answers.push(r.data);
390
418
  status = r.status;
391
419
  continue;
392
420
  }
421
+ // Cancelled (#2775): this batch was cut off, or never sent after a wait the signal ended.
422
+ // Nothing more is sent, and the answer goes nowhere.
423
+ if (deps.signal?.aborted)
424
+ return deps.failed(tool, r);
393
425
  if (r.kind === "http" && r.status === 429) {
394
426
  if (r.retryAfter === undefined) {
395
427
  stop = { reason: "rate_limited", detail: "the API answered 429 without a Retry-After to wait for" };
@@ -400,7 +432,7 @@ export async function checkSbom(deps, a) {
400
432
  else {
401
433
  const waitMs = Math.max(MIN_WAIT_MS, r.retryAfter * 1000);
402
434
  if (now() - started + waitMs <= TIME_BUDGET_MS) {
403
- await sleep(waitMs);
435
+ await sleep(waitMs, deps.signal);
404
436
  waits++;
405
437
  waitedMs += waitMs;
406
438
  waitedFor = r;
@@ -567,9 +599,12 @@ export function registerCheckSbom(server, deps) {
567
599
  sbom: z
568
600
  .union([z.string(), z.record(z.string(), z.unknown())])
569
601
  .optional()
570
- .describe("a CycloneDX JSON or SPDX JSON document, as JSON text or as an object; its purls are read here and only they are sent"),
602
+ .describe("a CycloneDX JSON or SPDX JSON document, as JSON text or as an object; its purls are read by this MCP server and only they are sent to the API; over the hosted endpoint (mcp.echelongraph.io) the document is the request body"),
571
603
  }),
572
604
  outputSchema: output,
573
605
  annotations: deps.annotations,
574
- }, async (a) => deps.checked(CHECK_SBOM, output, await checkSbom(deps, a)));
606
+ }, async (a, ctx) => {
607
+ const run = checkSbom({ ...deps, signal: ctx.mcpReq.signal }, a);
608
+ return deps.checked(CHECK_SBOM, output, await (deps.hold ? deps.hold(run) : run));
609
+ });
575
610
  }
@@ -13,7 +13,7 @@
13
13
  // the note says it can lag the feed.
14
14
  import * as z from "zod";
15
15
  import { ANNOTATIONS, api, badInput, checked, crashed, envelopeSchema, failed, field, lenAt, numAt, okHead, opt, rowsScoreNote, strAt, succeeded, } from "../index.js";
16
- import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
16
+ import { GET_CVE_WHOLE, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
17
17
  const TOOL = "get_cwe";
18
18
  const CWE_ARG = /^(?:CWE-)?0*(\d{1,6})$/i;
19
19
  const MAX_PAGE = 200;
@@ -25,7 +25,7 @@ export const GET_CWE_DESCRIPTION = `One CWE (weakness class) and the CVEs classi
25
25
  const GET_CWE_TEXT = {
26
26
  rows: "cves",
27
27
  levels: [{ keep: ["cve_id", "severity", "cvss_v3_score", "echelongraph_score", "echelongraph_severity", "score_assessed", "score_unassessed_reason", "kev_listed", "published", "description"], clip: 120 }],
28
- whole: "get_cve returns any one of these CVEs' records whole.",
28
+ whole: `${GET_CVE_WHOLE}.`,
29
29
  };
30
30
  function schemas() {
31
31
  const row = z.looseObject({
@@ -1,5 +1,5 @@
1
1
  import * as z from "zod";
2
- import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
2
+ import { GET_CVE_WHOLE, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
3
3
  const TOOL = "kev_recent";
4
4
  const PATH = "/api/v1/public/kev/recent";
5
5
  export const KEV_RECENT_MAX_LIMIT = 200;
@@ -140,7 +140,8 @@ function kevText(a) {
140
140
  { keep: ["cve_id", "kev_added_date", "kev_vendor", "kev_product", "kev_ransomware"], clip: 40 },
141
141
  { keep: ["cve_id", "kev_added_date", "kev_vendor", "kev_ransomware"], clip: 40 },
142
142
  ],
143
- whole: "get_cve returns any one of these CVEs' records whole.",
143
+ // A record carries the CISA fields a row leaves out (kev_due_date, kev_product, kev_vuln_name).
144
+ whole: `${GET_CVE_WHOLE}.`,
144
145
  page: (shown) => `next_cursor continues after the last row of this page, not after the last row in this text: to read the rows left out in the text, call kev_recent again with the same filters${a.cursor?.trim() ? " and cursor" : ""} and a limit of ${shown}, whose rows are the ones in this text, then with that page's next_cursor and a limit of ${shown} or fewer.`,
145
146
  };
146
147
  }
@@ -210,7 +210,9 @@ const ROW_CUT_LEVELS = [
210
210
  { keep: ["vendor", "vendor_display_name", "vendor_advisory_id", "title", "severity", "cvss_v3_score", "cve_ids", "summary", "affected_products", "vendor_published_at", "withdrawn"], clip: 200, cap: 20 },
211
211
  { keep: ["vendor", "vendor_advisory_id", "title", "severity", "cve_ids", "vendor_published_at", "withdrawn"], clip: 100, cap: 10 },
212
212
  ];
213
- const WHOLE = "get_vendor_advisory returns any one of these advisories whole, by its vendor and vendor_advisory_id.";
213
+ // get_vendor_advisory's own first text block is cut too past the budget (#2802): only its
214
+ // structuredContent.data is one advisory whole.
215
+ const WHOLE = "get_vendor_advisory returns any one of these advisories by its vendor and vendor_advisory_id, whole in its structuredContent.data, and in its first text block with every field (past 30,000 characters, each list cut to its first entries).";
214
216
  const FOR_CVE_TEXT = { rows: "advisories", levels: ROW_CUT_LEVELS, whole: WHOLE };
215
217
  const searchText = (a) => ({
216
218
  rows: "advisories",
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "echelongraph-mcp",
3
- "version": "2.6.3",
3
+ "version": "2.6.5",
4
4
  "mcpName": "io.echelongraph/echelongraph-mcp",
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.",
5
+ "description": "MCP server for CVE lookups: EchelonGraph scores, CISA KEV, EPSS, CWE, exploits, version and SBOM checks, vendor advisories, and per-CVE exposure derived from Shodan data. Shodan data is owned by Shodan, which holds its copyright (© Shodan). Keyless.",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "echelongraph-mcp": "dist/index.js"
package/server.json CHANGED
@@ -17,7 +17,7 @@
17
17
  ]
18
18
  }
19
19
  ],
20
- "version": "2.6.3",
20
+ "version": "2.6.5",
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.3",
31
+ "version": "2.6.5",
32
32
  "transport": {
33
33
  "type": "stdio"
34
34
  },