Find shadow MCP servers by checking the places they hide: the network and your clusters, where an MCP server answers a server/discover or initialize request; developer laptops, where MCP clients list servers in config files; and vendor accounts, where connectors run from the vendor’s cloud. Then compare what you found against a registry of servers you approved, because anything not in it is shadow. This page is the walk. Why shadow MCP servers are hard to track is the hub, and what shadow MCP is is the definition.
I work at Solo.io, which makes agentgateway.
Only scan networks you are responsible for.
Where shadow MCP servers hide
Four places. A scan of one of them will always miss the others.
| Place | What finds it | What misses it |
|---|---|---|
| Network | A server/discover probe, then initialize, then GET /sse | stdio servers, vendor-cloud connectors |
| Kubernetes | Services the gateway already federates, plus a scan of Service endpoints from inside the cluster | Laptops, connectors that never enter the cluster |
| Laptops | A read-only sweep of known MCP config paths | Servers with no local file |
| Vendor accounts | Admin consoles that list connectors | Personal OAuth grants on individual plans |
On the network: ask a question only MCP answers
Port numbers do not identify MCP. There is no standard port. You identify a server by sending a request only an MCP server answers.
Probe server/discover first. The 2026-07-28 spec removed initialize and says servers MUST implement server/discover. Streamable HTTP requires MCP-Protocol-Version and Mcp-Method on every request so an intermediary can inspect without parsing the body. Then initialize for older servers, 2025-11-25 and earlier. Then GET /sse for the deprecated HTTP+SSE transport: a text/event-stream response with an event: endpoint line is still an MCP server, and easy to miss if you only speak the current transport. Those checks are read-only, they need no credentials, and they never call tools/call.
A modern-only server reports OPEN / modern when server/discover is first. An initialize-only sweep reports that same server as not MCP. That is why the order is the finding.
Run the scanner
The scanner is themsquared/shadow-mcp-scanner
. I tested it in the repo’s local-process lab (scripts/lab_local.sh). Put this form in CI:
python3 scanner/scan.py mcp.internal:8080 --fail-on-open
--fail-on-open exits 1 when any endpoint answers server/discover or initialize without credentials, and 0 otherwise. The scanner takes hosts and ports, not a CIDR. Feed it from your inventory, your mesh, or nmap output. It is standard library Python with no dependencies.
The lab includes one ordinary web service, build-dashboard, that is not MCP at all. It is there so the output proves the scanner is not just flagging open ports. The numbers on the hub come from that local-process lab. A compose re-run is pending, so I am not quoting a fresh table here.
What open looks like
An open server answers tools/list for an anonymous caller. Tool descriptions routinely carry internal hostnames and account names. The hub section on what an open server hands over
has the lab example. Pin tool definitions
, not just names, because the next tools/list can rewrite what the model is told the tool does.
In Kubernetes: start from what the gateway knows
With agentgateway on Kubernetes, MCP Services that set appProtocol: agentgateway.dev/mcp are the ones the gateway federates as virtual MCP. An AgentgatewayBackend can point at those Services with label selectors and multiplex several servers onto one endpoint. Label selectors speak Streamable HTTP only. If you have SSE, that backend has to be static. Those federated Services are your known servers. I built the federation half of that as a federated MCP endpoint
on Solo Enterprise.
Default failure mode on that backend is FailClosed: if any MCP target fails to initialize, the whole session fails. FailOpen skips the failed target and keeps serving the healthy ones. Either way, the list of targets on the backend is the list you meant to run. A Service in the cluster that speaks MCP and is not selected by that backend is the gap.
Then run the scanner from inside the cluster against Service endpoints. Any MCP server that answers and is not behind the gateway is a server agents could reach around your controls.
Current open-source agentgateway is v1.6.0, released October 2, 2026. Nothing below is from main or nightly.
Watch the traffic the gateway already sees
In standalone, agentgateway exposes mcp_requests_total, labeled by server, method, resource, and resource_type. Filter on method to count tool calls. Structured logs add mcp.method.name, mcp.target, mcp.session.id, and gen_ai.tool.name on tool calls. A server label you do not recognize is a lead even before you have a full inventory.
On Kubernetes, the dataplane metrics endpoint on port 15020 includes agentgateway_mcp_requests_total. Counters appear after the first MCP request, which is normal Prometheus behavior. The docs’ example PromQL groups that counter by server and resource and filters on method="tools/call". A server name you do not recognize in that metric is a lead: something is already calling a server that is not on your approved list.
The Kubernetes metric is the same family as the standalone one. I am not claiming the Kubernetes page lists every standalone label. Use the labels the docs actually show.
On laptops: read the config files
A process list misses stdio servers. They are children of the IDE or agent and never open a port. Credentials often sit in env blocks next to the command. Sweep known config paths read-only, record client, scope, transport, and URL or command, and never copy env or headers values. A sweep that prints those values has made the inventory worse. Project files such as .mcp.json arrive with git clone, so the sweep has to include repos, not only home directories.
The method for laptops is its own page. The full path table is its own page. This section only names the place.
In vendor accounts: connectors with no local file
Claude custom connectors connect from Anthropic’s cloud, not from the laptop, in every Claude client. That is true on claude.ai, Claude Desktop, Cowork, and the mobile apps. A laptop sweep and a network scan can both be clean while that connector is still live. The MCP server has to be reachable on the public internet from Anthropic’s IP ranges, which is the other side of the same fact: a private-network MCP server will not show up as a Claude connector, and a Claude connector will not show up on your network.
On Team and Enterprise plans, owners add custom connectors under Organization settings, then Connectors, which gives you a list. Members then connect those connectors themselves. Personal OAuth grants on individual plans do not show up anywhere you control.
Reconcile against a registry of record
The official MCP Registry lists public servers only. It does not support private servers such as an internal hostname, and it says its codebase is not designed for self-hosting. Run a private MCP registry that implements the official API. Unapproved is found minus registered. Until that list exists, every scan is a pile of names with no baseline. Name matching is not a substitute. A server name is a label the server chose.
Make finding them continuous
Three loops, not a one-off:
- The scanner gate in CI,
--fail-on-open, against the hosts and ports you actually deploy. - The config sweep on a schedule through MDM, read-only, redacted.
- Gateway metric alerts on new
serverlabel values inmcp_requests_total(standalone) oragentgateway_mcp_requests_total(Kubernetes).
Finding them produces a timestamped list. It does not stop the next one.
Put agentgateway in the only MCP path, standalone or on Kubernetes. Federate the servers you registered. Authorize each tool call. Default-deny egress so a workload cannot reach a remote MCP URL on its own. Allowlist by URL and command, never by name. Laptops stay the gap: a developer on a machine with unrestricted egress can still reach a personal remote server, and no cluster NetworkPolicy will notice. The find page ends where that work starts.