使用说明
cve-mcp
An MCP server for scanning packages against the OSV.dev vulnerability database.
Tools
cve_scan_packages
Scans a list of pinned packages for known CVEs/advisories. Results are cached locally (SQLite, 24 h TTL) to avoid hammering the OSV API.
Parameters
| Field | Type | Required | Description |
|---|---|---|---|
packages |
array | ✓ | List of { name, version, ecosystem } objects |
min_severity |
string | — | Filter threshold: LOW | MEDIUM | HIGH | CRITICAL (default LOW) |
Ecosystem identifiers (case-sensitive, matching OSV schema): PyPI, crates.io, npm, Go, Maven, NuGet, RubyGems, Packagist, Hex, Pub, CocoaPods, Swift
Returns — a JSON object:
{
"scanned": 3,
"elapsed_ms": 412,
"findings": [
{
"name": "sequoia-openpgp",
"version": "1.22.0",
"ecosystem": "crates.io",
"vuln_id": "RUSTSEC-2025-0136",
"aliases": ["CVE-2025-67897", "GHSA-v6x3-9r38-r27q"],
"severity": "MEDIUM",
"fixed_in": "2.1.0",
"summary": "Underflow in aes_key_unwrap function"
}
]
}
One finding per advisory
An advisory usually carries several identifiers — a RUSTSEC or GHSA ID plus
the CVE it was assigned. Those are collapsed into a single finding rather
than counted once per ID. vuln_id is the most specific identifier
(CVE > RUSTSEC > GHSA > everything else) and aliases holds the rest,
so a caller can still match on any ID it knows. aliases is omitted when
the advisory has no other identifiers.
The same advisory affecting two packages — or two versions of one package — is genuinely two findings, and stays that way.
How severity is derived
OSV records carry severity in two places, and either, both, or neither may be present:
database_specific.severity— aLOW/MODERATE/HIGH/CRITICALlabel. GHSA sets it; RUSTSEC does not.severity[]— CVSS vectors. Both v3.x and v4.0 are scored and banded by the standard thresholds (0.1–3.9LOW,4.0–6.9MEDIUM,7.0–8.9HIGH,9.0–10.0CRITICAL).
The reported severity is the highest of every signal that parses, across every identifier that collapsed into the finding. It is never lower than the publisher's own label.
When nothing parses, severity is UNKNOWN — not LOW. UNKNOWN findings
are always returned regardless of min_severity, because a filter cannot
honestly exclude what it could not grade. Drop them yourself if you want a
strictly-graded view.
Which fix fixed_in names
fixed_in is the fix for the release branch the scanned version is on, not
the first fix the advisory happens to list.
A branch that opens and closes at the same version describes nothing, and is
removed before the range is read. last_affected is the exception: it is
inclusive, so [introduced 4.1.0, last_affected 4.1.0] says that one version
is affected and stays. A range that ends on last_affected reads as affected
above that version too, which is wider than the record. That is deliberate:
it ends in no fix rather than in a fix that does not apply. That is how Debian and Alpine write "never
affected", and RUSTSEC-2024-0399 carries one inside a longer range.
One advisory usually patches several branches at once, and it may cover
several packages, or several releases of one distribution. An OSV range is a
flat list of events: introduced opens a branch and fixed,
last_affected or limit closes it, in whatever order the publisher wrote
them. So a vite 5.4.21 pin is fixed by 6.4.2 even though the record lists
8.0.5 first, a spring-boot-starter-web pin is never handed a
spring-beans fix, and a Debian 11 host is never handed a Debian 12
package.
A version is reported only when all three of these hold:
- every range covering the scanned package can be read,
- at least one of those ranges holds the scanned version, and
- the version reported sits outside all of them.
The third is the one that is easy to get wrong. OSV ranges union — a version is affected if any one range holds it — so the lowest fix in the record is not the answer unless it is itself clear of every range.
fixed_in is omitted whenever those do not hold: the advisory covers other
packages only, the scanned version sits outside every affected branch, the
branch holding it has no published fix (last_affected, or a branch never
closed), a range carries a bound that will not order, or no candidate fix is
clear of every range.
It is never borrowed from an alias either. Collapsing several identifiers
into one finding takes the summary from whichever record has one, but the fix
comes from the advisory the finding is reported under and nowhere else: a
record that closes its branch with last_affected names no fix on purpose,
and filling that in from a sibling would report the package as patched when
its own advisory says nobody has patched it.
Ordering is semantic-version ordering, widened for the OSV 0 sentinel, a
Go-style leading v, and short numeric versions such as 1.0.
An Alpine revision is written after -, as in 1.31.1-r21. Semver reads a
prerelease there and compares the identifiers as text, so r9 would sort
above r21. For the Alpine ecosystem, and only there, the revision is
rewritten to the number it is, which semver compares numerically, so Alpine
keeps its own order. A crate publishing a genuine -r2 prerelease keeps
semver's own rule.
Two kinds of version are refused rather than ordered wrongly:
- anything semver cannot parse, such as Maven's
5.2.20.RELEASE, and - a Debian revision, written after
+as in7.74.0-1.3+deb11u10— semver reads that as build metadata and drops it from the comparison, so two distinct packages compare equal.
The ecosystem is matched whole, release qualifier included. Debian:11 and
Debian:12 are separate version spaces, and a curl version from one does not
exist in the other.
The cost of one ordering for every ecosystem is that Debian and Maven records often report no fix at all, because their versions carry forms semver cannot order. An ecosystem-aware ordering would recover those answers.
Naming a fix that does not apply would report a package as patched when it is not, so silence is the answer wherever the record cannot be read with confidence.
cve_lookup_vuln
Looks up a single advisory by ID. Accepts OSV IDs (RUSTSEC-…), CVE IDs (CVE-…), or GHSA IDs (GHSA-…).
Parameters
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | ✓ | Vulnerability identifier |
Returns — the raw OSV advisory JSON.
Running
# Stdio mode (default, for VS Code / Claude Desktop)
cargo run -- serve
# WebSocket mode
cargo run -- serve --mode websocket --port 9000
# Custom cache DB path
cargo run -- serve --db ~/my-caches/cve.db
# Disable cache (always hits OSV.dev)
cargo run -- serve --no-cache
Cache
The advisory cache lives at ~/.local/share/cve-mcp/cache.db by default (SQLite). Entries expire after 24 hours. Run cve_scan_packages again after expiry to refresh.
Cached advisory records carry the model version they were written with. An upgrade that reads a field the older model did not store leaves those records unreadable, so the first scan after such an upgrade fetches them again rather than answering from a record that no longer says what it used to.
Logging
mcp-core's run installs the process subscriber; this crate calls nothing
to get it. Logs go to stderr, never stdout — the stdio transport frames
JSON-RPC on stdout, and one log line there would corrupt the protocol
stream. RUST_LOG sets the level (default info); see mcp-core's own
README for the full level contract, the request/tool-call spans, and the
standard OTEL_* environment variables.
What this server adds on top of what it inherits:
- Both tool handlers (
cve_scan_packages,cve_lookup_vuln) open their own span, named after the tool, nested undermcp-core'smcp.tools.callspan. - A
debug!line on each outbound OSV.dev call: the package count for aquerybatchrequest, the advisory id for avulnlookup. An advisory id is a tool argument, so it stays at DEBUG and is never attached to a span;RUST_LOG=debugis what it takes to see it. cve.osv_request.duration, a latency histogram for each outbound OSV.dev call, labelled byendpoint(querybatchorvuln).cve.upstream_failures, a counter labelled byendpointand a boundedreason(httporunexpected_response), for a fault reaching OSV.dev. A cache, serialization, or filesystem fault never left the process, so it is not counted here.mcp-corealready records a tool-call counter and a latency histogram by tool and outcome (mcp.tools.call,mcp.tools.call.duration); this server does not duplicate them.
The otel feature
Off by default. A pure passthrough — cve-mcp -> mcp-core -> adelie-telemetry
— so this crate takes no direct dependency on adelie-telemetry or on any
opentelemetry crate. With the feature off, cargo tree resolves no
opentelemetry crate at all.
cargo build --features otel
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 ./target/debug/cve-mcp serve
Testing
just check # default features: fmt, lint, build, test
just check-otel # the same, built with --features otel
tests/telemetry_*.rs is the telemetry acceptance suite: that stdout
carries only JSON-RPC at RUST_LOG=trace, that no package name, version, or
advisory id reaches an INFO line or a span field for any registered tool,
and that a default build resolves no opentelemetry crate. Both content tests
iterate the server's live tool list rather than a hardcoded pair, so a tool
added later without a matching entry in tests/support/mod.rs fails the
test by name instead of being silently uncovered.