echelongraph-mcp 2.3.4 → 2.4.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.
package/dist/index.js CHANGED
@@ -5,7 +5,8 @@
5
5
  // that EchelonGraph's KEV-exposure radar derives from Shodan data. Every figure is as fresh as
6
6
  // the schedule that refreshes it, and the tool texts say which schedule that is.
7
7
  // stdio transport; no auth required; read-only. It makes no request other than the API call a
8
- // tool needs to answer.
8
+ // tool needs to answer. The hosted Streamable HTTP endpoint (mcp.echelongraph.io, #2316) is
9
+ // http.ts, which serves this file's createServer — one tool module for both.
9
10
  //
10
11
  // Result contract (#1874). A tool answers in exactly one of two shapes, and they never blur:
11
12
  // - success: content[0] is the API's JSON verbatim; content[1] is a one-line note saying
@@ -45,6 +46,14 @@ import { readFileSync } from "node:fs";
45
46
  import { McpServer } from "@modelcontextprotocol/server";
46
47
  import { serveStdio } from "@modelcontextprotocol/server/stdio";
47
48
  import * as z from "zod";
49
+ import { registerKevRecent } from "./tools/kev_recent.js";
50
+ import { registerEPSSHistory } from "./tools/epss_history.js";
51
+ import { registerCheckAffected } from "./tools/check_affected.js";
52
+ import { registerCheckSbom } from "./tools/check_sbom.js";
53
+ import { registerCveIntel } from "./tools/cve_intel.js";
54
+ import { registerGetCwe } from "./tools/get_cwe.js";
55
+ import { isHttpEntrypoint, upstreamHeaders } from "./runtime.js";
56
+ import { registerVendorAdvisoryTools } from "./tools/vendor_advisories.js";
48
57
  // The package actually running, read from the package.json that ships beside dist/. The MCP
49
58
  // handshake (serverInfo) and the User-Agent both carry its name and version, so the API's
50
59
  // access log can count adoption per version without a code change per release.
@@ -64,14 +73,30 @@ const pkgField = (k, fallback) => {
64
73
  const NAME = pkgField("name", "echelongraph-mcp");
65
74
  const VERSION = pkgField("version", "unknown");
66
75
  const BASE = (process.env.ECHELONGRAPH_API_BASE || "https://app.echelongraph.io").replace(/\/+$/, "");
67
- const UA = `${NAME}/${VERSION} (+https://echelongraph.io/pulse/mcp)`;
76
+ // ECHELONGRAPH_MCP_UA (#2724): one product token, such as echelongraph-mcp-synthetic/1.0, put
77
+ // AHEAD of the package's own User-Agent. The API's access log reads the family from the leading
78
+ // token, so a caller that is not a user (our production synthetic) is filed under its own family
79
+ // and never counted as MCP adoption, while the package and version stay readable after it.
80
+ // Unset by default. A value that is not a single token (spaces, control characters, a name over 64
81
+ // or a version over 32 characters) is ignored, with one line on stderr naming its length, not its text.
82
+ const UA_TOKEN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}(?:\/[A-Za-z0-9][A-Za-z0-9._-]{0,31})?$/;
83
+ const UA_PREFIX = (() => {
84
+ const v = process.env.ECHELONGRAPH_MCP_UA;
85
+ if (v === undefined || v === "")
86
+ return "";
87
+ if (UA_TOKEN.test(v))
88
+ return `${v} `;
89
+ process.stderr.write(`echelongraph-mcp: ECHELONGRAPH_MCP_UA ignored: not a single product token (${v.length} characters)\n`);
90
+ return "";
91
+ })();
92
+ const UA = `${UA_PREFIX}${NAME}/${VERSION} (+https://echelongraph.io/pulse/mcp)`;
68
93
  const DEFAULT_TIMEOUT_MS = 15_000;
69
94
  const TIMEOUT_MS = (() => {
70
95
  const n = Number(process.env.ECHELONGRAPH_API_TIMEOUT_MS);
71
96
  return Number.isFinite(n) && n > 0 ? Math.floor(n) : DEFAULT_TIMEOUT_MS;
72
97
  })();
73
98
  // The base as quoted in tool results. A proxy credential in the URL's userinfo is dropped.
