State of the MCP ecosystem
An unauthenticated survey of every public remote MCP server we could reach. Every figure on this page is computed from immutable evidence by the query shown beneath it, states what it is a percentage of, and links to the hosts it counted. Figures are recomputed at most every five minutes and cached in between, so what you see was true within the last few minutes rather than this instant.
The Model Context Protocol has an inventory problem
We scanned every public MCP endpoint we could find — 13,008 endpoints, across 12,537 services, on 9,394 distinct hostnames — and asked each one a single question: what will you tell a stranger?
Most of them will tell a stranger everything.62.3% of MCP hosts hand their complete tool inventory to an unauthenticated caller. Not the data behind the tools — the tools themselves: names, descriptions, and full input schemas, returned to anyone who completes a protocol handshake. Only 37.7% (2,414 of 6,395 hostnames that spoke MCP) require credentials first.
This is not a bug in MCP. The specification does not mandate authentication fortools/list, and for a read-only public data server there is a reasonable argument that the inventory should be open. The problem is that the ecosystem has not sorted itself into servers where that argument holds and servers where it does not.
How many MCP endpoints refuse an anonymous handshake, rather than revealing their tool inventory to any caller?
how this is computed
with latest as ( select distinct on (s.endpoint_id) s.id as scan_id, s.endpoint_id, s.status, split_part(sv.slug, '/', 1) as slug, sv.slug as service_slug from scans s join endpoints e on e.id = s.endpoint_id join services sv on sv.id = e.service_id where s.tenant_id = $1 and s.status in ('completed','blocked','error') order by s.endpoint_id, s.requested_at desc ) , per_host as ( select latest.slug, min(latest.service_slug) as rep, bool_and(coalesce((e.payload->>'requires_auth')::bool, false)) as flag from latest join evidence e on e.scan_id = latest.scan_id and e.type = 'mcp_protocol_observation' group by latest.slug) select count(*) filter (where flag)::int as numerator, count(*)::int as denominator from per_hostWhat is in those open inventories
22.6% of MCP hosts — 1,448 of them — reveal at least one tool whose name indicates it changes state, moves money, or executes code.The names are not ambiguous, and they recur across unrelated operators.
Which tool names indicating a write, transfer, or execution appear most often across hosts? Detection reads the NAME only, so a harmless tool with an alarming name is counted and a destructive tool with a bland name is missed. These tools were listable anonymously; we never call one, so this is not evidence that they can be invoked without authentication.
| Tool name | Hostnames | |
|---|---|---|
| create_checkout | 38 | |
| create_booking | 30 | |
| create_project | 28 | |
| submit_feedback | 26 | |
| submit_form | 25 | |
| send_message | 19 | |
| delete_webhook | 16 | |
| create_task | 14 | |
| create_webhook | 13 | |
| update_project | 13 | |
| create_account | 11 | |
| create_order | 11 | |
| submit_lead | 11 | |
| subscribe | 11 | |
| create_api_key | 10 |
how this is computed
with latest as ( select distinct on (s.endpoint_id) s.id as scan_id, s.endpoint_id, s.status, split_part(sv.slug, '/', 1) as slug, sv.slug as service_slug from scans s join endpoints e on e.id = s.endpoint_id join services sv on sv.id = e.service_id where s.tenant_id = $1 and s.status in ('completed','blocked','error') order by s.endpoint_id, s.requested_at desc ) , tools as ( select distinct latest.slug, lower(jsonb_array_elements(e.payload->'tools')->>'name') as name from latest join evidence e on e.scan_id = latest.scan_id and e.type = 'mcp_capability_observation') select name as label, count(distinct slug)::int as hosts from tools where (name ~ '(^|[^a-z0-9])(delete|remove|drop|destroy|purge|revoke|write|update|create|insert|modify|upload|publish|send|transfer|pay|charge|refund|purchase|checkout|execute|exec|deploy|provision|grant|impersonate|sudo)([^a-z0-9]|$)' or (name !~ '^(get|list|read|search|fetch|find|query|describe|show|view|lookup|check|count|summarize|download|export|inspect|browse|preview|resolve|status)([^a-z0-9]|$)' and name ~ '(^|[^a-z0-9])(cancel|close|edit|set|put|patch|add|append|post|submit|approve|reject|invoice|buy|sell|order|trade|mint|sign|invite|subscribe|run|shell|command|admin|book|move|rename|reset|disable|enable|merge|push)([^a-z0-9]|$)')) group by name order by count(distinct slug) desc, name limit 15Inventories are not always small, either. Of the 3,981 hosts that returned a tool list, 625 expose more than 20 tools and 172 expose more than 50. A fifty-tool inventory read by a stranger is a fifty-item map of what your service can be persuaded to do.
How many tools does an anonymous caller learn about? Bucketed by the largest inventory observed on each host. Every count here was readable without credentials, because the scanner never authenticates.
| Inventory size | Hostnames | |
|---|---|---|
| no tools | 140 | |
| 1-5 tools | 1,608 | |
| 6-20 tools | 1,608 | |
| 21-50 tools | 453 | |
| more than 50 tools | 172 |
how this is computed
with latest as ( select distinct on (s.endpoint_id) s.id as scan_id, s.endpoint_id, s.status, split_part(sv.slug, '/', 1) as slug, sv.slug as service_slug from scans s join endpoints e on e.id = s.endpoint_id join services sv on sv.id = e.service_id where s.tenant_id = $1 and s.status in ('completed','blocked','error') order by s.endpoint_id, s.requested_at desc ) , per_host as ( select latest.slug, max((e.payload->>'tool_count')::int) as tools from latest join evidence e on e.scan_id = latest.scan_id and e.type = 'mcp_capability_observation' group by latest.slug) select case when tools = 0 then 'no tools' when tools <= 5 then '1-5 tools' when tools <= 20 then '6-20 tools' when tools <= 50 then '21-50 tools' else 'more than 50 tools' end as label, count(*)::int as hosts, min(tools) as sort_key from per_host group by 1 order by min(tools)The caveat that matters most
We never call a tool. Not once, in 13,008 scans. The scanner sends initialize andtools/list and nothing else, deliberately and permanently.
So this finding is precisely: the inventory is readable without authentication, and it contains state-changing tools. It is not a finding that those tools can be invoked without authentication. Many of these servers almost certainly do enforce authorisation at call time, and the correct reading is a question rather than a verdict: why is the map public if the door is locked?
Publishing an unauthenticated inventory of your write operations is a reconnaissance gift even when every one of those operations is properly guarded. It tells an attacker exactly which door to try.
How many hosts let an unauthenticated caller ENUMERATE tools whose names suggest they change state, move money, or execute code? This measures what is listable, not what is callable — we never invoke a tool, so this is disclosure of capability, not proof of unauthenticated write access.
how this is computed
with latest as ( select distinct on (s.endpoint_id) s.id as scan_id, s.endpoint_id, s.status, split_part(sv.slug, '/', 1) as slug, sv.slug as service_slug from scans s join endpoints e on e.id = s.endpoint_id join services sv on sv.id = e.service_id where s.tenant_id = $1 and s.status in ('completed','blocked','error') order by s.endpoint_id, s.requested_at desc ) , per_host as ( select latest.slug, min(latest.service_slug) as rep, bool_or((f.rule_id = 'mcp.unauthenticated_consequential_tools')) as flag from latest join evidence ev on ev.scan_id = latest.scan_id and ev.type = 'mcp_protocol_observation' left join findings f on f.scan_id = latest.scan_id group by latest.slug) select count(*) filter (where flag)::int as numerator, count(*)::int as denominator from per_hostHow much does the definition matter?
Detection reads the tool name only. That is a crude instrument, so we tested how crude: we re-ran the classification with a stricter rule that vetoes names beginning with a read verb, removing false positives like get_post andget_zodiac_sign that a naive keyword match had counted as state-changing. The figure moved by 0.3 percentage points. The finding is not an artifact of where we drew the line.
A correction we had to make to ourselves
The first version of these statistics was wrong, in a way worth publishing because we expect other people measuring this ecosystem to hit it.
We counted per service, where a service is a hostname plus a path. That is the right unit for a profile page: two paths on one gateway really are different services with different capabilities, and each deserves its own record. It is the wrong unit for an ecosystem statistic. One operator runs 1,311 of those paths on a single hostname — 10.5% of every service in the corpus. Every one of them shares an auth posture, a CDN, a protocol version and a tool template. Counted per service, that single deployment cast 1,311 votes in every percentage we published.
The corpus is lumpier than it looks: 12,537 services live on 9,394 hostnames. Recomputing per hostname moved our own headline numbers substantially:
| Statistic | Per service | Per hostname |
|---|---|---|
| Require authentication | 29.2% | 37.7% |
| Reveal state-changing tools | 33.2% | 22.6% |
| Publish OAuth metadata | 43.1% | 34.7% |
The per-service column is a fixed historical measurement taken on 2026-08-20, kept as a record of the error. The per-hostname column is live.
Every figure on this page is per hostname. Profiles remain per service. Two units, because they answer two different questions — and if you are measuring this ecosystem yourself, the gateways will bite you too.
The most concentrated hostnames
| Hostname | Endpoints | Share of corpus |
|---|---|---|
| gateway.pipeworx.io | 1,311 | 10.1% |
| api.m2mcent.com | 276 | 2.1% |
| api.mcp.ai | 238 | 1.8% |
| server.smithery.ai | 216 | 1.7% |
| mcp.apify.com | 128 | 1% |
Authentication and its advertising
37.7% of MCP hostnames require authentication to list capabilities. 34.7% of responding hostnames publish OAuth protected-resource metadata — the RFC 9728 document that tells a client where to authenticate.
These two figures have different denominators and should not be subtracted from one another. What can be said is that neither is a majority, and that this is the pair we most want to watch: a scan repeated in six months tells you whether the MCP ecosystem is hardening or simply growing.
How many endpoints tell a client how to authenticate, by serving /.well-known/oauth-protected-resource?
how this is computed
with latest as ( select distinct on (s.endpoint_id) s.id as scan_id, s.endpoint_id, s.status, split_part(sv.slug, '/', 1) as slug, sv.slug as service_slug from scans s join endpoints e on e.id = s.endpoint_id join services sv on sv.id = e.service_id where s.tenant_id = $1 and s.status in ('completed','blocked','error') order by s.endpoint_id, s.requested_at desc ) , per_host as ( select latest.slug, min(latest.service_slug) as rep, bool_and(coalesce((e.payload->>'metadata_present')::bool, false)) as flag from latest join evidence e on e.scan_id = latest.scan_id and e.type = 'oauth_metadata_observation' group by latest.slug) select count(*) filter (where flag)::int as numerator, count(*)::int as denominator from per_hostThe ecosystem is standing on a very small number of floors
84.1% of MCP hostnames sit behind a CDN or PaaS(5,475 of 6,511 that returned a Server header). Among those where the provider is identifiable, Cloudflare fronts 64% of them.
For hosts where a CDN or PaaS could be identified from response headers, which provider is in front? Hosts whose provider could not be identified are excluded, so this describes the identifiable population rather than the whole corpus.
| Provider | Hostnames | |
|---|---|---|
| Cloudflare | 3,502 | |
| Vercel | 870 | |
| Railway | 516 | |
| Google Cloud | 303 | |
| Fly.io | 164 | |
| AWS ELB | 44 | |
| Netlify | 43 | |
| Heroku | 16 | |
| AWS CloudFront | 13 | |
| AWS S3 | 3 | |
| Erlang/Cowboy | 1 |
how this is computed
with latest as ( select distinct on (s.endpoint_id) s.id as scan_id, s.endpoint_id, s.status, split_part(sv.slug, '/', 1) as slug, sv.slug as service_slug from scans s join endpoints e on e.id = s.endpoint_id join services sv on sv.id = e.service_id where s.tenant_id = $1 and s.status in ('completed','blocked','error') order by s.endpoint_id, s.requested_at desc ) , per_host as ( select distinct latest.slug, e.payload->>'provider' as provider from latest join evidence e on e.scan_id = latest.scan_id and e.type = 'hosting_provider_observation' where e.payload->>'provider' is not null) select provider as label, count(distinct slug)::int as hosts from per_host group by provider order by count(distinct slug) desc limit 12This has an immediate consequence for how anyone should read a trust score. A server presenting valid TLS, HSTS and clean headers may have earned none of it — it may have inherited all of it by deploying behind a platform. ATX therefore separatesprovider-earned signals from infrastructure-supplied ones and discounts the latter, because "this team makes good security decisions" and "this team clicked deploy on a platform that makes them" are different claims about the future.
It is also a concentration risk the ecosystem has not priced. One provider's outage or policy change moves most of MCP at once.
How many endpoints are fronted by a vendor, so their TLS and headers are the vendor configuration rather than the provider one?
how this is computed
with latest as ( select distinct on (s.endpoint_id) s.id as scan_id, s.endpoint_id, s.status, split_part(sv.slug, '/', 1) as slug, sv.slug as service_slug from scans s join endpoints e on e.id = s.endpoint_id join services sv on sv.id = e.service_id where s.tenant_id = $1 and s.status in ('completed','blocked','error') order by s.endpoint_id, s.requested_at desc ) , per_host as ( select latest.slug, min(latest.service_slug) as rep, bool_or((e2.id is not null)) as flag from latest join evidence e on e.scan_id = latest.scan_id and e.type = 'http_response_observation' and e.payload->>'server' is not null left join evidence e2 on e2.scan_id = latest.scan_id and e2.type = 'hosting_provider_observation' group by latest.slug) select count(*) filter (where flag)::int as numerator, count(*)::int as denominator from per_hostYou cannot patch-scan this ecosystem
Only 5.5% of hosts disclose a product version(411 of 7,465). Suppressed version banners are good hygiene, and the platform concentration above is largely why the number is so low — CDNs strip these headers by default. But the consequence is worth naming plainly: the standard external security assessment does not work here. Banner-grab-to-CVE-lookup produces almost nothing across 13,008 services, and where a match does exist it is an inference from a header that may be stale, proxied, or simply wrong.
ATX reports these matches at a severity ceiling of medium regardless of CVSS, labelled as inference rather than observation, because a 9.8 CVSS matched from aServer header is not a 9.8 CVSS finding about that service. External trust assessment for AI-callable software has to be built on behavioural evidence — what a server does when you talk to it — not on version arithmetic.
How many endpoints report a precise software version, which is what vulnerability matching depends on?
how this is computed
with latest as ( select distinct on (s.endpoint_id) s.id as scan_id, s.endpoint_id, s.status, split_part(sv.slug, '/', 1) as slug, sv.slug as service_slug from scans s join endpoints e on e.id = s.endpoint_id join services sv on sv.id = e.service_id where s.tenant_id = $1 and s.status in ('completed','blocked','error') order by s.endpoint_id, s.requested_at desc ) , per_host as ( select latest.slug, min(latest.service_slug) as rep, bool_or((e.payload->>'server' ~ '[0-9]+\.[0-9]+')) as flag from latest join evidence e on e.scan_id = latest.scan_id and e.type = 'http_response_observation' group by latest.slug) select count(*) filter (where flag)::int as numerator, count(*)::int as denominator from per_hostTransport security is a solved problem
99.7% of hosts serve a certificate valid for their hostname(7,436 of 7,461), and 95.6% were reachable at all.This is the healthiest number in the report and it deserves to be said plainly: TLS is done. The residue is small, specific and fixable rather than an ecosystem-wide gap.
It is also a useful yardstick. This is what a solved problem looks like at 99.7%, which is the standard the authentication numbers above should eventually be held to.
How many endpoints present TLS that validates for the host being called?
how this is computed
with latest as ( select distinct on (s.endpoint_id) s.id as scan_id, s.endpoint_id, s.status, split_part(sv.slug, '/', 1) as slug, sv.slug as service_slug from scans s join endpoints e on e.id = s.endpoint_id join services sv on sv.id = e.service_id where s.tenant_id = $1 and s.status in ('completed','blocked','error') order by s.endpoint_id, s.requested_at desc ) , per_host as ( select latest.slug, min(latest.service_slug) as rep, bool_and(((e.payload->>'hostname_match')::bool is not false)) as flag from latest join evidence e on e.scan_id = latest.scan_id and e.type = 'tls_certificate_observation' group by latest.slug) select count(*) filter (where flag)::int as numerator, count(*)::int as denominator from per_hostOf the remote MCP endpoints listed in the official registry, how many answer at all?
how this is computed
with latest as ( select distinct on (s.endpoint_id) s.id as scan_id, s.endpoint_id, s.status, split_part(sv.slug, '/', 1) as slug, sv.slug as service_slug from scans s join endpoints e on e.id = s.endpoint_id join services sv on sv.id = e.service_id where s.tenant_id = $1 and s.status in ('completed','blocked','error') order by s.endpoint_id, s.requested_at desc ) , per_host as ( select latest.slug, min(latest.service_slug) as rep, bool_or(coalesce((e.payload->>'reachable')::bool, true)) as flag from latest join evidence e on e.scan_id = latest.scan_id and e.type = 'availability_observation' group by latest.slug) select count(*) filter (where flag)::int as numerator, count(*)::int as denominator from per_hostThe protocol is fragmenting slowly
11.1% of live MCP servers still speak the November 2024 revision (444 of 3,985 hostnames that negotiated a version). Version skew is normal in a young protocol; it matters because "still on an old spec" tends to correlate with "no longer actively maintained," and an unmaintained server with an open write-tool inventory is a different risk from a maintained one.
Which MCP protocol version did each host agree to speak? A host running several endpoints on different revisions is counted once per revision.
| Protocol version | Hostnames | |
|---|---|---|
| 2025-06-18 | 3,223 | |
| 2024-11-05 | 444 | |
| 2025-03-26 | 243 | |
| 2025-11-25 | 61 | |
| 2026-07-28 | 11 | |
| 2025-11-05 | 3 |
how this is computed
with latest as ( select distinct on (s.endpoint_id) s.id as scan_id, s.endpoint_id, s.status, split_part(sv.slug, '/', 1) as slug, sv.slug as service_slug from scans s join endpoints e on e.id = s.endpoint_id join services sv on sv.id = e.service_id where s.tenant_id = $1 and s.status in ('completed','blocked','error') order by s.endpoint_id, s.requested_at desc ) , per_host as ( select distinct latest.slug, e.payload->>'protocol_version' as version from latest join evidence e on e.scan_id = latest.scan_id and e.type = 'mcp_protocol_observation' where e.payload->>'protocol_version' is not null) select version as label, count(distinct slug)::int as hosts from per_host group by version order by count(distinct slug) desc limit 12Capability drift
Whether a server's tools change under its clients is the measurement we most want and have least of. It requires two observations of the same endpoint, and this is a first scan.
Of hosts observed more than once, how many added, removed, or silently altered a tool?
how this is computed
with per_ep as ( select split_part(sv.slug, '/', 1) as host, ev.subject_id, count(*) filter (where ev.type = 'mcp_capability_observation') as obs_n, count(*) filter (where ev.type = 'mcp_capability_drift') as drift_n from evidence ev join endpoints e on e.id = ev.subject_id join services sv on sv.id = e.service_id where ev.tenant_id = $1 and ev.type in ('mcp_capability_observation', 'mcp_capability_drift') group by 1, 2) , per_host as ( select host, max(obs_n) as max_obs, sum(drift_n) as drift_n from per_ep group by host) select count(*) filter (where drift_n > 0)::int as numerator, count(*)::int as denominator from per_host where max_obs > 1What this report does not tell you
- We never invoke a tool. No
tools/call, ever. Every capability finding is about what is listable, never what iscallable. - Consequential-tool detection reads names, not behaviour. A tool called
create_reportthat only renders a PDF is counted; a destructive tool with a bland name is missed. Tested as robust to ±0.3pp against a stricter definition, but it is still a keyword match. - 17% of hostnames disallow us via
robots.txt(1,596 of 9,394) and are excluded from every capability figure. We honour that to RFC 9309. There is no reason to assume the excluded population resembles the measured one. - One vantage point, one moment. Everything here is unauthenticated, from a single network location, over a single scanning window. Geo-routing, WAF behaviour and rate-limiting all make one vantage point a partial view.
- Absence of evidence is recorded as absence of evidence. An unreachable host is stored as an observation, not silently dropped — but it is not a claim that the host is down for anyone else.
How many endpoints ask not to be scanned, and were therefore not scanned?
how this is computed
with latest as ( select distinct on (s.endpoint_id) s.id as scan_id, s.endpoint_id, s.status, split_part(sv.slug, '/', 1) as slug, sv.slug as service_slug from scans s join endpoints e on e.id = s.endpoint_id join services sv on sv.id = e.service_id where s.tenant_id = $1 and s.status in ('completed','blocked','error') order by s.endpoint_id, s.requested_at desc ) , per_host as ( select latest.slug, min(latest.service_slug) as rep, bool_or((latest.status = 'blocked')) as flag from latest group by latest.slug) select count(*) filter (where flag)::int as numerator, count(*)::int as denominator from per_hostMethod
Every figure is derived from an append-only evidence ledger. Each observation is content-hashed, attributed to the scanner instance that produced it, and never mutated — corrections supersede rather than overwrite.
Scanning is deliberately conservative: at most three unauthenticated GET requests plus, where the endpoint speaks MCP, two read-only JSON-RPC calls (initialize and tools/list) over Streamable HTTP. No tool is ever invoked. One request at a time per host, a minimum delay between them, robots.txt honoured, Retry-Afterobeyed, and a user-agent carrying a contact URL and opt-out route on every request — see the scanner page.
Statistics use the most recent terminal scan per endpoint, so a frequently rescanned endpoint is not counted more heavily, then roll up so each hostname counts once. Where a hostname's endpoints disagree the rollup runs toward caution: a favourable property must hold on every endpoint, while a risk counts if it appears on any. Each statistic states which rule it used. Percentages are suppressed below a sample of 30 rather than published at a size that cannot support them.
Identity. A service is a hostname plus its path, with the trailing /mcp or /sse mount removed. A host that versions its path appears as a separate service per version, and a host that distinguishes services by query string collapses into one — query strings are user-composable, so treating them as identity would let anyone mint unlimited entries.
Every number here is a link. Click any figure to see the hosts behind it and the evidence behind them. If a figure is wrong, it is wrong in a way you can demonstrate — and we would like you to. We have already had to correct ourselves once, in public, above.
If this is your server
Every scanned service has a public profile. If you operate one:
- Claim it by proving domain control — a DNS
TXTrecord at_atx-challenge.<host>or a file at/.well-known/atx-challenge. Verified operators get a badge on the profile. - Dispute a finding if the evidence is wrong. Corrections supersede rather than overwrite, so the record shows what we thought and when we changed it.
- Opt out via
robots.txt. We honour it, and we do not treat opting out as a negative signal.
ATX is an independent evidence layer for AI-callable software. No provider pays for a score, and no score can be bought.