An MCP server is a small HTTP service. Anyone on your team can start one in an afternoon, and the useful ones get built precisely because they reach something valuable: the warehouse, the ticket tracker, the build agent, the CRM. None of that appears in a service catalog, and the port it listens on tells you nothing.
So I wrote a scanner that finds them and reports what each one gives up to a caller with no credentials. The code is at themsquared/shadow-mcp-scanner, along with a five-endpoint network to point it at. It runs in about fifteen seconds and needs nothing but Docker.
The census nobody has repeated
In July 2025, Alfredo Oliveira and David Fiser at Trend Micro published a count of internet-exposed MCP servers: 492 of them running with no client authentication and no transport encryption, collectively exposing 1,402 tools. More than 90% offered direct read access to a data source. About 74% were hosted on AWS, Azure, GCP, or Oracle.
That report is over a year old, and it is still the number people cite. Nobody has published a follow-up census. Whatever the figure is now, the interesting thing is that the ecosystem grew enormously in the interval and the measurement did not. Meanwhile r/mcp posts a dozen new servers on an ordinary day, and none of those announcements say who operates the endpoint or what it requires to call it.
The internet-facing count is also the less interesting half. A server bound to a VPC, reachable by every workload in the cluster and every laptop on the VPN, is not in that 492 and is the one your agents will actually find.
What makes an MCP server findable
You cannot identify an MCP server by port. You identify it by asking it a question only an MCP server answers.
Send a JSON-RPC initialize over HTTP POST. A real server responds with its protocol version and a serverInfo block:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-06-18",
"capabilities": {
"tools": {
"listChanged": false
}
},
"serverInfo": {
"name": "mcp-open",
"version": "1.4.2"
}
}
}
That handshake is the fingerprint. It costs one request, needs no credentials, and works regardless of what the service calls itself.
There is a second transport worth probing. Before Streamable HTTP, MCP used HTTP+SSE: a GET /sse that opens an event stream and announces where to POST. Plenty of deployed servers still run it, and it is easy to miss if you only speak the current transport. A GET that comes back as text/event-stream with an event: endpoint line is an MCP server too.
The scanner tries both, in that order, against every host and port it is given.
Four postures, not two
The useful output is not a list of servers. It is a list of servers sorted by what they require of a caller.
ENDPOINT POSTURE TRANSPORT TOOLS
------------------------------------------------------------------------------------------------
http://legacy-sse:8080/sse OPEN http+sse (legacy) 1
http://mcp-open:8080/mcp OPEN streamable-http 3
http://mcp-bearer:8080/mcp PROTECTED (no discovery) streamable-http -
http://mcp-oauth:8080/mcp PROTECTED (RFC 9728) streamable-http -
http://build-dashboard:8080 not MCP - -
scanned 5 endpoints: 4 speak MCP, 2 open with no auth
The scanner was not told which of those five hosts were MCP servers. It was handed five addresses and worked it out. build-dashboard is an ordinary web service, included so the output demonstrates the scanner is not simply flagging open ports.
Two of the four are open. One of them only speaks the legacy transport, which is exactly the server a current-transport-only sweep would have missed.
What an open server hands over
initialize tells you a server exists. tools/list tells you what it can do, and an open server answers that from an anonymous caller as readily as from an authorized one:
http://mcp-open:8080/mcp answered tools/list with no credentials:
- run_query: Execute a read-only SQL statement against the analytics warehouse (warehous...
- send_email: Send an email as noreply@ from the shared notifications mailbox.
- list_customers: Return customer records from the CRM, including billing contact and plan.
The tool names in the demo are deliberately mundane and deliberately far-reaching, because that combination is the finding. Note what the truncation is hiding. The full description of run_query reads:
Execute a read-only SQL statement against the analytics warehouse (warehouse-prod.internal:5439, service account svc_analytics).
An internal hostname, a port, and a service account name, disclosed to anyone who can route to the endpoint, before a single tool is invoked. Tool descriptions are written for a model, in prose, by whoever built the server. They are not treated as a public surface, and on an open server that is precisely what they are.
This is the reason discovery has to come before the enumeration is anyone’s problem. The disclosure happens at tools/list, and tools/list does not need a credential on a server that never asked for one.
The 401 that tells you nothing
The two protected servers in the table both return a 401. Only one of them is useful to a client that finds it:
mcp-bearer: HTTP 401
WWW-Authenticate: Bearer
mcp-oauth: HTTP 401
WWW-Authenticate: Bearer resource_metadata="http://mcp-oauth:8080/.well-known/oauth-protected-resource"
The second one names a metadata document. Fetch it and you get the authorization server, the supported scopes, and how to present the token:
{
"resource": "http://mcp-oauth:8080",
"authorization_servers": ["https://idp.internal/realms/agents"],
"bearer_methods_supported": ["header"],
"scopes_supported": ["mcp:tools:read", "mcp:tools:invoke"]
}
This is not a style preference between two valid options. The MCP authorization spec says servers MUST implement RFC 9728 Protected Resource Metadata, and MUST use WWW-Authenticate on a 401 to indicate the resource metadata URL. A bare Bearer is protected and non-conforming. Whoever finds that server knows they need a credential and has no supported way to learn which authorization server issues it.
So the scanner reports those separately. PROTECTED (no discovery) is not a finding on the same severity as OPEN, but it is a finding: it marks a server that will be hard to onboard properly and was probably configured by hand. I wrote about the wider version of this gap in the MCP agent identity gap, where the roadmap names four identity workstreams and the ecosystem ships one.
Running it
git clone https://github.com/themsquared/shadow-mcp-scanner
cd shadow-mcp-scanner
bash scripts/demo.sh
That brings up five containers and scans them. To check the classification against what the repo claims:
bash scripts/verify.sh # 20 assertions
Against something real, the scanner takes hosts and ports rather than a CIDR. Feed it from your own inventory, your mesh, or nmap output:
python3 scanner/scan.py mcp.internal:8080
python3 scanner/scan.py buildbox.internal:8000-8100
python3 scanner/scan.py a.internal:8080,b.internal:3000 --json
The form worth putting in a pipeline is the gate:
python3 scanner/scan.py mcp.internal:8080 --fail-on-open
It exits 1 when any endpoint answered initialize without credentials and 0 otherwise. The scan is cheap enough to run on every deploy, and the failure is unambiguous.
The scanner is read-only. It calls initialize and tools/list, never tools/call. It is standard library Python with no dependencies, which matters mostly because it means you can read all of it before you run it on your own network. Only scan networks you are responsible for.
What finding them does not fix
Discovery is the control that has to come first, and it is not the control that solves anything. A scan produces an inventory with a timestamp on it, and the inventory is stale the next time somebody runs docker compose up.
The durable version is a chokepoint. Route MCP traffic through a gateway that authorizes each tool call, so an unregistered server is not reachable by an agent even when it is running, and keep a registry that records who owns each server and what it is allowed to reach. I built the federation half of that in multi-tenant MCP federation. The broader question of which controls actually bind an agent, versus which ones only look like they do, is the subject of the rogue agent control map.
Until that exists, the scanner tells you the size of the problem. Two open servers out of five is a demo number. The one worth knowing is yours.
Every command and every block of output above was run on the commit that ships in the repo: 20 of 20 assertions pass from a fully torn-down stack, two seconds to bring five containers up and scan them, one second to verify, on an M4 Max with the base image already pulled.