Latest dated report built with this methodology: Known vulnerabilities in MCP servers, 2026 Q3
What counts as an MCP server
Discovery spans six surfaces. From npm we run the
registry's own keyword search for mcp — so a package is found because
it declares that keyword, not because of how it is named — and read the
top slice of those results each cycle, ranked by the registry's relevance. PyPI is searched the same way. A package that omits the keyword, or that ranks below
the window against the tens of thousands that carry it, is not picked up from
these two surfaces; it may still arrive via GitHub, the official registry, or
a directory listing below.
Smithery (smithery.ai) and mcp.so contribute their published
directory listings. From GitHub we follow repos tagged with the mcp or model-context-protocol topics, surfacing the most recently active
first and skipping repos quiet for over 18 months. From the
official MCP registry we
ingest every listed server with a public repo or package. Slice the resulting
set by inferred capability and permission in the servers browser.
Beyond discovery, dedicated workers deepen and watch the set: they track MCP client releases, infer capabilities from each README, statically analyze the published source artifact, and ingest CVE / advisory feeds. Every worker and its cadence is in the poll-cadence table below; live health for each is on the feeds page.
One project, one identity
The same project routinely appears on several registries with mildly different names. A resolver collapses those into one canonical record, in priority order:
- explicit override — a maintainer-curated map keyed by source
+ slug, loaded from
overrides.jsonat boot. - cross-source
repositoryurl match — two records from different sources pointing at the same git remote are merged into one canonical identity. Same-source repo sharers are treated as monorepo siblings and are not merged. - exact source-record fold — a normalized
(source, slug)pair that thesource_recordsprovenance ledger has seen before resolves to the existing identity, making re-resolution deterministic.
Failing all three, a record keeps its source-qualified id (e.g. npm:foo). This is why the tracked-server count is a count of identities,
not of registry rows — a server on npm, GitHub, and Smithery is one server
here.
Levenshtein / fuzzy merging was explicitly removed: it chained distinct
same-author tools together (e.g. two unrelated tools by the same publisher
would collapse into one). The Levenshtein threshold now only feeds the
typosquat radar (naming flags), which flags — never merges. Retired ids forward via identity_alias
so old URLs redirect with 301 rather than 404.
Verification
A server is verified when at least one independent strong trust signal vouches for it. Each signal is recorded separately, so the badge always carries a reason:
-
official-registry— listed and active in the official MCP registry. -
npm-provenance/pypi-attestation— a cryptographic build-provenance attestation asserting the published artifact was built from its source repo; we record that the registry publishes one. smithery— Smithery's ownverifiedflag.
These differ in how far we can stand behind them, and we label each
accordingly.
npm-provenance / pypi-attestation is attested
— a build-provenance attestation the registry publishes and we read first-hand;
we record that it exists, but don't re-verify the signature ourselves. official-registry and
smithery are reported: a third party vouches
and we relay it, but the observatory hasn't independently confirmed it.
"Verified" is never our own endorsement — it always names whose claim it is.
A weak signal (a publisher owning the declared repo) is recorded but never sets the badge alone — otherwise nearly every server would qualify and the word would lose meaning. Losing a strong signal (an official entry going deprecated) drops the badge, which is itself a tracked change.
Reading the code
Two layers populate each server's capability surface. Registry introspection reads the declared tool / resource / prompt counts and transport from Smithery and the official registry. Static code analysis goes deeper: for npm, PyPI, and GitHub servers a separate, isolated scanner process downloads the published artifact (npm tarball, PyPI sdist, or GitHub source tarball), unpacks it into a throwaway directory of its own, and reads the tree statically — no code is ever run (not the server, its tests, or its install hooks). Running the read + analysis in its own container is deliberate: a heavy scan can never stall ingestion or the live site. Every finding quotes the exact file and line:
- permissions — evidence-backed filesystem, network, shell, secrets, database, and untrusted-content access (the injection-entry axis: web scrapes, issue/PR/comment bodies, fetched documents).
- tools — SDK tool / resource registrations and decorators.
- dependencies & install hooks — parsed manifests, plus
pre/post-installscripts (read, never executed). - transports — stdio, SSE, streamable-HTTP, HTTP binds.
- prompt surface — shipped skill / agent-instruction files (SKILL.md, .cursorrules, prompts/*) scanned for hidden-channel injection: invisible unicode, Unicode-Tag smuggling, and comment-buried model directives.
- danger signals — committed secrets, dynamic-exec sinks (
eval,pickle.loads), and suspicious call-home endpoints.
The analyzer also counts code files (any language) in the unpacked tree and
records how many dependency-manifest entries it could parse. A repo with
zero code files cannot implement a server whatever its README claims, so
it's classified unconfirmed rather than trusted at face value; a
repo whose manifest we couldn't parse stays pending — unknown, not
absent — so it isn't hidden or wrongly branded either way. Both counts are shown
alongside the evidence itself, never collapsed into a verdict.
A second static pass runs Semgrep's taint
/ dataflow rules (vendored and pinned in-repo) over the same unpacked
source, proving when a credential or environment value actually flows to a network or shell sink — the kind of source-to-sink path the line-level
scan can only approximate. It runs in the same isolated scanner process as the line-level pass — so both stay off the main worker — is still static (no code is executed), and its findings are tagged separately (semgrep) and treated
as inferred review prompts, not verdicts.
Coverage: 81% of 57,957 analyzable servers analyzed by the current analyzer (47,159 current · 9,833 re-analysis due — 0 awaiting an analyzer re-scan + 9,790 source changed · 1 not yet · 964 not analyzable). An analyzer bump marks the back-catalogue due and drops this figure until the re-scan catches up, so it tracks the running analyzer's live reach. A further 4,650 listed repos have since vanished upstream (deleted / renamed / made private) and are excluded from every count.
The analyzer is versioned: each detector change bumps a version that marks the back-catalogue stale and re-scans it. The scanner changelog lists what every version detects and when it shipped. The findings themselves — hidden prompt content, committed secrets, dynamic-exec sinks, suspicious endpoints — are browsable and filterable on the code analysis page.
Which analyzer produced a grade
Because the analyzer is versioned and the back-catalogue re-scans in the background, at any moment most stored analyses were produced by an older analyzer than the one running now. So every grade carries a second tag next to its epistemic one: analyzer v32 (current) or analyzer v28 (4 behind). "4 behind" means four detector releases have shipped since this server's source was last read, and none of them has been applied to it — its grade reflects the checks that existed at v28. That is why two servers can differ in grade because of how hard we looked rather than because of the software, and why the side-by-side comparison view says so out loud when its columns don't share an analyzer version.
This is a disclosure, not a new evidence bar. The floor for counting an analysis as evidence at all is unchanged at analyzer v27 — the version that first recorded multi-ecosystem dependencies and code-file counts, below which the absence of a signal proves nothing. Runs at v27 and above all still count; the tag tells you where in that range one sits.
"Current" is not a completeness claim. It says the analysis is up to date with our detectors, not that our detectors saw everything — they miss real behaviour, and we know they do. A current analysis is still an inferred review signal, never a verdict. Freshness sits alongside the attested / reported / inferred scale rather than inside it, because they answer different questions: what backs a claim, and how recently we looked.
What a clean scan does not cover
Everything above analyses an artifact. For most servers that is the whole question: clean code, current dependencies and no drift means the grade is honest. For a few capability classes it is not — and the gap is structural, not a hole in our coverage that more detectors would close.
A disposable-inbox API is the clearest case. It is a legitimate tool for testing signup flows — that is what it is for — and it is also exactly what you would reach for to register accounts in bulk. The published artifact is identical in both cases. Static analysis returns a clean grade and is right to: the abuse would live entirely in who called the server, how fast, and how many times. The observatory sees published artifacts, never deployments. It has no telemetry, no request logs, and no view of call volume, so that axis is not one it measures poorly — it is one it cannot see at all.
Rather than leave that implicit behind a reassuring letter grade, servers in these classes carry a scope-of-analysis note on their security tab naming what the scan does not reach. Three things it is deliberately not: it is attached to the capability class, never to any incident or report, so every server in a class carries it or none do; it carries no score and never moves a grade, because belonging to a class is not a defect; and it is not an allegation about a server or the people using it. It says one thing only — for this kind of tool, a clean scan answers a narrower question than it appears to.
The security signals we derive
On top of the capability surface, the observatory derives a stack of independent security signals. Each is collected on its own cadence and surfaced on the security hub:
- vulnerabilities — OSV.dev query for every npm / PyPI server, with CVSS severity, fix availability, and EPSS exploit-probability scoring.
- capability drift — we snapshot each server's permission mask, tool count, transport, and verification, and raise an advisory when they change. Silent permission escalations on high-adoption servers are flagged for review as possible rug-pulls — a prompt to look, not an accusation; routine version bumps expand capabilities too.
- naming & impersonation — servers whose names sit a single
edit (Levenshtein distance 1) from a verified or official-scope target are flagged
as possible typosquats. A mixed-script name is also flagged at distance 0 —
one whose look-alike characters (Cyrillic
аfor Latina, say) fold onto the target exactly, so the two render identically while being different strings; a pure homoglyph swap is deliberate by construction. Distance 2 is deliberately not used: on a short name it collides unrelated real words far more often than it catches a squat, and this radar is a review prompt, so precision is worth more than recall. - abandonment — scored from release age and cadence; long-silent or content-less repos surface so dependents can plan around them.
- supply chain — install-hook presence, build-provenance coverage, and registry deprecation flags.
- licence posture — SPDX classification flagging missing, non-commercial, or strong-copyleft terms.
- hijack exposure — the intersection of abandoned, widely-depended-on, and CVE-carrying servers: the highest-leverage takeover targets.
Scoring risk
A background job folds those signals into a single 0–100 risk score and an A–E grade per server. Vulnerabilities (weighted by severity, halved when a fix exists, scaled by EPSS), inferred permission exposure, recent drift, CVEs inherited through the dependency graph, supply-chain flags, abandonment, and tool-safety findings all add weight; mitigators subtract it — being verified, shipping build provenance, and staying actively released. The score is a read cache, recomputed whenever a signal changes, and drives the risk leaderboard.
OWASP MCP Top 10 coverage
How our in-house detectors map to the OWASP MCP Top 10 (2025). We grade ourselves honestly — 8 covered, 1 partial, and 1 out of scope for a static, no-execution observatory. "Covered" means a heuristic detector points at the risk — not that every instance is caught. Detection is inferred throughout; this matrix is a coverage map, not a guarantee.
- MCP01 Token Mismanagement & Secret Exposure covered
Committed credentials / .env, and credential values fed into log sinks.
secrettoken-logdangerous_code - MCP02 Privilege Escalation via Scope Creep covered
Over-broad OAuth scopes, capability gained after first observation, benign-named tools carrying dangerous perms.
oauth-scoperug_pullpurpose_mismatch - MCP03 Tool Poisoning covered
Name + description + full input schema (Full-Schema Poisoning), plus shipped prompt/skill files.
tool_poisoningprompt_injection_fileskill-exfil - MCP04 Software Supply Chain Attacks & Dependency Tampering covered
Dependency manifest, install hooks, OSV CVEs (incl. inherited), abandonment, build-provenance coverage.
depinstall-hookcveabandonmentprovenance - MCP05 Command Injection & Execution covered
Dynamic-exec sinks, unconstrained command/path params, shell capability.
dynamic-execdangerous_codeloose_schemaperm:shell - MCP06 Intent Flow Subversion covered
Puppet attacks (untrusted tool steering toward a verified server's tool) and in-description tool steering.
cross_server_steeringtool_poisoning - MCP07 Insufficient Authentication & Authorization partial
Gap: we infer scope/secret-handling from source but do not model a server's auth/authz configuration directly (much of it is deploy-time, not in the artifact).
oauth-scopetoken-log - MCP08 Lack of Audit and Telemetry out of scope
Gap: whether a server logs/audits its own actions is a runtime property a static, no-execution observatory cannot observe. Tracked for completeness.
- MCP09 Shadow MCP Servers covered
Unverified servers whose tool names or identities collide with a verified/official target (typosquat radar).
tool_shadownaming/impersonation - MCP10 Context Injection & Over-Sharing covered
Hidden-channel injection (zero-width / bidi / tag-smuggling / HTML-comment directives) in tool schemas and shipped prompt files, plus the lethal trifecta (toxic_flow): private-data access + untrusted-content ingestion + a network exfil channel reachable in one session (per-server and cross-server via the dependency graph).
hidden-promptprompt_injection_filetool_poisoningtoxic_flow
Poll cadences
Each source has its own ingest worker on a fixed interval. Discovery workers find and refresh servers; enrichment & security workers deepen and watch them. Live status for every worker — last fetch, next due, errors — is on the feeds page.
discovery
enrichment & security
Related servers
The relatedness graph derives edges from two signals: a shared maintainer (same npm user, PyPI author, or GitHub owner) and shared runtime dependencies. The dependency graph filters to the most active nodes — at least one release in the last 30 days — so dormant projects don't crowd the view. That makes it a deliberate selection bias: the graph reads as "who's active and clustered", not a census of the whole ecosystem — stable, mature, infrequently-released packages are under-represented by design. The same dependency edges power the inherited-CVE walk and the hijack-exposure model above.
What we don't do
- no user-submitted servers — every record comes from a polled upstream.
- no comments, votes, or rankings — the observatory measures, it doesn't curate.
- no runtime execution — code analysis is purely static. We never run a server, its tests, or its install hooks; we only read the published source.
For the tech stack behind all of this, see the colophon.