echelongraph-mcp 3.0.0 → 3.1.0

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.
@@ -27,10 +27,11 @@ const ApiCoverage = (instant) => z.looseObject({
27
27
  first_day: opt(Day).describe("The earliest score date in the run log."),
28
28
  last_day: opt(Day).describe("The latest score date in the run log."),
29
29
  days_held: opt(z.number().int().min(0)).describe("Score dates from first_day to last_day that a refresh applied completely."),
30
- days_partial: opt(z.array(Day)).describe("Score dates whose refreshes all failed part-way: some CVEs hold that day, some do not."),
30
+ days_partial: opt(z.array(Day)).describe("Score dates no refresh completed and one failed part-way: some CVEs hold that day, some do not. Also in days_in_progress while a retry of it has not finished."),
31
31
  days_missing: opt(z.array(Day)).describe("Score dates from first_day to last_day that no refresh applied."),
32
- runs_undated: opt(z.number().int().min(0)),
33
- refresh_runs_truncated: opt(z.boolean()),
32
+ days_in_progress: opt(z.array(Day)).describe("Score dates no refresh completed and one that has not finished is applying: some CVEs hold that day so far. Not counted held or missing; also in days_partial when an earlier refresh of it failed part-way."),
33
+ runs_undated: opt(z.number().int().min(0)).describe("Logged refreshes with no score date: the days they applied are not counted held, partial or in progress, and their points carry no score_date."),
34
+ refresh_runs_truncated: opt(z.boolean()).describe("True when only the newest refreshes of the run log were read, so the earliest score dates are not listed."),
34
35
  });
35
36
  const data = (instant) => z.looseObject({
36
37
  cve_id: z.string(),
@@ -62,13 +63,28 @@ export function epssHistoryOutput(kit) {
62
63
  first_day: z.string().nullable().describe("The earliest FIRST score date the run log lists; null when it lists none."),
63
64
  last_day: z.string().nullable(),
64
65
  days_held: z.number().int().min(0).nullable().describe("Score dates from first_day to last_day the record holds; null when none are listed."),
65
- days_partial: z.array(z.string()).describe("Score dates held for some CVEs only."),
66
+ days_partial: z.array(z.string()).describe("Score dates held for some CVEs only, because their refreshes failed part-way."),
66
67
  days_missing: z.array(z.string()).describe("Score dates from first_day to last_day the record does not hold."),
68
+ days_in_progress: z
69
+ .array(z.string())
70
+ .nullable()
71
+ .describe("Score dates a refresh that has not finished is applying (#3143): held for some CVEs so far. A date an earlier refresh failed part-way on is in days_partial too. Null when the API did not say."),
72
+ runs_undated: z
73
+ .number()
74
+ .int()
75
+ .min(0)
76
+ .nullable()
77
+ .describe("Logged refreshes with no score date (#3143): the days they applied are not counted, so a day in days_missing may be held for the CVEs they changed, and their points carry no score_date. Null when the API did not say."),
78
+ refresh_runs_truncated: z
79
+ .boolean()
80
+ .nullable()
81
+ .describe("True when only the newest refreshes of the run log were read, so the earliest score dates are not listed; null when the API did not say."),
67
82
  }),
68
83
  freshness: null,
69
84
  });
70
85
  }
71
86
  const isNum = (v) => typeof v === "number" && Number.isFinite(v);
87
+ const isCount = (v) => Number.isInteger(v) && v >= 0;
72
88
  const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
73
89
  const realInstant = (v) => typeof v === "string" && Date.parse(v) > 0;
74
90
  const isDay = (v) => typeof v === "string" && /^\d{4}-\d{2}-\d{2}$/.test(v);
@@ -96,7 +112,10 @@ function completeness(d, points) {
96
112
  daysHeld: firstDay && isNum(cov.days_held) ? cov.days_held : undefined,
97
113
  daysPartial: dayList(cov.days_partial),
98
114
  daysMissing: dayList(cov.days_missing),
115
+ daysInProgress: Array.isArray(cov.days_in_progress) ? dayList(cov.days_in_progress) : undefined,
99
116
  runLogSince: realInstant(cov.run_log_since) ? cov.run_log_since : undefined,
117
+ runsUndated: isCount(cov.runs_undated) ? cov.runs_undated : undefined,
118
+ runsTruncated: typeof cov.refresh_runs_truncated === "boolean" ? cov.refresh_runs_truncated : undefined,
100
119
  hasCoverage: isObj(d.coverage),
101
120
  };
102
121
  }
@@ -146,12 +165,26 @@ function note(head, id, d, c) {
146
165
  const held = c.daysHeld === undefined ? "" : `, ${c.daysHeld} held`;
147
166
  const missing = c.daysMissing.length ? `; not held: ${sayDays(c.daysMissing, "days_missing")}` : "; none missing";
148
167
  const partial = c.daysPartial.length ? `; held for some CVEs only (a refresh failed part-way): ${sayDays(c.daysPartial, "days_partial")}` : "";
168
+ // #3143: never "nothing failed". A retry of a date whose earlier refresh failed part-way is in
169
+ // days_partial too, so it is named in both clauses, each true of it.
170
+ const applying = c.daysInProgress?.length
171
+ ? `; being applied by a refresh that has not finished, so held for some CVEs so far: ${sayDays(c.daysInProgress, "days_in_progress")}`
172
+ : "";
149
173
  const since = c.runLogSince ? ` (run log since ${c.runLogSince}; earlier dates are not listed)` : "";
150
- s.push(`FIRST score dates in EchelonGraph's run log${since}: ${c.firstDay} to ${c.lastDay}${held}${missing}${partial}.`);
174
+ s.push(`FIRST score dates in EchelonGraph's run log${since}: ${c.firstDay} to ${c.lastDay}${held}${missing}${partial}${applying}.`);
151
175
  }
152
176
  else if (c.hasCoverage) {
153
177
  s.push("The run log lists no FIRST score date yet, so this answer does not say which days the record holds, and no point carries a score_date.");
154
178
  }
179
+ // #3143: said only when non-zero; coverage carries the value either way. "Not counted": in no
180
+ // list, held, partial or in progress.
181
+ if (c.runsUndated) {
182
+ const one = c.runsUndated === 1;
183
+ s.push(`${c.runsUndated} logged refresh${one ? " has" : "es have"} no FIRST score date (coverage.runs_undated: ${one ? "its file named none, or it" : "their files named none, or they"} stopped before recording it). The day${one ? " it" : "s they"} applied ${one ? "is" : "are"} not counted, so a day listed missing, or not listed, may be held for the CVEs ${one ? "it" : "they"} changed, and the points ${one ? "it" : "they"} wrote carry no score_date.`);
184
+ }
185
+ if (c.runsTruncated) {
186
+ s.push("refresh_runs_truncated is true: only the newest refreshes of the run log were read, so first_day is not where the run log starts, and earlier score dates are not listed.");
187
+ }
155
188
  if (d.points_truncated === true)