74
- const SHOWN_BASE = (() => {
99
+ export const SHOWN_BASE = (() => {
75
100
  try {
76
101
  const u = new URL(BASE);
77
102
  if (!u.username && !u.password)
@@ -127,47 +152,54 @@ function apiMessage(body) {
127
152
  }
128
153
  return snippet(body);
129
154
  }
130
- // One GET against the API. Never throws: every way the call can fail comes back as a typed
131
- // Failure so the tool renders it as an error result. A 2xx whose body is not a JSON object
132
- // (an SPA shell, an edge challenge page, a literal null) is a failure too — it is not data.
133
- async function api(path) {
155
+ // One request against the API, a GET unless init says otherwise. Never throws: every way the call
156
+ // can fail comes back as a typed Failure so the tool renders it as an error result. A 2xx whose
157
+ // body is not a JSON object (an SPA shell, an edge challenge page, a literal null) is a failure
158
+ // too — it is not data. init.headers carries what a caller typed (#1983: X-EG-* headers, never
159
+ // the URL); the User-Agent and Accept are this server's and are not overridden.
160
+ export async function api(path, init) {
134
161
  const ctrl = new AbortController();
135
162
  let timedOut = false;
136
163
  const timer = setTimeout(() => {
137
164
  timedOut = true;
138
165
  ctrl.abort();
139
166
  }, TIMEOUT_MS);
140
- const timeout = () => ({ ok: false, kind: "timeout", path, detail: `no response within ${TIMEOUT_MS} ms` });
167
+ const m = init?.method && init.method.toUpperCase() !== "GET" ? { method: init.method.toUpperCase() } : {};
168
+ const timeout = () => ({ ok: false, kind: "timeout", path, ...m, detail: `no response within ${TIMEOUT_MS} ms` });
141
169
  try {
142
170
  let res;
143
171
  try {
144
172
  res = await fetch(`${BASE}${path}`, {
145
- headers: { "User-Agent": UA, Accept: "application/json" },
173
+ method: init?.method,
174
+ body: init?.body,
175
+ // upstreamHeaders: empty on stdio; on the hosted endpoint, the forward token and the
176
+ // end client's address (runtime.ts, #2316).
177
+ headers: { ...upstreamHeaders(`${BASE}${path}`), ...(init?.headers ?? {}), "User-Agent": UA, Accept: "application/json" },
146
178
  signal: ctrl.signal,
147
179
  });
148
180
  }
149
181
  catch (e) {
150
- return timedOut ? timeout() : { ok: false, kind: "network", path, detail: describeError(e) };
182
+ return timedOut ? timeout() : { ok: false, kind: "network", path, ...m, detail: describeError(e) };
151
183
  }
152
184
  let body;
153
185
  try {
154
186
  body = await res.text();
155
187
  }
156
188
  catch (e) {
157
- return timedOut ? timeout() : { ok: false, kind: "network", path, detail: `reading the body failed: ${describeError(e)}` };
189
+ return timedOut ? timeout() : { ok: false, kind: "network", path, ...m, detail: `reading the body failed: ${describeError(e)}` };
158
190
  }
159
191
  if (!res.ok)
160
- return { ok: false, kind: "http", path, status: res.status, detail: apiMessage(body) };
192
+ return { ok: false, kind: "http", path, ...m, status: res.status, detail: apiMessage(body) };
161
193
  let data;
162
194
  try {
163
195
  data = JSON.parse(body);
164
196
  }
165
197
  catch {
166
198
  const ctype = res.headers.get("content-type") ?? "no content-type";
167
- return { ok: false, kind: "not_json", path, status: res.status, detail: `${ctype}; body starts: ${snippet(body)}` };
199
+ return { ok: false, kind: "not_json", path, ...m, status: res.status, detail: `${ctype}; body starts: ${snippet(body)}` };
168
200
  }
169
201
  if (data === null || typeof data !== "object") {
170
- return { ok: false, kind: "not_object", path, status: res.status, detail: `body was ${snippet(body)}` };
202
+ return { ok: false, kind: "not_object", path, ...m, status: res.status, detail: `body was ${snippet(body)}` };
171
203
  }
172
204
  return { ok: true, status: res.status, data };
173
205
  }
@@ -178,21 +210,22 @@ async function api(path) {
178
210
  const text = (t) => ({ type: "text", text: t });
179
211
  // A note, one sentence per entry. Sentences end in ".", "!" or "?" before whitespace, so a URL,
180
212
  // a version or a field path (observed.total, crt.sh) does not split one.
181
- const sentences = (t) => t
213
+ export const sentences = (t) => t
182
214
  .replace(/\s+/g, " ")
183
215
  .trim()
184
216
  .split(/(?<=[.!?])\s+/)
185
217
  .filter(Boolean);
186
218
  // A timestamp worth relaying as one: see realInstant below.
187
- const instantOrNull = (s) => (realInstant(s) ? s : null);
219
+ export const instantOrNull = (s) => (realInstant(s) ? s : null);
188
220
  // One sentence per failure kind: where we looked, for what, and what came back.
189
221
  function describeFailure(f) {
190
- const where = `from ${SHOWN_BASE} for GET ${f.path}`;
222
+ const verb = f.method ?? "GET";
223
+ const where = `from ${SHOWN_BASE} for ${verb} ${f.path}`;
191
224
  switch (f.kind) {
192
225
  case "network":
193
- return `EchelonGraph at ${SHOWN_BASE} could not be reached for GET ${f.path}: ${f.detail}.`;
226
+ return `EchelonGraph at ${SHOWN_BASE} could not be reached for ${verb} ${f.path}: ${f.detail}.`;
194
227
  case "timeout":
195
- return `EchelonGraph at ${SHOWN_BASE} did not answer GET ${f.path} within ${TIMEOUT_MS} ms.`;
228
+ return `EchelonGraph at ${SHOWN_BASE} did not answer ${verb} ${f.path} within ${TIMEOUT_MS} ms.`;
196
229
  case "http":
197
230
  return `EchelonGraph answered HTTP ${f.status} ${where} — the API said: ${f.detail}.`;
198
231
  case "not_json":
@@ -204,7 +237,7 @@ function describeFailure(f) {
204
237
  }
205
238
  }
206
239
  // What a model must not conclude from a failure.
207
- const NOT_A_FINDING = "The lookup did not complete, so this is not a finding: do not report it as zero, none found, absent, or unexposed. Retry later or check ECHELONGRAPH_API_BASE.";
240
+ export const NOT_A_FINDING = "The lookup did not complete, so this is not a finding: do not report it as zero, none found, absent, or unexposed. Retry later or check ECHELONGRAPH_API_BASE.";
208
241
  // A 404 is the one non-2xx that carries an answer of its own — the API's not-found message,
209
242
  // quoted in the sentence before it — so it is worded as "no record", not as an outage.
210
243
  const NO_RECORD = "The API returned no record for this request; the quoted message is the API's own explanation. This says nothing about internet exposure.";
@@ -242,25 +275,25 @@ const envelopeText = (structured, said, own) => {
242
275
  // structuredContent and again as the last text block. The error quotes only what
243
276
  // describeFailure already quotes (a redacted cause, the API's own message). Every note is a
244
277
  // sentence of the message, so the text envelope carries none.
245
- const failure = (state, message, error, coverage = null) => {
278
+ export const failure = (state, message, error, coverage = null) => {
246
279
  const structuredContent = { state, measured_at: null, method: null, coverage, freshness: null, notes: sentences(message), error };
247
280
  return { content: [text(message), envelopeText(structuredContent, message, [])], structuredContent, isError: true };
248
281
  };
249
282
  const errorOf = (f) => ({ kind: f.kind, path: f.path, status: f.status ?? null, message: f.detail });
250
- const failed = (tool, f) => {
283
+ export const failed = (tool, f) => {
251
284
  if (f.kind === "http" && f.status === 400) {
252
285
  return failure("invalid_input", `${tool} FAILED ${INVALID_INPUT}: ${describeFailure(f)} ${REJECTED_INPUT}`, errorOf(f));
253
286
  }
254
287
  return failure("failed", `${tool} FAILED: ${describeFailure(f)} ${f.kind === "http" && f.status === 404 ? NO_RECORD : NOT_A_FINDING}`, errorOf(f));
255
288
  };
256
- const badInput = (tool, why) => failure("invalid_input", `${tool} FAILED ${INVALID_INPUT}: ${why}. Nothing was looked up, so this is not a finding.`, {
289
+ export const badInput = (tool, why) => failure("invalid_input", `${tool} FAILED ${INVALID_INPUT}: ${why}. Nothing was looked up, so this is not a finding.`, {
257
290
  kind: "invalid_input",
258
291
  path: null,
259
292
  status: null,
260
293
  message: why,
261
294
  });
262
295
  // A bug in this server is still not a finding — it must never surface as an empty success.
263
- const crashed = (tool, e) => failure("failed", `${tool} FAILED inside the MCP server while querying ${SHOWN_BASE}: ${describeError(e)}. ${NOT_A_FINDING}`, {
296
+ export const crashed = (tool, e) => failure("failed", `${tool} FAILED inside the MCP server while querying ${SHOWN_BASE}: ${describeError(e)}. ${NOT_A_FINDING}`, {
264
297
  kind: "internal",
265
298
  path: null,
266
299
  status: null,
@@ -283,7 +316,7 @@ function unexpectedShape(tool, error) {
283
316
  });
284
317
  }
285
318
  // Every success is checked against the tool's own outputSchema before it is returned.
286
- const checked = (tool, schema, r) => {
319
+ export const checked = (tool, schema, r) => {
287
320
  if (r.isError)
288
321
  return r;
289
322
  const p = schema.safeParse(r.structuredContent);
@@ -291,35 +324,35 @@ const checked = (tool, schema, r) => {
291
324
  };
292
325
  // A success: content[0] is the data, content[1] the note, content[2] the envelope less both;
293
326
  // the envelope carries both, the note as sentences after any the envelope adds about itself.
294
- const succeeded = (data, note, env) => {
327
+ export const succeeded = (data, note, env) => {
295
328
  const own = env.notes ?? [];
296
329
  const structuredContent = { ...env, notes: [...own, ...sentences(note)], data };
297
330
  return { content: [text(JSON.stringify(data, null, 2)), text(note), envelopeText(structuredContent, note, own)], structuredContent };
298
331
  };
299
- const okHead = (tool, status) => `${tool} OK: EchelonGraph answered${status === undefined ? "" : ` HTTP ${status}`} from ${SHOWN_BASE}.`;
332
+ export const okHead = (tool, status) => `${tool} OK: EchelonGraph answered${status === undefined ? "" : ` HTTP ${status}`} from ${SHOWN_BASE}.`;
300
333
  // Tolerant readers for the note: a missing or renamed field degrades the sentence, never
301
334
  // the result — content[0] still carries whatever the API sent.
302
- const field = (o, ...keys) => keys.reduce((v, k) => (v !== null && typeof v === "object" ? v[k] : undefined), o);
303
- const numAt = (o, ...keys) => {
335
+ export const field = (o, ...keys) => keys.reduce((v, k) => (v !== null && typeof v === "object" ? v[k] : undefined), o);
336
+ export const numAt = (o, ...keys) => {
304
337
  const v = field(o, ...keys);
305
338
  return typeof v === "number" && Number.isFinite(v) ? v : undefined;
306
339
  };
307
- const strAt = (o, ...keys) => {
340
+ export const strAt = (o, ...keys) => {
308
341
  const v = field(o, ...keys);
309
342
  return typeof v === "string" && v ? v : undefined;
310
343
  };
311
- const lenAt = (o, ...keys) => {
344
+ export const lenAt = (o, ...keys) => {
312
345
  const v = field(o, ...keys);
313
346
  return Array.isArray(v) ? v.length : undefined;
314
347
  };
315
- const boolAt = (o, ...keys) => {
348
+ export const boolAt = (o, ...keys) => {
316
349
  const v = field(o, ...keys);
317
350
  return typeof v === "boolean" ? v : undefined;
318
351
  };
319
352
  // A timestamp worth repeating as prose. The API has been seen answering the Go zero time
320
353
  // (0001-01-01T00:00:00Z) where it had no instant to give; that is not a time, so it is never
321
354
  // presented as one.
322
- const realInstant = (s) => s !== undefined && Date.parse(s) > 0;
355
+ export const realInstant = (s) => s !== undefined && Date.parse(s) > 0;
323
356
  // ── The CVE feed tools: cve_summary, search_cves, get_cve ──
324
357
  //
325
358
  // Their numbers are CVE records and scores, not exposure counts, so a success is measured.
@@ -364,7 +397,7 @@ function cveScoreNote(cve, rec) {
364
397
  }
365
398
  // search_cves' sentences about its rows' scores, naming each row a sentence is about, each
366
399
  // starting with a space; none when every row says it is scored.
367
- function rowsScoreNote(d) {
400
+ export function rowsScoreNote(d) {
368
401
  const rows = field(d, "cves");
369
402
  if (!Array.isArray(rows) || rows.length === 0)
370
403
  return "";
@@ -550,6 +583,16 @@ async function searchCVEs(a) {
550
583
  return crashed(tool, e);
551
584
  }
552
585
  }
586
+ // #2720: cpe_configurations is NVD's configuration tree, stored since migration 152 and filled
587
+ // as the NVD poller next writes each CVE, so an answer without it is "not stored", never "no
588
+ // product affected". Said in the envelope's own notes, so the note in content[1] is unchanged.
589
+ function cpeConfigurationsNote(rec) {
590
+ const cfg = field(rec, "cpe_configurations");
591
+ if (Array.isArray(cfg)) {
592
+ return `cpe_configurations is NVD's configurations array as sent (${cfg.length}): a cpeMatch with vulnerable false in an AND configuration is a platform, not an affected product.`;
593
+ }
594
+ return "No cpe_configurations in the answer: EchelonGraph holds no stored NVD configuration tree for this CVE, which does not mean no product is affected; cpe_match lists its CPE criteria flattened, without the AND/OR operators.";
595
+ }
553
596
  async function getCVE(cve_id) {
554
597
  const tool = "get_cve";
555
598
  const id = cve_id.trim();
@@ -572,6 +615,7 @@ async function getCVE(cve_id) {
572
615
  ? "measured_at is the record's updated_at: when EchelonGraph last wrote this record."
573
616
  : "measured_at is null: the record carries no real updated_at.",
574
617
  NO_FEED_FRESHNESS,
618
+ cpeConfigurationsNote(r.data),
575
619
  ],
576
620
  });
577
621
  }
@@ -600,7 +644,7 @@ async function getCVE(cve_id) {
600
644
  // stored.
601
645
  // Every text calls last_seen a write or refresh time, never a sighting ("last seen listening",
602
646
  // "re-seen", "most recently seen"), and it is never measured_at (see observedAt).
603
- const CVE_ID = /^CVE-\d{4}-\d{4,}$/i;
647
+ export const CVE_ID = /^CVE-\d{4}-\d{4,}$/i;
604
648
  // Quoted when the API does not send its own `method` string.
605
649
  const EXPOSURE_METHOD = "Shodan banner match on the radar's tracked products; up to 100 ip:port services per product query; searched every 12 h when Shodan query credits allow";
606
650
  const SHODAN_ATTRIBUTION = "Exposure counts are derived from Shodan data.";
@@ -1645,7 +1689,7 @@ const ErrorSchema = z.strictObject({
1645
1689
  status: z.number().int().nullable().describe("The HTTP status, when the API answered one."),
1646
1690
  message: z.string().describe("The cause: the API's own message, or what went wrong."),
1647
1691
  });
1648
- const NOTES = z.array(z.string()).describe("Caveats, one sentence each.");
1692
+ export const NOTES = z.array(z.string()).describe("Caveats, one sentence each.");
1649
1693
  // The success states (#2465). 2.1.0 described not_assessed as no dated measurement whose numbers
1650
1694
  // were all denied the status of findings, on the same schema as a cve_exposure answer relaying
1651
1695
  // services on record for a CISA-KEV CVE, which is not_assessed because its count is undated
@@ -1654,7 +1698,7 @@ const NOTES = z.array(z.string()).describe("Caveats, one sentence each.");
1654
1698
  // says both, and leaves what each count is to the notes and exposure_state, which say it per
1655
1699
  // answer; it makes no blanket claim about the numbers, in either direction.
1656
1700
  const SUCCESS_STATE = "measured: a measurement of what was asked; an exposure count is measured only with measured_at and method. not_assessed: the answer holds no dated measurement of what was asked, so no count in it is presented as one; it can still relay a count, as what the source holds on record, undated, and its notes (and exposure_state, where the result carries it) say what each count is.";
1657
- function envelopeSchema(o) {
1701
+ export function envelopeSchema(o) {
1658
1702
  const coverage = o.coverage ? o.coverage.nullable() : z.null();
1659
1703
  return z.discriminatedUnion("state", [
1660
1704
  z.strictObject({
@@ -1679,7 +1723,11 @@ function envelopeSchema(o) {
1679
1723
  ]);
1680
1724
  }
1681
1725
  // A field of the API's JSON: optional, and null where the Go type can marshal null.
1682
- const opt = (t) => t.nullable().optional();
1726
+ export const opt = (t) => t.nullable().optional();
1727
+ // #2720: what cpe_match and cpe_configurations are, one constant string each (the site's
1728
+ // tool-claims check folds only constant strings).
1729
+ const CPE_MATCH_DESCRIPTION = "The record's CPE match criteria as one flat list, every configuration's cpeMatch entries together: the AND/OR operator and negate of each configuration and node, versionStartExcluding and matchCriteriaId are not in it, so a CPE that is only a platform a product runs on reads as one more entry.";
1730
+ const CPE_CONFIGURATIONS_DESCRIPTION = "NVD's configurations array as NVD sent it: each configuration and node with its operator (AND, OR) and negate, and each cpeMatch with criteria, vulnerable, versionStartIncluding, versionStartExcluding, versionEndIncluding, versionEndExcluding and matchCriteriaId. A cpeMatch with vulnerable false inside an AND configuration is the platform the vulnerable product runs on, not an affected product. Absent: EchelonGraph holds no stored NVD configuration for the CVE, which is not a finding that no product is affected.";
1683
1731
  const CVERecord = z.looseObject({
1684
1732
  cve_id: opt(z.string()),
1685
1733
  description: opt(z.string()),
@@ -1701,6 +1749,9 @@ const CVERecord = z.looseObject({
1701
1749
  kev_added_date: opt(z.string()),
1702
1750
  ghsa_id: opt(z.string()),
1703
1751
  references: z.unknown().optional(),
1752
+ // #2720: NVD's configurations, losslessly, beside the flat list (see cpeConfigurationsNote).
1753
+ cpe_match: z.unknown().optional().describe(CPE_MATCH_DESCRIPTION),
1754
+ cpe_configurations: opt(z.array(z.unknown())).describe(CPE_CONFIGURATIONS_DESCRIPTION),
1704
1755
  published: opt(z.string()),
1705
1756
  modified: opt(z.string()),
1706
1757
  updated_at: opt(z.string()),
@@ -1888,7 +1939,7 @@ const INSTRUCTIONS = [
1888
1939
  `${SHODAN_ATTRIBUTION} ${SHODAN_OWNERSHIP}`,
1889
1940
  "exposure_radar's mcp_servers counts use no Shodan data: their hostnames come from EchelonGraph's own Certificate Transparency feed.",
1890
1941
  ].join(" ");
1891
- const ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
1942
+ export const ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
1892
1943
  const CVE_ID_ARG = z.string().describe("a CVE ID, e.g. CVE-2023-44487");
1893
1944
  // What each description says about its structured result, naming only fields its schema holds.
1894
1945
  const FEED_ENVELOPE = "Its structured result carries state (measured), measured_at, method, coverage, freshness (null: the feed serves no poll-completion time) and notes, with data equal to the API's JSON; the result's last text block repeats it without data (the first text block) and without the note's sentences (the text block before it), with which notes ends.";
@@ -1905,9 +1956,12 @@ const SUMMARY_REJECTED_DESCRIPTION = "summary.rejected counts the CVE records re
1905
1956
  // #2535: what search_cves and get_cve say about score_assessed, one constant string, since the
1906
1957
  // site's tool-claims check (marketing-site lib/mcpToolClaims.test.ts) folds only constant strings.
1907
1958
  const SCORE_ASSESSED_DESCRIPTION = "echelongraph_score, echelongraph_severity and echelongraph_risk are EchelonGraph's score only when score_assessed is true. With score_assessed false the CVE is NOT YET SCORED, not scored 0: any of those three it carries (0, NONE, 0) is a placeholder, not a rating, and does not mean the CVE is harmless; the API may leave them out instead, score_confidence is NONE, and score_unassessed_reason says why (a rejected record, withdrawn by its numbering authority, is never scored, and the note says NOT SCORED). The note labels each such CVE NOT YET SCORED: report it that way, never as a score of 0. An answer with no score_assessed (an API older than that field) does not say whether the CVE was scored, the note says so, and a 0 there is not a rating either.";
1908
- // One server, built per connection by serveStdio for whichever era the client opens with.
1959
+ // The shared helpers a tool in src/tools/ is handed (#2718), so its file holds its own logic.
1960
+ const TOOL_KIT = { api, succeeded, failed, badInput, crashed, checked, okHead, envelopeSchema, annotations: ANNOTATIONS, cveIdArg: CVE_ID_ARG, instant: Instant };
1961
+ // One server, built per connection by serveStdio for whichever era the client opens with, and
1962
+ // per request by the hosted HTTP entrypoint (http.ts, #2316): one tool module for both.
1909
1963
  // tools/list answers in registration order, which is the order below.
1910
- function createServer() {
1964
+ export function createServer() {
1911
1965
  const server = new McpServer({ name: NAME, version: VERSION }, { instructions: INSTRUCTIONS, capabilities: { tools: { listChanged: false } } });
1912
1966
  server.registerTool("cve_summary", {
1913
1967
  title: "CVE feed summary",
@@ -1930,7 +1984,7 @@ function createServer() {
1930
1984
  }, async (a) => checked("search_cves", SEARCH_CVES_OUTPUT, await searchCVEs(a)));
1931
1985
  server.registerTool("get_cve", {
1932
1986
  title: "CVE detail",
1933
- description: `Full record for one CVE: description, severity, cvss_v3_score, cvss_v4_score and cvss_v4_severity (when the record has a CVSS v4 score), echelongraph_score and echelongraph_severity with score_confidence (EchelonGraph's multi-source score) and score_assessed (whether EchelonGraph has scored it), epss_score and epss_percentile, CISA-KEV status (kev_listed, kev_added_date, and kev_ransomware for known ransomware-campaign use), ghsa_id (GitHub GHSA), references, published, modified and updated_at; each field only where the record has it. Pass a CVE ID like CVE-2023-44487. ${SCORE_ASSESSED_DESCRIPTION} ${FEED_ENVELOPE}`,
1987
+ description: `Full record for one CVE: description, severity, cvss_v3_score, cvss_v4_score and cvss_v4_severity (when the record has a CVSS v4 score), echelongraph_score and echelongraph_severity with score_confidence (EchelonGraph's multi-source score) and score_assessed (whether EchelonGraph has scored it), epss_score and epss_percentile, CISA-KEV status (kev_listed, kev_added_date, and kev_ransomware for known ransomware-campaign use), ghsa_id (GitHub GHSA), references, cpe_match (the CPE criteria as one flat list) and cpe_configurations (NVD's configurations as NVD sent them, with each AND/OR operator, negate, versionStartExcluding and matchCriteriaId; absent where EchelonGraph has stored none, which is not a finding that no product is affected), published, modified and updated_at; each field only where the record has it. Pass a CVE ID like CVE-2023-44487. ${SCORE_ASSESSED_DESCRIPTION} ${FEED_ENVELOPE}`,
1934
1988
  inputSchema: z.object({ cve_id: CVE_ID_ARG }),
1935
1989
  outputSchema: GET_CVE_OUTPUT,
1936
1990
  annotations: ANNOTATIONS,
@@ -1948,9 +2002,26 @@ function createServer() {
1948
2002
  outputSchema: EXPOSURE_RADAR_OUTPUT,
1949
2003
  annotations: ANNOTATIONS,
1950
2004
  }, async () => checked("exposure_radar", EXPOSURE_RADAR_OUTPUT, await exposureRadar()));
2005
+ registerKevRecent(server, { api, succeeded, failed, badInput, crashed, checked, envelopeSchema, okHead, annotations: ANNOTATIONS });
2006
+ registerEPSSHistory(server, TOOL_KIT);
2007
+ // #2716: its logic, schemas and description live in tools/check_affected.ts.
2008
+ registerCheckAffected(server, { api, succeeded, failed, badInput, crashed, checked, okHead, envelopeSchema, annotations: ANNOTATIONS });
2009
+ // #2721: tools/check_sbom.ts, on this file's api(), envelope and failure contract.
2010
+ registerCheckSbom(server, { api, failed, badInput, crashed, checked, succeeded, okHead, envelopeSchema, annotations: ANNOTATIONS });
2011
+ registerCveIntel(server);
2012
+ registerGetCwe(server);
2013
+ // #2719: vendor_advisories_for_cve, get_vendor_advisory, search_vendor_advisories.
2014
+ registerVendorAdvisoryTools(server, { api, failed, badInput, crashed, succeeded, checked, okHead, envelopeSchema, annotations: ANNOTATIONS });
1951
2015
  return server;
1952
2016
  }
2017
+ // The package identity and API settings, for the hosted HTTP entrypoint's health answer and
2018
+ // start-up line (http.ts).
2019
+ export { NAME, VERSION, TIMEOUT_MS }; // SHOWN_BASE is exported where it is declared.
1953
2020
  // Both eras on stdio: a 2026-07-28 client's server/discover, and a 2025-era client's initialize.
1954
- serveStdio(createServer, { legacy: "serve" });
1955
- // MCP uses stdout for the protocol — diagnostics go to stderr.
1956
- console.error(`EchelonGraph CVE MCP server ${NAME}@${VERSION} running (API: ${SHOWN_BASE}, timeout ${TIMEOUT_MS} ms)`);
2021
+ // Not when the hosted HTTP entrypoint loaded this module (runtime.ts): it serves createServer
2022
+ // itself, and a stdio transport there would read the container's stdin.
2023
+ if (!isHttpEntrypoint()) {
2024
+ serveStdio(createServer, { legacy: "serve" });
2025
+ // MCP uses stdout for the protocol — diagnostics go to stderr.
2026
+ console.error(`EchelonGraph CVE MCP server ${NAME}@${VERSION} running (API: ${SHOWN_BASE}, timeout ${TIMEOUT_MS} ms)`);
2027
+ }
@@ -0,0 +1,65 @@
1
+ // Which entrypoint is running, and — for the hosted HTTP entrypoint only — the end client a
2
+ // request is being answered for (#2316).
3
+ //
4
+ // One tool module (index.ts) serves two entrypoints: the npm package's stdio bin (index.ts
5
+ // itself) and the hosted remote endpoint at mcp.echelongraph.io (http.ts). http.ts marks itself
6
+ // here BEFORE it loads index.ts, which is how index.ts knows not to start the stdio transport.
7
+ // The default is stdio: an entrypoint that forgets to mark itself gets the npm behaviour,
8
+ // which is the published package's, unchanged.
9
+ //
10
+ // The npm package sends nothing extra, ever: upstreamHeaders() is empty unless the HTTP
11
+ // entrypoint is running (the no-telemetry promise, #2304).
12
+ import { AsyncLocalStorage } from "node:async_hooks";
13
+ let httpEntrypoint = false;
14
+ /** Called by http.ts before it imports index.ts. */
15
+ export function markHttpEntrypoint() {
16
+ httpEntrypoint = true;
17
+ }
18
+ export function isHttpEntrypoint() {
19
+ return httpEntrypoint;
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
+ const clientStore = new AsyncLocalStorage();
25
+ export function withClient(clientIp, fn) {
26
+ return clientStore.run({ clientIp }, fn);
27
+ }
28
+ /** The header that carries the forward token, and the one that names the client (core-backend waf/mcpforward.go). */
29
+ export const FORWARD_TOKEN_HEADER = "X-EG-MCP-Forward";
30
+ export const CLIENT_IP_HEADER = "X-EG-Client-IP";
31
+ /** The token, trimmed: a Secret Manager value can carry a trailing newline. */
32
+ export function forwardToken() {
33
+ return (process.env.ECHELONGRAPH_FORWARD_TOKEN ?? "").trim();
34
+ }
35
+ // The hosts the token may be sent to: core-backend's public host, plus one exact hostname an
36
+ // operator (or a test's stub) names. Host-exact, never a suffix, for the reason marketing-site's
37
+ // internalApiHeaders gives: nothing look-alike may coax the token out, even if
38
+ // ECHELONGRAPH_API_BASE is ever pointed somewhere else.
39
+ function tokenMayGoTo(url) {
40
+ let host;
41
+ try {
42
+ host = new URL(url).hostname;
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ const extra = (process.env.ECHELONGRAPH_FORWARD_HOST ?? "").trim();
48
+ return host === "app.echelongraph.io" || (extra !== "" && host === extra);
49
+ }
50
+ /**
51
+ * The extra request headers for one API call to `url`: on the HTTP entrypoint, with a token
52
+ * configured, a client known and the URL on the allowlist, the token and the client's address;
53
+ * otherwise nothing. Why the API believes them, and under what rule, is core-backend's
54
+ * waf.MCPForwardedClient: without them every remote user would be keyed on this service's
55
+ * one address and share one budget (#2212).
56
+ */
57
+ export function upstreamHeaders(url) {
58
+ if (!httpEntrypoint)
59
+ return {};
60
+ const token = forwardToken();
61
+ const clientIp = clientStore.getStore()?.clientIp;
62
+ if (!token || !clientIp || !tokenMayGoTo(url))
63
+ return {};
64
+ return { [FORWARD_TOKEN_HEADER]: token, [CLIENT_IP_HEADER]: clientIp };
65
+ }