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/README.md +54 -3
- package/dist/http.js +240 -0
- package/dist/httpPolicy.js +244 -0
- package/dist/index.js +116 -45
- package/dist/runtime.js +65 -0
- package/dist/tools/check_affected.js +269 -0
- package/dist/tools/check_sbom.js +329 -0
- package/dist/tools/cve_intel.js +276 -0
- package/dist/tools/epss_history.js +125 -0
- package/dist/tools/get_cwe.js +148 -0
- package/dist/tools/kev_recent.js +190 -0
- package/dist/tools/vendor_advisories.js +316 -0
- package/package.json +4 -2
- package/server.json +8 -2
package/README.md
CHANGED
|
@@ -22,6 +22,19 @@ API call a tool needs to answer.
|
|
|
22
22
|
| `get_cve` | Full record for one CVE: CVSS v3 and (when scored) v4, the EchelonGraph score and its confidence, whether EchelonGraph has scored it (`score_assessed`), EPSS, CISA-KEV status and known ransomware use, the GitHub GHSA id, references, and its published, modified and `updated_at` times. |
|
|
23
23
|
| `cve_exposure` | Internet-exposure footprint for a CVE: exposed service count (distinct ip:port, the `exposed_hosts` field) + country/product breakdown, from the KEV-exposure radar. |
|
|
24
24
|
| `exposure_radar` | Aggregate totals across the exposure radars: services running CISA-KEV CVEs; unauthenticated data stores and observability UIs, found through Shodan (LeakIX when Shodan query credits run low) and then confirmed by EchelonGraph's own identified check, which is not a pure read (on Redis it names its client; on ClickHouse its query lands in the server's query log); leaked credentials; shadow AI; and MCP servers found in EchelonGraph's own Certificate Transparency feed, by RFC 9728 verdict, protocol era and transport. Every number is labelled by what it counts, and a field the tool cannot label is left out and named. |
|
|
25
|
+
| `kev_recent` | The CVEs CISA has added to its Known Exploited Vulnerabilities catalog, newest first (`kev_added_date`), from EchelonGraph's copy of the catalog, polled from CISA every 5 minutes: due date, vendor, product, known ransomware use, EchelonGraph's severity, CVSS, EPSS and `eg_kev_tier`, and `our_first_seen_kev`. Filter by date range, ransomware and vendor; page with `limit` and `next_cursor`. Dated by `last_successful_fetch_at`, our last successful fetch of CISA's feed; the filters travel as request headers, never in the URL. |
|
|
26
|
+
| `epss_history` | How one CVE's EPSS score has changed, as EchelonGraph recorded it: one point per recorded change (`series_kind` `change_only`), never a daily series, with the value now and `series_starts_at`, when recording began; before it a missing point means not recorded, not unchanged. |
|
|
27
|
+
| `check_affected` | Whether a product (its NVD CPE product token) or a registry package (`ecosystem` and `package`) at a given version is affected by known CVEs, from the matcher behind echelongraph.io/am-i-affected: `assessed` first (false: not evaluated, with `not_assessed_reason`, and a count of 0 then is not "not affected"), the matching CVEs with `kev_listed`, `ransomware`, `epss_score`, `effective_score` and `score_assessed`, and advisories it cannot decide counted as `undetermined_count`, never as safe. What you look up travels in request headers, never in the URL. |
|
|
28
|
+
| `check_sbom` | A dependency list checked against EchelonGraph's advisory corpus (OSV.dev records), one verdict per component: `affected`, `not_affected`, `undetermined` or `not_assessed`, each with its `not_assessed_reason`. Pass up to 200 purls, or a CycloneDX JSON or SPDX JSON document: the purls are read from it on your machine and only they are sent, in a POST body; the document is not. A deb, apk or rpm purl without a `distro` qualifier naming its release is not assessed (`distro_release_unknown`): EchelonGraph does not guess a release. Only `not_affected` is clean. No ranking, no score. |
|
|
29
|
+
| `cve_intel` | Weakness, public exploit code, affected packages and fixed versions for one CVE, from EchelonGraph's per-CVE enrichment: `cwes`, `exploits` (at most 10, verified first) with `exploits_total`, `exploits_capped`, `exploits_by_kind` and `exploits_by_status`, `affected_packages`, `fixed_versions` and `timeline`. `verified_status` is the label stored with each reference, not a guarantee that the exploit works. An empty `exploits` list is not evidence that no public exploit exists; a section the API could not read is named in `coverage.sections_failed`, never relayed as an empty list. |
|
|
30
|
+
| `get_cwe` | One CWE (weakness class) and the CVEs classified under it: `name` and `description` from the MITRE CWE catalog EchelonGraph embeds, `total`, and one page of 50 `cves`, ordered as `order` states (CISA-KEV-listed first, then EchelonGraph score). A `total` of 0 says that no CVE in EchelonGraph's feed is classified under that CWE, not that none exists. |
|
|
31
|
+
| `vendor_advisories_for_cve` | The vendor-published advisories (Microsoft MSRC, Red Hat, Cisco, Palo Alto Networks, GitHub GHSA and the other feeds EchelonGraph polls) that name one CVE, newest first, at most 20. |
|
|
32
|
+
| `get_vendor_advisory` | One vendor advisory in full: description, severity, `cve_ids` and the subset with a CVE record here (`known_cve_ids`), `affected_products`, `remediation` and `references`. |
|
|
33
|
+
| `search_vendor_advisories` | Search vendor advisories by text (title, description, vendor, advisory ID, products, CVE IDs), vendor, severity and whether they name a CVE. The search text is sent in a request header, never in the URL. |
|
|
34
|
+
|
|
35
|
+
The three vendor-advisory tools relay `vendor_published_at` (the vendor's date), `our_first_seen_at`
|
|
36
|
+
(when EchelonGraph first recorded the advisory) and `withdrawn` (the vendor rescinded it; the note
|
|
37
|
+
names each one, and the search leaves them out).
|
|
25
38
|
|
|
26
39
|
Every figure is as fresh as the schedule that refreshes it. The CVE feed is polled from its
|
|
27
40
|
sources on a schedule; each radar refreshes on its own.
|
|
@@ -276,6 +289,38 @@ above, each a whole number adding up to the count it divides, is left out whole
|
|
|
276
289
|
field this version does not know is left out, and named only when its name is shaped like a
|
|
277
290
|
field name.
|
|
278
291
|
|
|
292
|
+
### How `check_affected` decides
|
|
293
|
+
|
|
294
|
+
`check_affected` asks the matcher behind
|
|
295
|
+
[echelongraph.io/am-i-affected](https://echelongraph.io/am-i-affected) whether a version is
|
|
296
|
+
affected, by one of two lookup paths:
|
|
297
|
+
|
|
298
|
+
- **CPE path** — `product` and `version`. `product` is the NVD CPE product token (`openssl`,
|
|
299
|
+
`nginx`), which can differ from a package name. It returns the CVEs whose NVD CPE match
|
|
300
|
+
criteria name that product with a version range that includes the version. It matches the
|
|
301
|
+
token across vendors, so each match carries `cpe_vendor` and `vendor_unknown`: a match with
|
|
302
|
+
`vendor_unknown` true is real, but its vendor is not verified to be yours.
|
|
303
|
+
- **Registry path** — `ecosystem` (`npm`, `PyPI`, `Maven`, …), `package` and `version`. Each OSV
|
|
304
|
+
advisory record EchelonGraph holds for the package is decided against the version as
|
|
305
|
+
affected (a match), not affected (`not_affected_count`) or undetermined.
|
|
306
|
+
|
|
307
|
+
Read `assessed` before `count`. `assessed` false means the lookup did not evaluate the
|
|
308
|
+
component, and `not_assessed_reason` says why: `product_not_in_cpe_corpus`,
|
|
309
|
+
`package_not_cpe_nameable`, `candidate_load_pending` (with `degraded` true),
|
|
310
|
+
`candidate_window_truncated`, `package_not_in_advisory_corpus` or `no_decidable_advisory`. The
|
|
311
|
+
result's `state` is then `not_assessed`, and its `count` of 0 never means "not affected". An
|
|
312
|
+
advisory whose version range cannot be decided at this version is counted in
|
|
313
|
+
`undetermined_count` (up to 50 listed in `undetermined`) and is never reported as safe. `capped`
|
|
314
|
+
says the match list stopped at its cap, and `candidates_capped` that not every candidate CVE was
|
|
315
|
+
loaded. A failed advisory lookup (`advisory_lookup_failed`) comes back as a failure. Each match
|
|
316
|
+
keeps `kev_listed`, `ransomware`, `epss_score`, `effective_score`, `effective_severity` and
|
|
317
|
+
`score_assessed` as the API sent them.
|
|
318
|
+
|
|
319
|
+
`product`, `version`, `ecosystem` and `package` travel in the `X-EG-Product`, `X-EG-Version`,
|
|
320
|
+
`X-EG-Ecosystem` and `X-EG-Package` request headers, never in the URL, so request logs and
|
|
321
|
+
trace spans that record URLs do not hold what was looked up. The endpoint reads a CPE vendor
|
|
322
|
+
from the URL only, so this tool takes none.
|
|
323
|
+
|
|
279
324
|
## What a result means
|
|
280
325
|
|
|
281
326
|
Every tool answers in one of two shapes, so a model reading the result cannot mistake an
|
|
@@ -323,7 +368,7 @@ declares its shape as an `outputSchema` in `tools/list`, with a title and the an
|
|
|
323
368
|
| `state` | `measured`, `not_assessed`, `failed` or `invalid_input` (below). |
|
|
324
369
|
| `measured_at` | When the underlying observation was made, as the API states it. `null` when the answer does not say or holds no observation; never the Go zero time. |
|
|
325
370
|
| `method` | How the numbers were produced. `null` on a failure. |
|
|
326
|
-
| `coverage` | What the answer covers, where the tool can say: `in_scope` for `cve_exposure`, the list's own account of its count for `search_cves`,
|
|
371
|
+
| `coverage` | What the answer covers, where the tool can say: `in_scope` for `cve_exposure`, the list's own account of its count for `search_cves`, the radars that answered for `exposure_radar`, and `assessed`, `not_assessed_reason`, the lookup path and the match counts for `check_affected`. |
|
|
327
372
|
| `freshness` | The producing radar's last completed check (`last_run_at`), where the API serves one; `null` where it serves none. |
|
|
328
373
|
| `notes` | The caveats, one sentence each: what the envelope itself needs saying, then the note from the text block. |
|
|
329
374
|
| `data` | On a success only: the same JSON as the first text block. |
|
|
@@ -372,14 +417,19 @@ a note says so. Per tool:
|
|
|
372
417
|
to `mcp_servers.window.to`, over checks made at different times, which is not one observation
|
|
373
418
|
time. Its numbers keep the labels above. `freshness` holds each radar's `last_run_at` where the
|
|
374
419
|
API serves one, for `shadow_ai` also `running`, and for `mcp_servers` also `enabled`.
|
|
420
|
+
- `check_affected` is `measured` only when the answer says `assessed` true, and `not_assessed`
|
|
421
|
+
when it says false or does not say. Its `measured_at` and `freshness` are `null`: a match answer
|
|
422
|
+
carries no observation time. Its `coverage` gives `assessed` and `not_assessed_reason` first,
|
|
423
|
+
then `lookup` (`cpe` or `registry`), `match_layer`, `count`, `capped`, `candidates_capped`,
|
|
424
|
+
`excluded_count`, `undetermined_count`, `not_affected_count` and `degraded`.
|
|
375
425
|
|
|
376
426
|
The CVE feed tools' `freshness` is `null`: the feed's answers carry no time at which its
|
|
377
427
|
pollers last completed a poll for the feed as a whole.
|
|
378
428
|
|
|
379
429
|
A success whose fields do not fit the tool's `outputSchema` (a field of a type the schema does
|
|
380
430
|
not allow) is returned as a failure with `error.kind` `unexpected_shape`, never relayed. A
|
|
381
|
-
field the API adds later is still relayed by
|
|
382
|
-
`exposure_radar`
|
|
431
|
+
field the API adds later is still relayed by every tool that relays the API's JSON as data, and
|
|
432
|
+
`exposure_radar` and `cve_intel`, which relay a selection, leave it out and name what they leave out.
|
|
383
433
|
|
|
384
434
|
## Protocol versions
|
|
385
435
|
|
|
@@ -446,6 +496,7 @@ services does EchelonGraph's radar have on record for it?"*
|
|
|
446
496
|
|---|---|---|
|
|
447
497
|
| `ECHELONGRAPH_API_BASE` | `https://app.echelongraph.io` | Override the API base (self-host / proxy). |
|
|
448
498
|
| `ECHELONGRAPH_API_TIMEOUT_MS` | `15000` | Per-request timeout. A slower answer is reported as a failed lookup, not as empty data. |
|
|
499
|
+
| `ECHELONGRAPH_MCP_UA` | unset | One product token (for example `my-monitor/1.0`) put ahead of this package's own User-Agent, so automated callers such as monitors are told apart from people using the server. A value that is not a single token is ignored. |
|
|
449
500
|
|
|
450
501
|
## Develop
|
|
451
502
|
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The hosted, keyless remote MCP endpoint: https://mcp.echelongraph.io/mcp (#2316).
|
|
3
|
+
//
|
|
4
|
+
// Streamable HTTP on @modelcontextprotocol/server 2.x's createMcpHandler, serving the SAME tool
|
|
5
|
+
// module as the npm package (index.ts's createServer), in both protocol eras: a 2026-07-28
|
|
6
|
+
// client's per-request envelope (server/discover first), and a 2025-era client's initialize,
|
|
7
|
+
// answered statelessly by the SDK's legacy fallback. Nothing is kept between requests: each one
|
|
8
|
+
// gets a fresh server from the factory.
|
|
9
|
+
//
|
|
10
|
+
// POST /mcp the MCP endpoint (GET and DELETE are the 2025 session operations; stateless,
|
|
11
|
+
// the SDK answers them 405)
|
|
12
|
+
// OPTIONS /mcp CORS preflight, for a browser client on an https Origin
|
|
13
|
+
// GET /health liveness. Not /healthz: Cloud Run's front end reserves paths ending in "z"
|
|
14
|
+
// and answers them itself, so a /healthz would never reach this process.
|
|
15
|
+
//
|
|
16
|
+
// No auth: the endpoint serves only public data (MCP makes authorization optional), and the
|
|
17
|
+
// server's instructions say what it is. The edge rules — Origin policy, per-client limit, body
|
|
18
|
+
// cap, what is logged — are in httpPolicy.ts, each with its reasoning.
|
|
19
|
+
//
|
|
20
|
+
// The API sees every remote user as the client it is, not as this service: each call carries the
|
|
21
|
+
// client's address and a forward token core-backend checks (runtime.ts; core-backend
|
|
22
|
+
// waf/mcpforward.go, #2212). MCP_REQUIRE_FORWARD_TOKEN=1 (set by deploy-all.sh) makes a missing
|
|
23
|
+
// token fatal at start-up, because without it every remote user would share one API budget.
|
|
24
|
+
import http from "node:http";
|
|
25
|
+
import { createMcpHandler } from "@modelcontextprotocol/server";
|
|
26
|
+
import { forwardToken, markHttpEntrypoint, withClient } from "./runtime.js";
|
|
27
|
+
import { BODY_TOO_LARGE_CODE, DEFAULT_RATE_LIMIT_PER_MIN, FixedWindowLimiter, ORIGIN_REFUSED_CODE, RATE_LIMITED_CODE, bodyFacts, originVerdict, resolveClient, revisionLabel, rpcError, uaFamily, } from "./httpPolicy.js";
|
|
28
|
+
// Must run before index.ts is evaluated: it is how index.ts knows not to start stdio.
|
|
29
|
+
markHttpEntrypoint();
|
|
30
|
+
const { createServer, NAME, VERSION, SHOWN_BASE, TIMEOUT_MS } = await import("./index.js");
|
|
31
|
+
const positiveInt = (v, fallback) => {
|
|
32
|
+
const n = Number(v);
|
|
33
|
+
return Number.isInteger(n) && n > 0 ? n : fallback;
|
|
34
|
+
};
|
|
35
|
+
const PORT = (() => {
|
|
36
|
+
const n = Number(process.env.PORT);
|
|
37
|
+
return Number.isInteger(n) && n >= 0 && n < 65536 && process.env.PORT !== "" && process.env.PORT !== undefined ? n : 8080;
|
|
38
|
+
})();
|
|
39
|
+
const RATE_LIMIT_PER_MIN = positiveInt(process.env.MCP_RATE_LIMIT_PER_MIN, DEFAULT_RATE_LIMIT_PER_MIN);
|
|
40
|
+
// Tool arguments are a CVE ID or a short search; 64 KiB is far above any real request and far
|
|
41
|
+
// below the SDK's 4 MiB default.
|
|
42
|
+
const MAX_BODY_BYTES = positiveInt(process.env.MCP_MAX_BODY_BYTES, 64 * 1024);
|
|
43
|
+
const MCP_PATH = "/mcp";
|
|
44
|
+
const HEALTH_PATH = "/health";
|
|
45
|
+
// One JSON object per line on stdout: Cloud Logging reads `severity` and `message`.
|
|
46
|
+
function log(severity, message, fields = {}) {
|
|
47
|
+
process.stdout.write(`${JSON.stringify({ severity, message, ...fields })}\n`);
|
|
48
|
+
}
|
|
49
|
+
if (process.env.MCP_REQUIRE_FORWARD_TOKEN === "1" && !forwardToken()) {
|
|
50
|
+
log("ERROR", "mcp_remote_refusing_to_start", {
|
|
51
|
+
reason: "MCP_REQUIRE_FORWARD_TOKEN=1 but ECHELONGRAPH_FORWARD_TOKEN is empty: the API would count every remote user as this one service (#2212)",
|
|
52
|
+
});
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
const handler = createMcpHandler(createServer, {
|
|
56
|
+
legacy: "stateless",
|
|
57
|
+
// responseMode stays the SDK default, "auto": a modern request is answered with one JSON body
|
|
58
|
+
// unless a handler emits something before its result (none of these tools does). The legacy
|
|
59
|
+
// leg answers over SSE, as a 2025-era streamable HTTP server does.
|
|
60
|
+
maxRequestBodySize: MAX_BODY_BYTES,
|
|
61
|
+
// Names only: an SDK error message can quote what the client sent.
|
|
62
|
+
onerror: (e) => log("WARNING", "mcp_handler_error", { error_name: e.name }),
|
|
63
|
+
});
|
|
64
|
+
const limiter = new FixedWindowLimiter(RATE_LIMIT_PER_MIN);
|
|
65
|
+
// CORS for a browser client on an https Origin (the Origin policy has already admitted it).
|
|
66
|
+
// A wildcard is safe here because nothing is credentialed: no cookies, no auth.
|
|
67
|
+
const CORS_HEADERS = {
|
|
68
|
+
"Access-Control-Allow-Origin": "*",
|
|
69
|
+
"Access-Control-Expose-Headers": "Mcp-Session-Id, MCP-Protocol-Version, Retry-After",
|
|
70
|
+
};
|
|
71
|
+
const PREFLIGHT_HEADERS = {
|
|
72
|
+
...CORS_HEADERS,
|
|
73
|
+
"Access-Control-Allow-Methods": "POST, GET, DELETE, OPTIONS",
|
|
74
|
+
"Access-Control-Allow-Headers": "Content-Type, Accept, MCP-Protocol-Version, Mcp-Method, Mcp-Name, Mcp-Session-Id, Last-Event-ID",
|
|
75
|
+
"Access-Control-Max-Age": "600",
|
|
76
|
+
};
|
|
77
|
+
const HOP_BY_HOP = new Set(["host", "connection", "content-length", "transfer-encoding", "keep-alive", "upgrade", "expect"]);
|
|
78
|
+
function send(res, status, body, headers = {}) {
|
|
79
|
+
res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store", ...headers });
|
|
80
|
+
res.end(body);
|
|
81
|
+
}
|
|
82
|
+
// Reads the body up to MAX_BODY_BYTES. Over the cap: "too_large", and the rest is discarded
|
|
83
|
+
// unread (the response closes the connection).
|
|
84
|
+
function readBody(req) {
|
|
85
|
+
const declared = Number(req.headers["content-length"]);
|
|
86
|
+
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
|
|
87
|
+
req.resume();
|
|
88
|
+
return Promise.resolve("too_large");
|
|
89
|
+
}
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
const chunks = [];
|
|
92
|
+
let size = 0;
|
|
93
|
+
let done = false;
|
|
94
|
+
req.on("data", (chunk) => {
|
|
95
|
+
if (done)
|
|
96
|
+
return;
|
|
97
|
+
size += chunk.length;
|
|
98
|
+
if (size > MAX_BODY_BYTES) {
|
|
99
|
+
done = true;
|
|
100
|
+
resolve("too_large");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
chunks.push(chunk);
|
|
104
|
+
});
|
|
105
|
+
req.on("end", () => {
|
|
106
|
+
if (!done)
|
|
107
|
+
resolve(Buffer.concat(chunks));
|
|
108
|
+
});
|
|
109
|
+
req.on("error", (e) => {
|
|
110
|
+
if (!done)
|
|
111
|
+
reject(e);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
async function serveMcp(req, res, pathname) {
|
|
116
|
+
const method = req.method ?? "GET";
|
|
117
|
+
const origin = req.headers.origin;
|
|
118
|
+
const verdict = originVerdict(Array.isArray(origin) ? origin.join(", ") : origin);
|
|
119
|
+
if (!verdict.ok) {
|
|
120
|
+
send(res, 403, rpcError(ORIGIN_REFUSED_CODE, `Origin refused: ${verdict.reason}. This endpoint serves requests with no Origin or an https Origin.`));
|
|
121
|
+
return { status: 403, refused: "origin" };
|
|
122
|
+
}
|
|
123
|
+
const cors = origin === undefined ? {} : CORS_HEADERS;
|
|
124
|
+
if (method === "OPTIONS") {
|
|
125
|
+
res.writeHead(204, PREFLIGHT_HEADERS);
|
|
126
|
+
res.end();
|
|
127
|
+
return { status: 204 };
|
|
128
|
+
}
|
|
129
|
+
const xff = req.headers["x-forwarded-for"];
|
|
130
|
+
const client = resolveClient(Array.isArray(xff) ? xff.join(",") : xff, req.socket.remoteAddress);
|
|
131
|
+
const hit = limiter.hit(client.key);
|
|
132
|
+
if (!hit.allowed) {
|
|
133
|
+
send(res, 429, rpcError(RATE_LIMITED_CODE, `Rate limit exceeded: at most ${RATE_LIMIT_PER_MIN} requests per minute per client address. Retry after ${hit.retryAfterSec} s.`, { limit: RATE_LIMIT_PER_MIN, window_seconds: 60, retry_after_seconds: hit.retryAfterSec }), { ...cors, "Retry-After": String(hit.retryAfterSec) });
|
|
134
|
+
log("WARNING", "mcp_rate_limited", { client: client.key, limit: RATE_LIMIT_PER_MIN });
|
|
135
|
+
return { status: 429, throttled: true };
|
|
136
|
+
}
|
|
137
|
+
let body;
|
|
138
|
+
if (method === "POST") {
|
|
139
|
+
const read = await readBody(req);
|
|
140
|
+
if (read === "too_large") {
|
|
141
|
+
send(res, 413, rpcError(BODY_TOO_LARGE_CODE, `Request body too large: at most ${MAX_BODY_BYTES} bytes.`), { ...cors, Connection: "close" });
|
|
142
|
+
return { status: 413, refused: "body_size" };
|
|
143
|
+
}
|
|
144
|
+
body = read;
|
|
145
|
+
}
|
|
146
|
+
const headers = new Headers();
|
|
147
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
148
|
+
if (v === undefined || HOP_BY_HOP.has(k))
|
|
149
|
+
continue;
|
|
150
|
+
for (const one of Array.isArray(v) ? v : [v])
|
|
151
|
+
headers.append(k, one);
|
|
152
|
+
}
|
|
153
|
+
const abort = new AbortController();
|
|
154
|
+
res.on("close", () => abort.abort());
|
|
155
|
+
const request = new Request(`http://localhost${pathname}`, {
|
|
156
|
+
method,
|
|
157
|
+
headers,
|
|
158
|
+
body: body && body.length > 0 ? new Uint8Array(body) : undefined,
|
|
159
|
+
signal: abort.signal,
|
|
160
|
+
});
|
|
161
|
+
const response = await withClient(client.forwardAddress, () => handler.fetch(request));
|
|
162
|
+
const out = { ...cors };
|
|
163
|
+
response.headers.forEach((v, k) => {
|
|
164
|
+
out[k] = v;
|
|
165
|
+
});
|
|
166
|
+
res.writeHead(response.status, out);
|
|
167
|
+
if (response.body) {
|
|
168
|
+
for await (const chunk of response.body)
|
|
169
|
+
res.write(chunk);
|
|
170
|
+
}
|
|
171
|
+
res.end();
|
|
172
|
+
return { status: response.status, body };
|
|
173
|
+
}
|
|
174
|
+
const server = http.createServer((req, res) => {
|
|
175
|
+
const start = process.hrtime.bigint();
|
|
176
|
+
// The path only: a query string is never read, kept or logged.
|
|
177
|
+
let pathname = "/";
|
|
178
|
+
try {
|
|
179
|
+
pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
pathname = "(unparseable)";
|
|
183
|
+
}
|
|
184
|
+
const method = req.method ?? "GET";
|
|
185
|
+
const finish = (o) => {
|
|
186
|
+
const facts = o.body ? bodyFacts(o.body) : { rpc_method: "(none)" };
|
|
187
|
+
log("INFO", "mcp_request", {
|
|
188
|
+
method,
|
|
189
|
+
path: pathname === MCP_PATH || pathname === HEALTH_PATH ? pathname : "(other)",
|
|
190
|
+
status: o.status,
|
|
191
|
+
duration_ms: Number((process.hrtime.bigint() - start) / 1000000n),
|
|
192
|
+
...facts,
|
|
193
|
+
protocol_version: revisionLabel(req.headers["mcp-protocol-version"]),
|
|
194
|
+
origin_present: req.headers.origin !== undefined,
|
|
195
|
+
body_bytes: o.body?.length ?? 0,
|
|
196
|
+
throttled: o.throttled === true,
|
|
197
|
+
refused: o.refused ?? "",
|
|
198
|
+
ua_family: uaFamily(req.headers["user-agent"]),
|
|
199
|
+
});
|
|
200
|
+
};
|
|
201
|
+
if (pathname === HEALTH_PATH) {
|
|
202
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
203
|
+
send(res, 405, JSON.stringify({ error: "method not allowed" }), { Allow: "GET, HEAD" });
|
|
204
|
+
return finish({ status: 405 });
|
|
205
|
+
}
|
|
206
|
+
send(res, 200, JSON.stringify({ status: "ok", name: NAME, version: VERSION }));
|
|
207
|
+
return finish({ status: 200 });
|
|
208
|
+
}
|
|
209
|
+
if (pathname !== MCP_PATH) {
|
|
210
|
+
send(res, 404, JSON.stringify({ error: "not found", mcp_endpoint: MCP_PATH }));
|
|
211
|
+
return finish({ status: 404 });
|
|
212
|
+
}
|
|
213
|
+
serveMcp(req, res, pathname).then(finish, (e) => {
|
|
214
|
+
log("ERROR", "mcp_request_failed", { error_name: e instanceof Error ? e.name : typeof e });
|
|
215
|
+
if (!res.headersSent)
|
|
216
|
+
send(res, 500, rpcError(-32603, "Internal error"));
|
|
217
|
+
else
|
|
218
|
+
res.end();
|
|
219
|
+
finish({ status: 500 });
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
server.listen(PORT, () => {
|
|
223
|
+
const addr = server.address();
|
|
224
|
+
log("INFO", "mcp_remote_listening", {
|
|
225
|
+
port: typeof addr === "object" && addr ? addr.port : PORT,
|
|
226
|
+
name: NAME,
|
|
227
|
+
version: VERSION,
|
|
228
|
+
api_base: SHOWN_BASE,
|
|
229
|
+
api_timeout_ms: TIMEOUT_MS,
|
|
230
|
+
forward_token_configured: forwardToken() !== "",
|
|
231
|
+
rate_limit_per_min: RATE_LIMIT_PER_MIN,
|
|
232
|
+
max_body_bytes: MAX_BODY_BYTES,
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
const shutdown = () => {
|
|
236
|
+
server.close();
|
|
237
|
+
void handler.close();
|
|
238
|
+
};
|
|
239
|
+
process.on("SIGTERM", shutdown);
|
|
240
|
+
process.on("SIGINT", shutdown);
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// The hosted endpoint's edge rules (#2316), kept apart from the server wiring so each can be
|
|
2
|
+
// tested on its own: who the client is, which Origins are served, the per-client limit, and the
|
|
3
|
+
// JSON-RPC error bodies the edge answers with.
|
|
4
|
+
import { isIP } from "node:net";
|
|
5
|
+
// ── JSON-RPC errors from the edge ──
|
|
6
|
+
//
|
|
7
|
+
// Every refusal at the edge is a JSON-RPC error object, never an HTML page and never a bare
|
|
8
|
+
// status: an MCP client parses the body, and a model shown an HTML error page has nothing to say
|
|
9
|
+
// to its user. id is null because the edge answers before it has read (or trusted) the body.
|
|
10
|
+
export const ORIGIN_REFUSED_CODE = -32000; // the SDK's own code for an Origin refusal (originValidationResponse)
|
|
11
|
+
export const RATE_LIMITED_CODE = -32029; // implementation-defined server error range (-32000..-32099); "429"
|
|
12
|
+
export const BODY_TOO_LARGE_CODE = -32600; // Invalid Request
|
|
13
|
+
export function rpcError(code, message, data) {
|
|
14
|
+
return JSON.stringify({ jsonrpc: "2.0", id: null, error: { code, message, ...(data ? { data } : {}) } });
|
|
15
|
+
}
|
|
16
|
+
export function originVerdict(origin) {
|
|
17
|
+
if (origin === undefined)
|
|
18
|
+
return { ok: true };
|
|
19
|
+
const o = origin.trim();
|
|
20
|
+
if (o === "")
|
|
21
|
+
return { ok: false, reason: "an empty Origin header" };
|
|
22
|
+
if (o.toLowerCase() === "null")
|
|
23
|
+
return { ok: false, reason: 'the opaque Origin "null"' };
|
|
24
|
+
if (/[\s,]/.test(o))
|
|
25
|
+
return { ok: false, reason: "a malformed Origin header (not a single origin)" };
|
|
26
|
+
let url;
|
|
27
|
+
try {
|
|
28
|
+
url = new URL(o);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return { ok: false, reason: "a malformed Origin header" };
|
|
32
|
+
}
|
|
33
|
+
if (url.protocol !== "https:")
|
|
34
|
+
return { ok: false, reason: "a non-https Origin" };
|
|
35
|
+
// A serialized origin is scheme://host[:port] and nothing else.
|
|
36
|
+
if (url.username || url.password || `${url.protocol}//${url.host}` !== o.toLowerCase()) {
|
|
37
|
+
return { ok: false, reason: "a malformed Origin header (not a bare origin)" };
|
|
38
|
+
}
|
|
39
|
+
return { ok: true };
|
|
40
|
+
}
|
|
41
|
+
// The eight 16-bit words of an IPv6 literal that net.isIP has already accepted.
|
|
42
|
+
function expandIPv6(s) {
|
|
43
|
+
// A dotted IPv4 tail ("::ffff:203.0.113.7") is rewritten as its two words first.
|
|
44
|
+
const lastColon = s.lastIndexOf(":");
|
|
45
|
+
const v4 = s.slice(lastColon + 1);
|
|
46
|
+
if (v4.includes(".")) {
|
|
47
|
+
const o = v4.split(".").map(Number);
|
|
48
|
+
if (o.length !== 4 || o.some((n) => !Number.isInteger(n) || n < 0 || n > 255))
|
|
49
|
+
return undefined;
|
|
50
|
+
s = `${s.slice(0, lastColon + 1)}${((o[0] << 8) | o[1]).toString(16)}:${((o[2] << 8) | o[3]).toString(16)}`;
|
|
51
|
+
}
|
|
52
|
+
const parts = s.split("::");
|
|
53
|
+
if (parts.length > 2)
|
|
54
|
+
return undefined;
|
|
55
|
+
const head = parts[0] ? parts[0].split(":") : [];
|
|
56
|
+
const rest = parts.length === 2 && parts[1] ? parts[1].split(":") : [];
|
|
57
|
+
const fill = parts.length === 2 ? 8 - head.length - rest.length : 0;
|
|
58
|
+
if (fill < 0 || (parts.length === 1 && head.length !== 8))
|
|
59
|
+
return undefined;
|
|
60
|
+
const words = [...head, ...Array(fill).fill("0"), ...rest].map((h) => parseInt(h, 16));
|
|
61
|
+
if (words.length !== 8 || words.some((w) => !Number.isInteger(w) || w < 0 || w > 0xffff))
|
|
62
|
+
return undefined;
|
|
63
|
+
return words;
|
|
64
|
+
}
|
|
65
|
+
/** One forwarded hop or socket address, parsed with a forwarded hop's tolerances; undefined if it is not an address. */
|
|
66
|
+
export function parseAddress(raw) {
|
|
67
|
+
if (!raw)
|
|
68
|
+
return undefined;
|
|
69
|
+
let s = raw.trim();
|
|
70
|
+
if (!s)
|
|
71
|
+
return undefined;
|
|
72
|
+
// "[2001:db8::1]:443" / "[2001:db8::1]" → the literal; "203.0.113.7:51234" → the address.
|
|
73
|
+
const bracketed = /^\[([^\]]+)\](?::\d+)?$/.exec(s);
|
|
74
|
+
if (bracketed)
|
|
75
|
+
s = bracketed[1];
|
|
76
|
+
else if (/^[\d.]+:\d+$/.test(s))
|
|
77
|
+
s = s.slice(0, s.lastIndexOf(":"));
|
|
78
|
+
const zone = s.indexOf("%");
|
|
79
|
+
if (zone >= 0)
|
|
80
|
+
s = s.slice(0, zone);
|
|
81
|
+
const fam = isIP(s);
|
|
82
|
+
if (fam === 4)
|
|
83
|
+
return { family: 4, octets: s.split(".").map(Number), text: s };
|
|
84
|
+
if (fam !== 6)
|
|
85
|
+
return undefined;
|
|
86
|
+
const h = expandIPv6(s.toLowerCase());
|
|
87
|
+
if (!h)
|
|
88
|
+
return undefined;
|
|
89
|
+
// IPv4-mapped (::ffff:a.b.c.d) is the IPv4 address.
|
|
90
|
+
if (h.slice(0, 5).every((w) => w === 0) && h[5] === 0xffff) {
|
|
91
|
+
const octets = [h[6] >> 8, h[6] & 0xff, h[7] >> 8, h[7] & 0xff];
|
|
92
|
+
return { family: 4, octets, text: octets.join(".") };
|
|
93
|
+
}
|
|
94
|
+
return { family: 6, hextets: h, text: s.toLowerCase() };
|
|
95
|
+
}
|
|
96
|
+
/** Whether a parsed address is one a client on the public internet could have arrived from (core-backend's publicUnicast). */
|
|
97
|
+
export function isPublic(a) {
|
|
98
|
+
if (a.family === 4) {
|
|
99
|
+
const [o0, o1] = a.octets;
|
|
100
|
+
if (o0 === 0 || o0 === 10 || o0 === 127 || o0 >= 224)
|
|
101
|
+
return false; // this-network, private, loopback, multicast/reserved/broadcast
|
|
102
|
+
if (o0 === 169 && o1 === 254)
|
|
103
|
+
return false; // link-local (the platform's front end)
|
|
104
|
+
if (o0 === 172 && o1 >= 16 && o1 <= 31)
|
|
105
|
+
return false;
|
|
106
|
+
if (o0 === 192 && o1 === 168)
|
|
107
|
+
return false;
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
const h = a.hextets;
|
|
111
|
+
if (h.every((w) => w === 0))
|
|
112
|
+
return false; // ::
|
|
113
|
+
if (h.slice(0, 7).every((w) => w === 0) && h[7] === 1)
|
|
114
|
+
return false; // ::1
|
|
115
|
+
if ((h[0] & 0xffc0) === 0xfe80)
|
|
116
|
+
return false; // fe80::/10 link-local
|
|
117
|
+
if ((h[0] & 0xff00) === 0xff00)
|
|
118
|
+
return false; // ff00::/8 multicast
|
|
119
|
+
if ((h[0] & 0xfe00) === 0xfc00)
|
|
120
|
+
return false; // fc00::/7 unique local
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
function keyOf(a) {
|
|
124
|
+
if (a.family === 4)
|
|
125
|
+
return a.octets.join(".");
|
|
126
|
+
return `${a.hextets.slice(0, 4).map((w) => w.toString(16)).join(":")}::/64`;
|
|
127
|
+
}
|
|
128
|
+
/** The client of one request: see the section comment. */
|
|
129
|
+
export function resolveClient(xff, socketAddress) {
|
|
130
|
+
if (xff) {
|
|
131
|
+
const hops = xff.split(",");
|
|
132
|
+
for (let i = hops.length - 1; i >= 0; i--) {
|
|
133
|
+
const a = parseAddress(hops[i]);
|
|
134
|
+
if (a && isPublic(a))
|
|
135
|
+
return { forwardAddress: a.text, key: keyOf(a) };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const peer = parseAddress(socketAddress);
|
|
139
|
+
if (!peer)
|
|
140
|
+
return { key: socketAddress || "(unknown)" };
|
|
141
|
+
return { forwardAddress: isPublic(peer) ? peer.text : undefined, key: isPublic(peer) ? keyOf(peer) : peer.text };
|
|
142
|
+
}
|
|
143
|
+
// ── Per-client limit ──
|
|
144
|
+
//
|
|
145
|
+
// A fixed one-minute window per client key, in this instance's memory. The default is derived
|
|
146
|
+
// from the public API's own per-client ceiling, not invented: core-backend's WAF admits 600
|
|
147
|
+
// requests a minute per client address on the API's base paths (waf.DefaultTierConfigs
|
|
148
|
+
// ["default"], 10 a second), and the costliest tool here, exposure_radar, makes up to five API
|
|
149
|
+
// calls per MCP request. 600 / 5 = 120 MCP requests a minute keeps one client's heaviest use
|
|
150
|
+
// inside the ceiling the API applies to it (core-backend keys this service's calls on the
|
|
151
|
+
// forwarded client, #2212), so a client meets THIS limit — a JSON-RPC error it can read — before
|
|
152
|
+
// the API's. It is not a tier: it grants nothing the API does not already grant one address.
|
|
153
|
+
//
|
|
154
|
+
// Counted per instance: with Cloud Run's max-instances at N, one client spread over every
|
|
155
|
+
// instance could make N times this many requests a minute here; the API's own ceiling, which
|
|
156
|
+
// is counted fleet-wide, still binds behind it.
|
|
157
|
+
export const DEFAULT_RATE_LIMIT_PER_MIN = 120;
|
|
158
|
+
export class FixedWindowLimiter {
|
|
159
|
+
max;
|
|
160
|
+
windowMs;
|
|
161
|
+
now;
|
|
162
|
+
maxKeys;
|
|
163
|
+
windows = new Map();
|
|
164
|
+
constructor(max, windowMs = 60_000, now = Date.now, maxKeys = 50_000) {
|
|
165
|
+
this.max = max;
|
|
166
|
+
this.windowMs = windowMs;
|
|
167
|
+
this.now = now;
|
|
168
|
+
this.maxKeys = maxKeys;
|
|
169
|
+
}
|
|
170
|
+
hit(key) {
|
|
171
|
+
const t = this.now();
|
|
172
|
+
let w = this.windows.get(key);
|
|
173
|
+
if (!w || t - w.start >= this.windowMs) {
|
|
174
|
+
if (!w && this.windows.size >= this.maxKeys)
|
|
175
|
+
this.sweep(t);
|
|
176
|
+
w = { start: t, count: 0 };
|
|
177
|
+
this.windows.set(key, w);
|
|
178
|
+
}
|
|
179
|
+
if (w.count >= this.max) {
|
|
180
|
+
return { allowed: false, retryAfterSec: Math.max(1, Math.ceil((w.start + this.windowMs - t) / 1000)) };
|
|
181
|
+
}
|
|
182
|
+
w.count++;
|
|
183
|
+
return { allowed: true, remaining: this.max - w.count };
|
|
184
|
+
}
|
|
185
|
+
// Drop expired windows; if every key is live (a flood of distinct addresses), drop the oldest
|
|
186
|
+
// half rather than grow without bound. Forgetting a key early can only admit, never refuse.
|
|
187
|
+
sweep(t) {
|
|
188
|
+
for (const [k, w] of this.windows)
|
|
189
|
+
if (t - w.start >= this.windowMs)
|
|
190
|
+
this.windows.delete(k);
|
|
191
|
+
if (this.windows.size < this.maxKeys)
|
|
192
|
+
return;
|
|
193
|
+
let drop = Math.floor(this.windows.size / 2);
|
|
194
|
+
for (const k of this.windows.keys()) {
|
|
195
|
+
if (drop-- <= 0)
|
|
196
|
+
break;
|
|
197
|
+
this.windows.delete(k);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// ── Bounded access-log fields ──
|
|
202
|
+
//
|
|
203
|
+
// The access line carries no body, no query string, no argument and no client address: only
|
|
204
|
+
// values from small closed sets or shapes, so nothing a client types reaches the log (#1983).
|
|
205
|
+
const RPC_METHOD = /^[a-z][a-z/_]{0,39}$/i;
|
|
206
|
+
const TOOL_NAME = /^[a-z][a-z0-9_]{0,39}$/;
|
|
207
|
+
const REVISION = /^\d{4}-\d{2}-\d{2}$/;
|
|
208
|
+
const UA_FAMILY = /^[A-Za-z0-9._-]{1,40}$/;
|
|
209
|
+
/** The JSON-RPC method and, for tools/call, the tool name, shape-checked; never anything else from the body. */
|
|
210
|
+
export function bodyFacts(body) {
|
|
211
|
+
if (!body || body.length === 0)
|
|
212
|
+
return { rpc_method: "(none)" };
|
|
213
|
+
let parsed;
|
|
214
|
+
try {
|
|
215
|
+
parsed = JSON.parse(body.toString("utf8"));
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return { rpc_method: "(unparseable)" };
|
|
219
|
+
}
|
|
220
|
+
if (Array.isArray(parsed))
|
|
221
|
+
return { rpc_method: "(batch)" };
|
|
222
|
+
if (parsed === null || typeof parsed !== "object")
|
|
223
|
+
return { rpc_method: "(other)" };
|
|
224
|
+
const m = parsed.method;
|
|
225
|
+
if (typeof m !== "string")
|
|
226
|
+
return { rpc_method: "(response)" };
|
|
227
|
+
if (!RPC_METHOD.test(m))
|
|
228
|
+
return { rpc_method: "(other)" };
|
|
229
|
+
if (m !== "tools/call")
|
|
230
|
+
return { rpc_method: m };
|
|
231
|
+
const name = parsed.params?.name;
|
|
232
|
+
return { rpc_method: m, tool: typeof name === "string" && TOOL_NAME.test(name) ? name : "(other)" };
|
|
233
|
+
}
|
|
234
|
+
export function revisionLabel(v) {
|
|
235
|
+
if (v === undefined)
|
|
236
|
+
return "(none)";
|
|
237
|
+
return REVISION.test(v.trim()) ? v.trim() : "(other)";
|
|
238
|
+
}
|
|
239
|
+
export function uaFamily(ua) {
|
|
240
|
+
if (!ua)
|
|
241
|
+
return "(none)";
|
|
242
|
+
const fam = ua.split(/[/\s]/, 1)[0];
|
|
243
|
+
return UA_FAMILY.test(fam) ? fam : "(other)";
|
|
244
|
+
}
|