156
189
  s.push("points_truncated is true: only the newest records were read, so the earliest changes are missing from this series.");
157
190
  if (d.latest_point_matches_current === false) {
@@ -193,6 +226,9 @@ export async function epssHistory(kit, cve_id) {
193
226
  days_held: c.daysHeld ?? null,
194
227
  days_partial: c.daysPartial,
195
228
  days_missing: c.daysMissing,
229
+ days_in_progress: c.daysInProgress ?? null,
230
+ runs_undated: c.runsUndated ?? null,
231
+ refresh_runs_truncated: c.runsTruncated ?? null,
196
232
  },
197
233
  freshness: null,
198
234
  notes: [
@@ -208,8 +244,11 @@ export async function epssHistory(kit, cve_id) {
208
244
  return kit.crashed(TOOL, e);
209
245
  }
210
246
  }
247
+ // One output schema per process, not per request (3.0.2; index.ts envelopeSchema says why).
248
+ const OUTPUTS = new WeakMap();
211
249
  export function registerEPSSHistory(server, kit) {
212
- const output = epssHistoryOutput(kit);
250
+ const output = OUTPUTS.get(kit) ?? epssHistoryOutput(kit);
251
+ OUTPUTS.set(kit, output);
213
252
  server.registerTool(TOOL, {
214
253
  title: "EPSS change history for one CVE",
215
254
  description: EPSS_HISTORY_DESCRIPTION,
@@ -223,9 +223,12 @@ async function kevRecent(d, a) {
223
223
  return d.crashed(TOOL, e);
224
224
  }
225
225
  }
226
+ // One output schema per process, not per request (3.0.2; index.ts envelopeSchema says why).
227
+ const OUTPUTS = new WeakMap();
226
228
  // Registers the tool on a server built by index.ts's createServer.
227
229
  export function registerKevRecent(server, d) {
228
- const output = d.envelopeSchema({ data: Data, coverage: Coverage, freshness: Freshness });
230
+ const output = OUTPUTS.get(d.envelopeSchema) ?? d.envelopeSchema({ data: Data, coverage: Coverage, freshness: Freshness });
231
+ OUTPUTS.set(d.envelopeSchema, output);
229
232
  server.registerTool("kev_recent", {
230
233
  title: "Recent CISA KEV additions",
231
234
  description: KEV_RECENT_DESCRIPTION,
@@ -257,11 +257,16 @@ export async function sendBatches(deps, tool, purls) {
257
257
  // (purls.slice(answers.length * MAX_COMPONENTS)), which it can rebuild from its own input, given
258
258
  // the order they are counted in (`readOrder`), which the sentence says whenever it gives the
259
259
  // position, not only when the text cuts the list. #3013: the structured result's cut empties
260
- // data.not_sent_purls before any row leaves (it is rebuilt from the position), and gives it back
261
- // only the first entries the room the rows leave holds, so the sentence says it lists them whole
262
- // with full: true. `retry` is the tool and argument to send them to ("check_sbom
263
- // again with purls set to").
264
- export function sendingSentences(g, distinct, readOrder, retry) {
260
+ // data.not_sent_purls before any row leaves (it is rebuilt from the position). #3132's review:
261
+ // round 1 then gave it back the first entries the room the rows left held (3 of the rate-limited
262
+ // case's 800), a list a client would retry as though it were the whole; the cut now keeps it whole
263
+ // or not at all (check_sbom.ts sbomData), and the sentence gives the positions as coverage holds
264
+ // them. `retry` is the tool and argument to send them to ("check_sbom again with purls set to").
265
+ // #3132's second review: those positions index the purls this tool read, which a check_sbom
266
+ // client rebuilds from its own purl list (or SBOM), but a scan_manifest client cannot without
267
+ // reading the files as this tool does; with `positions` false (scan_manifest), the sentence sends
268
+ // it to the same call with full: true, which always returns the list, instead.
269
+ export function sendingSentences(g, distinct, readOrder, retry, positions = true) {
265
270
  const out = [];
266
271
  if (g.batches > 1) {
267
272
  out.push(`The ${distinct} distinct purls make ${g.batches} batches of at most ${MAX_COMPONENTS}, sent one after another; the API answered ${g.batches_sent} of them, and data.summary sums those answers.`);
@@ -273,10 +278,64 @@ export function sendingSentences(g, distinct, readOrder, retry) {
273
278
  if (notSent > 0) {
274
279
  const one = notSent === 1;
275
280
  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`;
276
- 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}, ${readOrder}, and data.not_sent_purls lists ${one ? "it" : "them"} whole with full: true (past the structured result's budget, that list can be cut by default): call ${retry} ${one ? "it" : "them"}${g.not_sent_reason === "request_failed" ? "" : " after a minute"}.`);
281
+ 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}, ${readOrder}, and data.not_sent_purls lists ${one ? "it" : "them"} (past the structured result's budget it is left out whole by default, never cut short${positions ? `, and ${one ? "that position is" : "those positions are"} coverage.sent + 1 to coverage.distinct_purls; with full: true it is always there` : ": the same call with full: true always returns it"}): call ${retry} ${one ? "it" : "them"}${g.not_sent_reason === "request_failed" ? "" : " after a minute"}.`);
277
282
  }
278
283
  return out;
279
284
  }
285
+ // One affected component as the note names it: its purl, and its first NOTE_CVES_MAX cve_ids.
286
+ const affectedNamed = (r) => {
287
+ const who = (typeof field(r, "purl") === "string" && field(r, "purl")) || `${field(r, "ecosystem") ?? ""} ${field(r, "package") ?? ""} ${field(r, "version") ?? ""}`.trim();
288
+ const ids = field(r, "cve_ids");
289
+ const list = Array.isArray(ids) ? ids.filter((i) => typeof i === "string") : [];
290
+ const shown = list.slice(0, NOTE_CVES_MAX).join(", ");
291
+ return `${who} (${shown}${list.length > NOTE_CVES_MAX ? `, and ${list.length - NOTE_CVES_MAX} more` : ""})`;
292
+ };
293
+ const affectedTail = (more) => (more > 0 ? `; and ${more} more affected components in data.results (whole with full: true)` : "");
294
+ // The note's sentence naming the affected components, the first NOTE_AFFECTED_MAX of `hits`.
295
+ function affectedSaid(hits) {
296
+ return `Affected: ${hits.slice(0, NOTE_AFFECTED_MAX).map(affectedNamed).join("; ")}${affectedTail(hits.length - NOTE_AFFECTED_MAX)}.`;
297
+ }
298
+ // #3269's third review: the characters, as a JSON string, of each row's name in that sentence,
299
+ // measured once per row object (the structured cut asks of the same rows many times).
300
+ const namedChars = new WeakMap();
301
+ const namedCharsOf = (r) => {
302
+ if (!isObj(r))
303
+ return JSON.stringify(affectedNamed(r)).length - 2;
304
+ let c = namedChars.get(r);
305
+ if (c === undefined)
306
+ namedChars.set(r, (c = JSON.stringify(affectedNamed(r)).length - 2));
307
+ return c;
308
+ };
309
+ const isAffected = (r) => field(r, "verdict") === "affected";
310
+ // The sentence's characters in the answer being cut, once per answer's rows.
311
+ const saidChars = new WeakMap();
312
+ /**
313
+ * #3269's third review: for a call of `m` of `rows` alone, the most characters (as JSON) the note's
314
+ * Affected sentence can take there, less what it takes in the answer of `all` (the call being cut;
315
+ * negative where a call of m of them names fewer or shorter components). A call of other purls
316
+ * names other components there, so a call that re-checks the rows a cut left out can carry a longer
317
+ * note than this one; it names at most NOTE_AFFECTED_MAX of its affected rows, so at most that many,
318
+ * or m, of these rows' longest names, and the tail counting the rest.
319
+ */
320
+ export function affectedSaidOver(rows, all) {
321
+ const longest = rows.filter(isAffected).map(namedCharsOf).sort((a, b) => b - a);
322
+ const top = [0];
323
+ for (const c of longest.slice(0, NOTE_AFFECTED_MAX))
324
+ top.push(top[top.length - 1] + c);
325
+ let now = saidChars.get(all);
326
+ if (now === undefined) {
327
+ const own = all.filter(isAffected);
328
+ saidChars.set(all, (now = own.length ? JSON.stringify(affectedSaid(own)).length - 2 : 0));
329
+ }
330
+ const was = now;
331
+ return (m) => {
332
+ const hits = Math.min(m, longest.length);
333
+ if (hits === 0)
334
+ return -was;
335
+ const named = Math.min(hits, NOTE_AFFECTED_MAX);
336
+ return "Affected: ".length + top[named] + 2 * (named - 1) + affectedTail(hits - NOTE_AFFECTED_MAX).length + 1 - was;
337
+ };
338
+ }
280
339
  // The verdict counts, the not-assessed reasons, the affected components by name, and the
281
340
  // upstream-matched rows, from the (merged) answer.
282
341
  export function verdictSentences(d, g) {
@@ -305,17 +364,10 @@ export function verdictSentences(d, g) {
305
364
  if (Array.isArray(rows)) {
306
365
  const hits = rows.filter((r) => field(r, "verdict") === "affected");
307
366
  if (hits.length) {
308
- const named = hits.slice(0, NOTE_AFFECTED_MAX).map((r) => {
309
- const who = (typeof field(r, "purl") === "string" && field(r, "purl")) || `${field(r, "ecosystem") ?? ""} ${field(r, "package") ?? ""} ${field(r, "version") ?? ""}`.trim();
310
- const ids = field(r, "cve_ids");
311
- const list = Array.isArray(ids) ? ids.filter((i) => typeof i === "string") : [];
312
- const shown = list.slice(0, NOTE_CVES_MAX).join(", ");
313
- return `${who} (${shown}${list.length > NOTE_CVES_MAX ? `, and ${list.length - NOTE_CVES_MAX} more` : ""})`;
314
- });
315
- out.push(`Affected: ${named.join("; ")}${hits.length > NOTE_AFFECTED_MAX ? `; and ${hits.length - NOTE_AFFECTED_MAX} more affected components in data.results` : ""}.`);
367
+ out.push(affectedSaid(hits));
316
368
  // Said only when the answer carries fixed_in (an API before #2834 sends none).
317
369
  if (hits.some((r) => (Array.isArray(field(r, "matches")) ? field(r, "matches") : []).some((m) => isObj(m) && "fixed_in" in m))) {
318
- out.push("Each match's fixed_in is the first version above the component's that no affected interval of the advisory holds; null, with fixed_in_reason, where the advisory records no fix for that version.");
370
+ out.push("Each match's fixed_in is the first version above the component's that no affected interval of the advisory holds; null, with fixed_in_reason, where no higher version is shown to be unaffected.");
319
371
  }
320
372
  }
321
373
  // #2836: rows matched by the source package their purl's upstream qualifier names.
@@ -380,6 +432,17 @@ export const Row = z.looseObject({
380
432
  undetermined: opt(z.array(z.looseObject({ cve_id: opt(z.string()) }))),
381
433
  capped: opt(z.boolean()),
382
434
  candidates_capped: opt(z.boolean()),
435
+ // #3132: set by this server's structured cut (check_sbom.ts SBOM_ROW_LEVELS), never by the API.
436
+ matches_truncated: z
437
+ .strictObject({
438
+ total: z.number().int().min(0),
439
+ returned: z.number().int().min(0),
440
+ kev_listed_cve_ids: z.array(z.string()).optional(),
441
+ ransomware_cve_ids: z.array(z.string()).optional(),
442
+ fixed_in_versions: z.array(z.string()).optional(),
443
+ })
444
+ .optional()
445
+ .describe("Present only on a row whose matches the structured result's cut (DATA CUT) shortened or left out: total, the row's matches in the answer; returned, the ones data keeps (chosen used in ransomware first, then the other KEV-listed ones, then highest effective_score, and listed in the API's order); and, while they fit, of all its matches, the CVE IDs KEV-listed (kev_listed_cve_ids) or used in ransomware (ransomware_cve_ids), and the distinct fixed_in versions (fixed_in_versions). The same call with full: true returns every match."),
383
446
  });
384
447
  // The batch route's answer, as data carries it: its fields, merged over the batches.
385
448
  export const batchDataShape = {
@@ -0,0 +1,58 @@
1
+ // #3285: which rows of a CVE's affected packages are one package, as core-backend's registry
2
+ // matcher reads them. A module of its own, with no import, so cve_remediation (fixed_branches) and
3
+ // cve_intel (affected_packages) state the same reading.
4
+ //
5
+ // core-backend cve/pkgecosystem.go: baseTokenMatchableEcosystems names the base tokens whose ":"
6
+ // suffix is a repository locator, not a release (Packagist, Maven), and ecosystemBaseToken is the
7
+ // lowercased, trimmed token before the first ":". cve/pkgmatch_fixedin.go registryRecordKey folds
8
+ // a suffixed ecosystem onto its base only when that base is one of them, so a row of one
9
+ // package_name under "Packagist:https://packages.drupal.org/8" and one under "Packagist" are one
10
+ // record, whose ranges the matcher walks together; a distro's release suffix (Debian:12,
11
+ // Alpine:v3.20) stays in the key and is never folded. CVE-2025-48013 lists drupal/quick_node_block
12
+ // under both (fixed 2.0.1 and fixed 2.0.0, each from 0): read row by row, a Packagist 1.0.0 gets
13
+ // 2.0.0, which the Drupal advisory still holds affected; as one package it gets 2.0.1, the
14
+ // matcher's fixed_in. (registryRecordKey also spells a PyPI name the PEP 503 way; a PyPI row is
15
+ // never listed under two ecosystem strings, so that plays no part here.)
16
+ export const BASE_MATCHABLE_ECOSYSTEMS = ["packagist", "maven"];
17
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
18
+ const str = (v, k) => {
19
+ const x = isObject(v) ? v[k] : undefined;
20
+ return typeof x === "string" && x.trim() ? x : undefined;
21
+ };
22
+ /** core-backend ecosystemBaseToken: the lowercased, trimmed token before the first ":". */
23
+ export function ecosystemBaseToken(raw) {
24
+ const t = raw.trim().toLowerCase();
25
+ const i = t.indexOf(":");
26
+ return i > 0 ? t.slice(0, i) : t;
27
+ }
28
+ /** The ecosystem part of core-backend's registryRecordKey: lowercased, folded onto its base only where the base is base-matchable. */
29
+ export function recordEcosystem(raw) {
30
+ const eco = raw.trim().toLowerCase();
31
+ const base = ecosystemBaseToken(eco);
32
+ return base !== eco && BASE_MATCHABLE_ECOSYSTEMS.includes(base) ? base : eco;
33
+ }
34
+ /**
35
+ * The packages `rows` (each with ecosystem and package_name) list under more than one ecosystem
36
+ * string that the matcher folds into one record, in the order each was first listed; `folded` is
37
+ * how many rows fold into another so.
38
+ */
39
+ export function packagesListedTwice(rows) {
40
+ const groups = new Map();
41
+ rows.forEach((r, i) => {
42
+ const eco = str(r, "ecosystem");
43
+ const name = str(r, "package_name");
44
+ if (!eco || !name || !BASE_MATCHABLE_ECOSYSTEMS.includes(ecosystemBaseToken(eco)))
45
+ return;
46
+ const key = `${recordEcosystem(eco)}\u0000${name.trim().toLowerCase()}`;
47
+ const g = groups.get(key) ?? { name, ecosystems: [], rows: [] };
48
+ if (!g.ecosystems.includes(eco))
49
+ g.ecosystems.push(eco);
50
+ g.rows.push(i);
51
+ groups.set(key, g);
52
+ });
53
+ const twice = [...groups.values()].filter((g) => g.ecosystems.length > 1);
54
+ return { twice, folded: twice.reduce((t, g) => t + g.rows.length - 1, 0) };
55
+ }
56
+ // What a note says of such rows, in cve_remediation's entries and cve_intel's rows alike: the rule
57
+ // the matcher applies, stated so that an agent walking the ranges reads them as it does.
58
+ export const ONE_PACKAGE_RULE = (what) => `${what} of one package_name under Packagist or Maven, bare or with a repository URL after ":", are one package, its ranges all of theirs together`;
@@ -15,31 +15,46 @@ const TEXT_NOT_CHECKED = 20;
15
15
  // The order the distinct purls are counted in, for the position of those not sent (#2799).
16
16
  const READ_ORDER = "counted in the order this tool read them (the files in the order given, each file's entries in the order the file lists them, each purl at its first place)";
17
17
  const NOT_CHECKED_NOTE = "not_checked entries are not clean: each names a dependency this tool did not send to the API (its version is a range or unresolved, or it comes from a local path, a VCS checkout or a line this tool does not read), so no verdict was looked up for it.";
18
+ // 3.1.0's phase A review: fix C's wording tied "50 seconds a call" to the 429 wait alone, while
19
+ // match_batch.ts's TIME_BUDGET_MS bounds the whole call: a slow batch is cut off at its end
20
+ // (time_budget), and a 429 refused (rate_limited) or a batch failed (request_failed) after the first
21
+ // also answers partial, the rest in not_sent_purls. 3.0.2's "within 50 seconds" said so; the
22
+ // description says it again, the room from shorter wording elsewhere (every claim kept).
18
23
  export const SCAN_MANIFEST_TITLE = "Check a lockfile against the advisory corpus";
19
- export const SCAN_MANIFEST_DESCRIPTION = "Check a project's lockfile or pinned manifest against EchelonGraph's advisory corpus, one verdict per dependency. Pass files: 1 to 20 of filename and content, up to 5,000,000 characters in all; the filename, or format, gives the format. Read: requirements.txt (== and === pins only), go.mod (require, after replace and exclude), package-lock.json and npm-shrinkwrap.json (v1 to v3), Cargo.lock, Gemfile.lock, composer.lock, poetry.lock and gradle.lockfile. package.json, pyproject.toml, Pipfile, Gemfile, Cargo.toml, composer.json and build.gradle hold ranges and are refused, naming the lockfile to pass; go.sum is refused: it lists versions the build does not select. This server reads the files into purls and only the purls are sent to the API, in POST bodies of at most 200, never in a URL; filenames and file contents are not sent on. Run from npm, this server is on your machine; over the hosted endpoint (mcp.echelongraph.io) it is EchelonGraph's, and the files are the request body, accepted up to 6 MiB. data.results holds one row per distinct purl, with verdict (affected, not_affected, undetermined or not_assessed), not_assessed_reason, cve_ids and matched_via; each match carries kev_listed, epss_score and fixed_in or null with fixed_in_reason. not_affected is the only clean verdict. data.not_checked lists each entry not sent, with file, line and reason: version_unpinned (a range), version_unresolved, local_path, vcs_source or unsupported_line; not_checked entries are not clean. At most 2,000 distinct purls per call, within 50 seconds; data.not_sent_purls lists any not sent, unchecked and not clean." +
24
+ export const SCAN_MANIFEST_DESCRIPTION = "Check a project's lockfile or pinned manifest against EchelonGraph's advisory corpus, one verdict per dependency. Pass files: 1 to 20 of filename and content, up to 5,000,000 characters in all; the filename or format gives the format. Read: requirements.txt (== and === pins only), go.mod (require, after replace and exclude), package-lock.json and npm-shrinkwrap.json (v1-v3), Cargo.lock, Gemfile.lock, composer.lock, poetry.lock and gradle.lockfile. package.json, pyproject.toml, Pipfile, Gemfile, Cargo.toml, composer.json and build.gradle hold ranges and are refused, naming the lockfile; go.sum is refused: it lists versions the build does not select. The server reads the files into purls; only the purls are sent to the API, in POST bodies of at most 200, never in a URL; filenames and file contents are not sent on. Run from npm, this server is on your machine; over the hosted endpoint (mcp.echelongraph.io) it is EchelonGraph's, and the files are the request body, up to 6 MiB. data.results holds a row per distinct purl, with verdict (affected, not_affected, undetermined or not_assessed), not_assessed_reason, cve_ids and matched_via; each match carries kev_listed, epss_score and fixed_in or null with fixed_in_reason. not_affected is the only clean verdict. data.not_checked lists each entry not sent (file, line, reason); not_checked entries are not clean. At most 2,000 distinct purls and 50 seconds a call. The API allows 1,200 components a minute per caller; on a 429 it waits out Retry-After within that; a later batch cut off, refused or failed ends the call, the rest in data.not_sent_purls, unchecked, not clean." +
20
25
  " " +
21
26
  TEXT_BUDGET_DESCRIPTION +
22
- " Cut, each row keeps index, purl, verdict, not_assessed_reason and cve_ids at least; not_checked keeps its first 20.";
27
+ " Cut, each row keeps index, purl, verdict, not_assessed_reason and cve_ids at least; not_checked its first 20.";
23
28
  // The text's cut: check_sbom's rows and levels, and data.not_checked beside them, cut to its
24
29
  // first TEXT_NOT_CHECKED once the first level does not fit (structuredContent.data keeps it whole).
30
+ // #3132's second review: check_sbom's sentence on not_sent_purls says a second call can send them
31
+ // without reading the list, from their positions in the input; here those positions index purls
32
+ // read from the files, which a client cannot rebuild, so the sentence names full: true instead.
25
33
  export function manifestText(sent) {
26
34
  const sbom = sbomText(sent);
27
35
  return {
28
36
  ...sbom,
29
37
  sides: [
30
- ...(sbom.sides ?? []),
38
+ ...(sbom.sides ?? []).map((side) => side.list === "not_sent_purls"
39
+ ? {
40
+ ...side,
41
+ said: (_inText, total) => `The ${total} purls not sent are the purls this tool read from position ${sent + 1} on, counted in the order the note gives for them; structuredContent.data.not_sent_purls lists every one with full: true.`,
42
+ }
43
+ : side),
31
44
  {
32
45
  list: "not_checked",
33
46
  cap: TEXT_NOT_CHECKED,
34
47
  said: (inText, total) => `coverage.not_checked_by_reason counts all ${total} entries not checked, ${inText} of them in the text.`,
35
48
  },
36
49
  ],
37
- page: (shown) => `To read every row in the text, pass files that hold ${shown} or fewer distinct purls per call.`,
50
+ // #3269's second review: no page sentence, as for check_sbom's text (sbomText says why).
38
51
  };
39
52
  }
40
53
  // #3013: the structured result's cut, check_sbom's (sbomData: the lists beside the rows, then every
41
54
  // row thinned, then rows leaving, clean ones first) with not_checked beside not_sent_purls.
42
- const MANIFEST_DATA = sbomData(["not_sent_purls", "not_checked"], (k) => `To read every row in data, pass files that hold ${k} or fewer distinct purls per call.`);
55
+ // #3269's second review: the sentence promises only what holds (check_sbom.ts sbomPageSaid).
56
+ export const manifestPageSaid = (k) => `A call whose files hold the first ${k} of these purls, or fewer, keeps every row in data; a call of other files says in its own note what it leaves out.`;
57
+ const MANIFEST_DATA = sbomData(["not_sent_purls", "not_checked"], manifestPageSaid);
43
58
  export function readFiles(a) {
44
59
  if (!Array.isArray(a.files))
45
60
  return "files must be a list of {filename, content}";
@@ -126,7 +141,7 @@ function noteFor(head, x, d, g) {
126
141
  // counts above are coverage.not_checked_by_reason's, which no cut touches.
127
142
  out.push(`${plural(nc, "entry was", "entries were")} NOT checked and ${nc === 1 ? "is" : "are"} not clean (${parts.join(", ")}): data.not_checked lists each with its file, line and reason, whole with full: true (past the structured result's budget, that list can be cut by default), and none was sent to the API.`);
128
143
  }
129
- out.push(...sendingSentences(g, x.purls.length, READ_ORDER, "check_sbom with purls set to"));
144
+ out.push(...sendingSentences(g, x.purls.length, READ_ORDER, "check_sbom with purls set to", false));
130
145
  if (d === null)
131
146
  return { note: out.join(" "), assessed: 0 };
132
147
  const v = verdictSentences(d, g);
@@ -243,11 +258,15 @@ export function scanManifestOutput(envelopeSchema) {
243
258
  freshness: null,
244
259
  });
245
260
  }
261
+ // One output schema per process, not per request (3.0.2; index.ts envelopeSchema says why).
262
+ const OUTPUTS = new WeakMap();
246
263
  // A literal server.registerTool(name, { title, description, … }) call with constant strings:
247
264
  // marketing-site lib/mcpToolClaims.test.ts reads it here (it follows index.ts's call to this
248
265
  // function) and holds the /pulse/mcp row to this title and description.
249
266
  export function registerScanManifest(server, deps) {
250
- const output = scanManifestOutput(deps.envelopeSchema);
267
+ const envelope = deps.envelopeSchema;
268
+ const output = OUTPUTS.get(envelope) ?? scanManifestOutput(envelope);
269
+ OUTPUTS.set(envelope, output);
251
270
  server.registerTool(SCAN_MANIFEST, {
252
271
  title: SCAN_MANIFEST_TITLE,
253
272
  description: SCAN_MANIFEST_DESCRIPTION,
@@ -77,6 +77,18 @@ const CVE_ID = /^CVE-\d{4}-\d{4,}$/i;
77
77
  const VENDOR_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
78
78
  // No control characters (a header or log line could carry them), at most 200 characters.
79
79
  const ADVISORY_ID = /^[^\x00-\x1f\x7f]{1,200}$/;
80
+ // #3011: a row's advisory_id is EchelonGraph's own ID for the advisory, a UUID (vendor_advisories,
81
+ // migration 063). The detail route is keyed on the vendor's ID, vendor_advisory_id (handler.go
82
+ // Detail, store.go GetAdvisoryByVendorAndID: WHERE v.slug = $1 AND va.vendor_advisory_id = $2), and
83
+ // the API has no route by the UUID, so get_vendor_advisory given a row's advisory_id answered a
84
+ // 404, worded as no record, for an advisory the API holds (CVE-2024-3400's Palo Alto row,
85
+ // a68d033b-5d1e-4537-8190-5e37897a27e8, 2026-10-10). A vendor's own ID could have that shape too, so
86
+ // the API is still asked; only its 404 for an ID of that shape is answered as the refusal it is.
87
+ const INTERNAL_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
88
+ // What a row's two IDs are, in the outputSchema of all three tools (the detail echoes advisory_id).
89
+ // Facts only, naming no other tool (test/description-policy.test.mjs: the MCP Directory policy).
90
+ const ROW_ADVISORY_ID_FIELD = "EchelonGraph's internal ID for this advisory, a UUID. It is not the vendor's advisory ID (vendor_advisory_id), and an advisory's detail is not looked up by it: the detail is keyed on vendor and vendor_advisory_id.";
91
+ const VENDOR_ADVISORY_ID_FIELD = "The vendor's own ID for the advisory (e.g. RHSA-2024:1234, GHSA-xxxx-xxxx-xxxx, or the CVE ID for Microsoft and Palo Alto Networks). With vendor, it is the key an advisory's detail is looked up by, as its advisory_id argument.";
80
92
  const METHOD = "EchelonGraph's vendor-advisory feed: advisories as each vendor published them in its own feed (for example Microsoft MSRC, Red Hat, Cisco, Palo Alto Networks and GitHub GHSA), each feed polled on a schedule; advisories of a feed EchelonGraph has disabled are left out.";
81
93
  const NO_FRESHNESS = "freshness is null: these answers carry no time at which the vendor-advisory pollers last completed a poll.";
82
94
  const DATES = "vendor_published_at is the date the vendor gives for the advisory; our_first_seen_at is when EchelonGraph first recorded it, which is not the vendor's date.";
@@ -245,10 +257,10 @@ const REMEDIATION_KINDS_FIELD = "The kinds of remediation the advisory lists (fo
245
257
  const REMEDIATION_KINDS = ["vendor_fix", "workaround", "mitigation", "no_fix_planned", "none_available", "other"];
246
258
  const REMEDIATION_STATES = ["parsed", "none_in_source", "not_parsed", "withdrawn"];
247
259
  const AdvisoryRow = z.looseObject({
248
- advisory_id: opt(z.string()),
260
+ advisory_id: opt(z.string()).describe(ROW_ADVISORY_ID_FIELD),
249
261
  vendor: opt(z.string()),
250
262
  vendor_display_name: opt(z.string()),
251
- vendor_advisory_id: opt(z.string()),
263
+ vendor_advisory_id: opt(z.string()).describe(VENDOR_ADVISORY_ID_FIELD),
252
264
  cve_ids: opt(z.array(z.string())),
253
265
  title: opt(z.string()),
254
266
  severity: opt(z.string()),
@@ -352,8 +364,11 @@ const SEARCH_DATA = {
352
364
  },
353
365
  };
354
366
  const ROW_CUT_DESCRIPTION = " Cut, each row keeps vendor, vendor_advisory_id, title, severity and cve_ids at least, its strings and lists shortened";
355
- export function registerVendorAdvisoryTools(server, kit) {
356
- const FOR_CVE_OUTPUT = kit.envelopeSchema({
367
+ // The three tools' output schemas, one each per process, not per request (3.0.2; index.ts
368
+ // envelopeSchema says why).
369
+ const OUTPUTS = new WeakMap();
370
+ function outputsOf(envelopeSchema) {
371
+ const FOR_CVE_OUTPUT = envelopeSchema({
357
372
  data: z.looseObject({
358
373
  cve_id: opt(z.string()),
359
374
  advisories: opt(z.array(AdvisoryRow)),
@@ -373,7 +388,7 @@ export function registerVendorAdvisoryTools(server, kit) {
373
388
  }),
374
389
  freshness: null,
375
390
  });
376
- const DETAIL_OUTPUT = kit.envelopeSchema({
391
+ const DETAIL_OUTPUT = envelopeSchema({
377
392
  data: AdvisoryRow.extend({
378
393
  description: opt(z.string()),
379
394
  known_cve_ids: opt(z.array(z.string())).describe("The CVE IDs of cve_ids that have a record in EchelonGraph's CVE feed."),
@@ -402,7 +417,7 @@ export function registerVendorAdvisoryTools(server, kit) {
402
417
  }),
403
418
  freshness: null,
404
419
  });
405
- const SEARCH_OUTPUT = kit.envelopeSchema({
420
+ const SEARCH_OUTPUT = envelopeSchema({
406
421
  data: z.looseObject({
407
422
  advisories: opt(z.array(AdvisoryRow)),
408
423
  total: opt(z.number()).describe("The matches; when total_capped is true, a lower bound: 1,000 or more."),
@@ -424,6 +439,12 @@ export function registerVendorAdvisoryTools(server, kit) {
424
439
  }),
425
440
  freshness: null,
426
441
  });
442
+ return { FOR_CVE_OUTPUT, DETAIL_OUTPUT, SEARCH_OUTPUT };
443
+ }
444
+ export function registerVendorAdvisoryTools(server, kit) {
445
+ const outputs = OUTPUTS.get(kit.envelopeSchema) ?? outputsOf(kit.envelopeSchema);
446
+ OUTPUTS.set(kit.envelopeSchema, outputs);
447
+ const { FOR_CVE_OUTPUT, DETAIL_OUTPUT, SEARCH_OUTPUT } = outputs;
427
448
  // The vendors' windows, read beside a tool's own request. Never a failure of the tool.
428
449
  async function readWindows() {
429
450
  const r = await kit.api(VENDOR_COVERAGE_PATH);
@@ -492,6 +513,13 @@ export function registerVendorAdvisoryTools(server, kit) {
492
513
  }
493
514
  }
494
515
  // ── get_vendor_advisory ──
516
+ // #3011: advisory_id was a row's advisory_id (a UUID), not its vendor_advisory_id. The API was
517
+ // asked (a vendor's own ID could be shaped so) and holds no advisory of the vendor under it, which
518
+ // is the wrong ID passed, not a missing advisory: invalid_input, naming the field to pass.
519
+ function internalIdRefusal(vendor, id, path) {
520
+ const why = `advisory_id ${id} has the shape of a row's advisory_id, EchelonGraph's internal ID for an advisory (a UUID), which this tool does not take, and EchelonGraph holds no ${vendor} advisory whose vendor_advisory_id it is (HTTP 404)`;
521
+ return kit.failure("invalid_input", `get_vendor_advisory FAILED (state: invalid_input): ${why}. Call get_vendor_advisory again with the row's vendor and its vendor_advisory_id as advisory_id (e.g. CVE-2024-3400 for Palo Alto Networks, RHSA-2024:1234 for Red Hat). This says nothing about whether the advisory exists, and it is not a finding.`, { kind: "invalid_input", path, status: 404, message: `${why}; pass the row's vendor_advisory_id as advisory_id` });
522
+ }
495
523
  async function detail(vendorArg, idArg) {
496
524
  const tool = "get_vendor_advisory";
497
525
  const vendor = vendorArg.trim();
@@ -507,6 +535,8 @@ export function registerVendorAdvisoryTools(server, kit) {
507
535
  try {
508
536
  const windowsRead = readWindows();
509
537
  const r = await kit.api(`/api/v1/public/vendor-advisories/${encodeURIComponent(vendor)}/${encodeURIComponent(id)}`);
538
+ if (!r.ok && r.kind === "http" && r.status === 404 && INTERNAL_ID.test(id))
539
+ return internalIdRefusal(vendor, id, r.path);
510
540
  if (!r.ok)
511
541
  return kit.failed(tool, r);
512
542
  const w = await windowsRead;
@@ -648,10 +678,12 @@ export function registerVendorAdvisoryTools(server, kit) {
648
678
  }, async ({ cve_id, full }) => kit.checked("vendor_advisories_for_cve", FOR_CVE_OUTPUT, await answering(full, FOR_CVE_DATA, () => forCVE(cve_id))));
649
679
  server.registerTool("get_vendor_advisory", {
650
680
  title: "Vendor advisory detail",
651
- description: `One vendor advisory in full, by vendor and the vendor's advisory ID (the vendor and vendor_advisory_id of a vendor advisory row): title, description, severity, cvss_v3_score, cve_ids and known_cve_ids (those with a record in EchelonGraph's CVE feed), affected_products, remediation, references, vendor_modified_at, and withdrawn_at and withdrawn_reason when the vendor withdrew it. remediations lists each remediation the vendor lists, with kind (the vendor's own category, never read from the text: vendor_fix, workaround, mitigation, no_fix_planned, none_available or other), source_category, text, url and the cve_ids and product_ids it covers; each text is the vendor's, verbatim, untested by EchelonGraph. remediation_state is parsed, none_in_source, not_parsed or withdrawn: none_in_source and not_parsed are not a finding that no fix exists. ${DATES_DESCRIPTION} ${REJECTED_CVE_DESCRIPTION} coverage.vendor_window is that vendor's window in what EchelonGraph holds: ${WINDOW_HEAD}; null when the windows could not be read or carry none for that vendor. measured_at is our_first_seen_at. ${ENVELOPE_DESCRIPTION} ${TEXT_BUDGET_DESCRIPTION}`,
681
+ description: `One vendor advisory in full, by vendor and the vendor's advisory ID (the vendor and vendor_advisory_id of a vendor advisory row, never its advisory_id, EchelonGraph's internal UUID): title, description, severity, cvss_v3_score, cve_ids and known_cve_ids (those with a record in EchelonGraph's CVE feed), affected_products, remediation, references, vendor_modified_at, and withdrawn_at and withdrawn_reason when the vendor withdrew it. remediations lists each remediation the vendor lists, with kind (the vendor's own category, never read from the text: vendor_fix, workaround, mitigation, no_fix_planned, none_available or other), source_category, text, url and the cve_ids and product_ids it covers; each text is the vendor's, verbatim, untested by EchelonGraph. remediation_state is parsed, none_in_source, not_parsed or withdrawn: none_in_source and not_parsed are not a finding that no fix exists. ${DATES_DESCRIPTION} ${REJECTED_CVE_DESCRIPTION} coverage.vendor_window is that vendor's window in what EchelonGraph holds: ${WINDOW_HEAD}; null when the windows could not be read or carry none for that vendor. measured_at is our_first_seen_at. ${ENVELOPE_DESCRIPTION} ${TEXT_BUDGET_DESCRIPTION}`,
652
682
  inputSchema: z.object({
653
683
  vendor: z.string().describe("the vendor slug, e.g. microsoft, redhat, github"),
654
- advisory_id: z.string().describe("the vendor's advisory ID, e.g. RHSA-2024:1234 or GHSA-xxxx-xxxx-xxxx"),
684
+ advisory_id: z
685
+ .string()
686
+ .describe("the vendor's own advisory ID, a row's vendor_advisory_id, e.g. RHSA-2024:1234, GHSA-xxxx-xxxx-xxxx or CVE-2024-3400; not a row's advisory_id, EchelonGraph's internal UUID"),
655
687
  full: FULL_ARG,
656
688
  }),
657
689
  outputSchema: DETAIL_OUTPUT,
@@ -43,6 +43,7 @@ maps to a CISA-KEV-listed CVE (see [How `cve_exposure` counts](tools.md#how-cve_
43
43
  | `kev_exposure.newest_kev[].cvss_v3_score`, `kev_exposure.newest_kev[].epss_score` | Scores, not counts: the CVE record's CVSS v3 base score and EPSS probability (0 to 1), absent when the record has none. |
44
44
  | `kev_exposure.last_run_at` | A timestamp, not a count: when the radar last completed a check, a cycle in which its Shodan search answered at least one query (others may have failed) and its list of services due for a re-check was read. A cycle that skipped the search for want of Shodan query credits, or whose reads failed, does not move it. |
45
45
  | `kev_exposure.window.from`, `kev_exposure.window.to` | Timestamps, not counts: when Shodan collected the banners of the oldest and the newest of the service×CVE pairs counted, Shodan's own times, not when EchelonGraph stored or re-checked a pair; a span of observations made at different times. Relayed only when every counted pair has such a time; otherwise the note says how many do not. |
46
+ | `kev_exposure.dated_window.from`, `kev_exposure.dated_window.to`, `kev_exposure.dated_window.undated` | Relayed instead of the window when some counted pairs have no banner time on record (pairs stored before the banner time was captured stay so while the InternetDB re-check keeps them): from and to are the same Shodan banner times over the dated pairs only, and undated counts the pairs with none. The note gives both numbers; the window does not describe the undated pairs. |
46
47
 
47
48
  A `newest_kev` row's `exposure_state` says whether its count is a measurement:
48
49
 
@@ -61,6 +62,7 @@ A `newest_kev` row's `exposure_state` says whether its count is a measurement:
61
62
  | `exposed_databases.top_engines`, `exposed_databases.top_countries` | Up to 15 engines and 10 countries, ranked by those services. |
62
63
  | `exposed_databases.pii_likely`, `exposed_databases.pci_likely` | Services whose schema names pass a high-confidence gate for personal data, or for payment-card data. The names are index, database, table or field names in the Shodan or LeakIX banner or in the check's response, never record values; the gate passes one unambiguous term (such as `ssn` or `cardholder`) or two distinct indicators. It is a precision-first schema gate, not a census: a service whose names do not pass it is not counted, whatever it holds, so the other services are not shown to hold no such data. |
63
64
  | `exposed_databases.window.from`, `exposed_databases.window.to` | Timestamps, not counts: when EchelonGraph's check last confirmed the oldest and the newest of the services counted answering without authentication, a span of checks made at different times. Relayed only when every counted service has such a time; otherwise the note says how many do not. |
65
+ | `exposed_databases.dated_window.from`, `exposed_databases.dated_window.to`, `exposed_databases.dated_window.undated` | Relayed instead of the window when some counted services have no such time on record: from and to span the dated services only, and undated counts those with none. |
64
66
  | `exposed_databases.last_run_at` | A timestamp, not a count: when the radar last completed a check, a cycle in which its Shodan search, or the LeakIX fallback, answered at least one query (others may have failed), its list of services due for a re-check was read, and the scan opt-out register could be consulted. A cycle that searched nothing, or whose reads failed, does not move it. |
65
67
 
66
68
  ## `leaked_credentials`
@@ -72,6 +74,7 @@ A `newest_kev` row's `exposure_state` says whether its count is a measurement:
72
74
  | `leaked_credentials.distinct_repos` | Public GitHub repositories with at least one. |
73
75
  | `leaked_credentials.top_providers`, `leaked_credentials.top_types` | Up to 15 providers and secret types, ranked by (repository, secret) pairs. |
74
76
  | `leaked_credentials.window.from`, `leaked_credentials.window.to` | Timestamps, not counts: when EchelonGraph's detector last found the oldest and the newest of the (repository, secret) pairs counted in a public commit. |
77
+ | `leaked_credentials.dated_window.from`, `leaked_credentials.dated_window.to`, `leaked_credentials.dated_window.undated` | Relayed instead of the window when some counted pairs have no such time on record: from and to span the dated pairs only, and undated counts those with none. |
75
78
  | `leaked_credentials.last_run_at` | A timestamp, not a count: when the radar last completed a check, a cycle that read the public GitHub event stream (fetches of some of the commits it lists may have failed). A cycle whose read of that stream failed does not move it. |
76
79
 
77
80
  None of them is validated. Each is a credential-shaped string in a public commit that passed
@@ -90,6 +93,7 @@ labels each one. Only `shadow_ai.confirmed_exposed` counts exposed services.
90
93
  | `confirmed_exposed.by_category` | The same services, by category. |
91
94
  | `confirmed_exposed.last_24h` | Confirmed-exposed services first recorded in the last 24 hours. |
92
95
  | `confirmed_exposed.window.from`, `confirmed_exposed.window.to` | Timestamps, not counts: when EchelonGraph's verifier ran the probe that last decided the oldest and the newest of the confirmed-exposed services, a span of probes made at different times. Relayed only when every one has such a time. |
96
+ | `confirmed_exposed.dated_window.from`, `confirmed_exposed.dated_window.to`, `confirmed_exposed.dated_window.undated` | Relayed instead of the window when some confirmed-exposed services have no such time on record: from and to span the dated ones only, and undated counts those with none. |
93
97
  | `observed.total` | Every Certificate Transparency or Shodan observation on record, whatever its verification state: observed, not exposed. |
94
98
  | `observed.by_category` | The same observations, by category. |
95
99
  | `observed.last_24h` | Observations first recorded in the last 24 hours. |
@@ -34,7 +34,9 @@ JSON-RPC error (code `-32029`) that names the limit and the seconds to wait. A r
34
34
  64 KiB is refused (HTTP 413, code `-32600`), except a `tools/call` of `check_sbom` or
35
35
  `scan_manifest`, or a `prompts/get` of `sbom_review`, which may be up to 6 MiB: enough for a
36
36
  CycloneDX or SPDX document, or a call's lockfiles, at the tools' 5,000,000-character cap. Each server instance reads at most two such large bodies at
37
- once; a third is answered HTTP 503 with `Retry-After` and a JSON-RPC error (code `-32030`). Each
37
+ once; a third waits up to 15 seconds for one to finish (two may wait at once), and is answered
38
+ HTTP 503 with `Retry-After` and a JSON-RPC error (code `-32030`) only when that wait runs out, or at
39
+ once when two are already waiting. Each
38
40
  instance serves at most 64 requests at once; the next waits up to 10 seconds for one to finish,
39
41
  and is then answered the same way. A `subscriptions/listen` stream, which stays open until its
40
42
  client leaves, is not one of the 64.
@@ -46,5 +48,6 @@ server at all (<https://docs.cloud.google.com/run/docs/troubleshooting>). The ho
46
48
  speaks HTTP/2 to Cloud Run, where the same page says a disconnect is passed on. Measured on
47
49
  2026-10-05, Cloud Run reset the stream of a client that stopped reading its answer 15 s in, and the
48
50
  call was logged as a client that closed. Whether a client that aborts mid-call is passed on at once
49
- has not been observed yet. Until it is, an abandoned `check_sbom` or `scan_manifest` call may run for up to its
50
- 50-second budget and hold its slot that long.
51
+ has not been observed yet: on 2026-10-18 two calls aborted 1.5 s in ran on to their answers. So an
52
+ abandoned `check_sbom` or `scan_manifest` call may run for up to its 50-second budget and hold its
53
+ slot that long, and a retry of it waits for that slot (above) instead of being refused at once.
@@ -10,7 +10,12 @@ Every field comes from what the API sends; where the API does not say, the field
10
10
  a note says so. Per tool:
11
11
 
12
12
  - `cve_summary` is `measured`, and its `measured_at` is `summary.last_updated`, the newest
13
- modification time among the active CVE records it counts.
13
+ modification time among the active CVE records it counts. Its `freshness.nvd_poll` is the
14
+ fleet's last completed NVD poll, from the answer's `nvd_poll` block: `last_run_at` is
15
+ `nvd_poll.last_completed_at` as sent, with `scope` `fleet` and the poll's `interval`. It is
16
+ relayed only as a real RFC 3339 time at most 5 minutes after this server's clock. `freshness` is
17
+ `null` where the answer carries no `nvd_poll` (an older API, or the API could not read that time)
18
+ or one without such a time, and the note says which.
14
19
  - `search_cves` is `measured`, with `measured_at` `null`: each record carries its own times. Its
15
20
  `coverage` repeats the list's `total`, `total_counted`, `total_is_lower_bound`,
16
21
  `search_relaxed`, `limit` and `offset`, and adds `returned`, the rows in the page. When
@@ -39,5 +44,12 @@ a note says so. Per tool:
39
44
  then `lookup` (`cpe` or `registry`), `match_layer`, `count`, `capped`, `candidates_capped`,
40
45
  `excluded_count`, `undetermined_count`, `not_affected_count` and `degraded`.
41
46
 
42
- The CVE feed tools' `freshness` is `null`: the feed's answers carry no time at which its
43
- pollers last completed a poll for the feed as a whole.
47
+ `search_cves`' and `get_cve`'s `freshness` is `null`: their answers carry no poll-completion
48
+ time. The fleet's last completed NVD poll is `cve_summary`'s `freshness`, as above.
49
+
50
+ Arguments that break a tool's `inputSchema` (a number outside its range, a value outside its
51
+ `enum`, the wrong JSON type, a required argument left out) are refused before any request is
52
+ made, with the same envelope as every other refusal: `state` `invalid_input`, `error.kind`
53
+ `invalid_input`, `isError` true, and notes that name each argument and the rule it broke (by its
54
+ JSON type, never by a value sent). Through 3.0.2 the MCP SDK answered those itself, with one
55
+ `Input validation error` text block and no `structuredContent` (#3015).