Blog

Inside Anthropic's MCP tunnels

Notes from running the reference deployment end to end.

Dana Cohen
12  min. read time

How MCP tunnels route private agents without exposing your network

Anthropic's MCP tunnels let Claude reach MCP servers that live inside a customer's private network without exposing those servers to the public internet. The customer's side dials outbound to Anthropic; nothing on the private side has to listen for incoming connections. Once a tunnel is set up, Claude can use those private MCP servers from Managed Agents in the Console and from the Messages API.

Four hops, one plaintext bridge: the full tunnel stack

The deployment has four distinct components:

  • Cloudflare edge: the Cloudflare-operated tunnel edge that fronts the MCP traffic to your subdomain under .tunnel.anthropic.com.
  • cloudflared: Cloudflare's open-source tunnel daemon, run by the customer. Dials outbound to the tunnel edge on port 7844 and carries the encrypted bytes between the edge and mcp-proxy.
  • mcp-proxy: Anthropic's routing component, run by the customer. Terminates the inner TLS handshake (against a customer-registered CA) and routes to the configured upstream.
  • Upstream MCP server: the customer's actual private MCP server.

The user talks to an agent hosted in Claude Console on Anthropic's backend. That agent reaches a private MCP server (the hello-mcp container) through the tunnel, addressed on the route subdomain echo.<tunnel-domain>.

What the wire actually shows: layer-by-layer traffic analysis

Anthropic → Cloudflare edge. Trying to curl the tunnel domain returns 403 from Cloudflare.

Cloudflare edge → cloudflared. QUIC/UDP 7844 (HTTP/2 over TCP fallback) to 198.41.192.0/19. The only continuous outbound rule the client's firewall needs, plus a one-time api.anthropic.com:443 for setup.

cloudflared → mcp-proxy. The proxy listens on :8080 as a plain WebSocket, and Anthropic's backend runs a TLS handshake inside the WebSocket frames.

Cloudflare Access attaches a Cf-Access-Jwt-Assertion to each inbound request, asserting the caller's Cloudflare Access identity, and forwards it through cloudflared to the proxy as a header. Decoding it shows the caller is CN=Toolbox MCP Client (production), O=Anthropic. That's Anthropic's backend.

Inner TLS terminates at the proxy against a cert signed by a CA the customer registered. Server-auth only: TLSv1.3 / TLS_AES_128_GCM_SHA256 in our deployment.

mcp-proxy → upstream MCP server. This hop is plain HTTP. (Per Anthropic's reference docs, the proxy also validates the upstream IP against upstream.allowed_ips, which defaults to RFC1918 ranges).

We also routed the HTTP communication through Burp Suite for easier request inspection and replay.

Security questions the documentation doesn't answer

  1. What does the proxy actually forward in plaintext? The proxy decrypts inner TLS, so the bridge between it and the upstream MCP server is the natural inspection point.
  2. Can a tool turn the tunnel around? If Anthropic can push HTTP at us, can a malicious tool push HTTP back the other way and reach into Anthropic?
  3. Can someone with network reach to the proxy speak MCP without credentials? Standing in for Anthropic's backend, no JWT and no client cert.
  4. Once we stand in for Anthropic, can we drive the upstream MCP server through the full protocol? Sampling, elicitation, roots, list-changed, real tool calls: what gets accepted, what gets rejected.
  5. Is the imitator limited to MCP, or will mcp-proxy forward arbitrary HTTP? A bare GET / should tell us.

The proxy accepts anyone on the Docker bridge: what stripping credentials one by one revealed

We started where every researcher starts: passive observation. We ran the reference deployment end-to-end and watched the wire at every layer we could reach. The cloudflared to Cloudflare hop gave us only metadata: QUIC keepalives to four edge POPs, no payloads. The mcp-proxy to upstream hop was the opposite: plaintext HTTP/1.1 carrying every header and tool argument in the clear. Each tap confirmed pieces of what the docs claim, but together they didn't answer the question we actually cared about: what does mcp-proxy require from whoever connects to it on port 8080? Sniffing tells you what the protocol says. It doesn't tell you what the proxy checks.

We had to ask the proxy directly. The plan was to take Anthropic's seat: to be the thing that normally hands traffic into mcp-proxy over inner-TLS-inside-WebSocket, and see how much of that role the proxy actually enforces. Strip a credential, see if it still works. Remove another. Repeat until we run out.

So we built imitate_client.py: pure Python stdlib, no MCP SDK, no Cloudflare libraries, just the raw protocol.

  • On the WebSocket upgrade, mcp-proxy returned 101 Switching Protocols identically whether the upgrade carried a valid Cf-Access-Jwt-Assertion, a bogus one, or none at all.
  • We dropped the JWT. mcp-proxy accepted us.
  • We removed every other Cf-Access-* header. mcp-proxy accepted us.
  • We removed the client-side certificate from the inner TLS handshake. mcp-proxy never asked for one.
  • We added a --noverify flag that skipped validating mcp-proxy's server cert entirely. The handshake still completed.

By the end, the client was a stranger off the docker bridge with no credentials at all, and mcp-proxy let it through every time.

All mcp-proxy actually checks is that the connection arrived on port 8080 and the caller asked for a WebSocket upgrade. Anthropic does enforce real authentication (JWTs, Cloudflare Access mTLS, cloudflared identity), but those checks all live at Cloudflare's edge, before the request reaches mcp-proxy. They never re-run at the proxy. So if a caller skips the edge and connects directly to mcp-proxy:8080 from inside the customer's network, none of those checks apply.

This is by design. Anthropic positions the proxy as a routing component (terminate inner TLS, route by hostname, SSRF-guard the upstream) and pushes caller authentication out to the layers around it: Cloudflare Access at the edge and OAuth on each upstream MCP server. The customer is explicitly named in the shared-responsibility model as the party that restricts network access to the proxy. What the docs don't quite spell out is the consequence of not doing that. They tell you to lock the door; they don't say what's behind it. That's the transparency seam this experiment fills.

We plugged Burp Suite into the mcp-proxy to hello-mcp hop to read and replay the plaintext coming out the other side. From the imitating client we drove the upstream through a full MCP session: initialize, notifications/initialized, tools/call hello. All worked.

[1] initialize                  200  TLSv1.3 / TLS_AES_128_GCM_SHA256
[2] notifications/initialized   202
[3] tools/call hello            200  "hello, imitator-no-jwt — ... works."

An attacker inside the customer's network can build a client like imitate_client.py, take Anthropic's seat at mcp-proxy, and drive every routed upstream with no credentials of any kind. mcp-proxy cannot tell the attacker apart from real Anthropic. Cloudflare Access at the edge was never positioned to help once the attacker is already inside.

The answer to "can anyone connect to the MCP with no authentication?" depends on where you're standing:

Where you are Can you reach mcp-proxy:8080 and speak MCP?
Random IP on the public internet No. Cloudflare Access rejects you at the edge (HTTP/2 403). The public *.tunnel.anthropic.com hostname requires Anthropic's mTLS identity (CN=Toolbox MCP Client (production)).
Anything with network reach to :8080 in the customer network Yes. No JWT, no client cert, no CA needed. The imitation client has a --noverify flag that skips CA validation entirely.

The entire authentication boundary lives at Cloudflare's edge. It's the inverse of what most people assume when they read "tunneled, mTLS, OAuth" in the docs and conclude this is end-to-end authenticated. It is, at the edge. Past the edge, network segmentation is the gate.

The docs do say "restrict network access for the proxy." What isn't documented is why: the proxy doesn't validate the Cloudflare Access JWT, and the inner TLS is server-auth only. imitate_client.py is what's on the other side of that door.

Network segmentation is doing more work than the docs imply: three fixes

The architecture itself is well-considered: outbound-only transport, inner TLS terminated by a customer-controlled CA, Cloudflare Access at the front door, and OAuth recommended on each upstream MCP server. The layers are sound. What's worth being explicit about is where the authentication actually ends: past mcp-proxy:8080, the proxy treats whatever reaches it as Anthropic. The customer's internal network controls are doing more security work than the typical reader of the docs assumes.

Three practical ways to close that gap:

1. Network-isolate the proxy. Put mcp-proxy on a dedicated network segment and let nothing but cloudflared reach port 8080. Kubernetes NetworkPolicy, Docker network configs, or VPC ACLs: whichever fits the environment. The goal is the same: no sibling container, peer pod, or host process should have a route to :8080. The deploy-compose reference is the right starting point for mapping network boundaries.

2. Require OAuth on every upstream MCP server. Even if an attacker reaches the proxy, OAuth at the upstream is what stops them from actually using the tools. Anthropic's docs already recommend this; the imitation experiment is what makes it non-optional.

3. Put a control point in front of every upstream. The mcp-proxy to upstream hop is the only place MCP exists in plaintext in this deployment, and it's the natural seam to add policy and audit. Whatever you put there should authenticate the caller, apply per-tool policy, and write a tool-call audit trail. The Compliance API logs MCP- and tunnel-lifecycle events (tunnel created, CA cert added, MCP server added, tool-policy changed), but those are administrative metadata, not the tool calls themselves: it still doesn't record which tool Claude invoked over a tunnel, with what arguments, or what it returned. So without your own instrumentation on this hop you can't reconstruct what Claude actually did over your tunnels last week.

The findings all point at the same missing piece: a control point between mcp-proxy and the actual upstream MCP server. That's exactly where Tego's MCP gateway sits. You point mcp-proxy's routes map at the gateway, so every MCP request lands there before reaching the real upstream. The gateway inspects every message, enforces per-tool policy, injects upstream credentials, and writes the full tool-call audit trail, the one the Compliance API doesn't surface. Same hop mcp-proxy was already going to forward to; it just stops being the place where security ends.

Q&A

Q: What is an MCP tunnel and why would I use one?

An MCP tunnel lets Claude reach MCP servers inside your private network without opening inbound firewall rules. Your side dials outbound to Anthropic's edge via cloudflared; no public IP or open port is needed on your infrastructure. It's the right choice when your MCP server talks to internal databases, private APIs, or other resources that can't or shouldn't be internet-accessible.

Q: Is the MCP tunnel end-to-end authenticated?

At the edge, yes. Cloudflare Access enforces mTLS with Anthropic's identity (CN=Toolbox MCP Client (production)) before any request reaches your network. Past the edge, specifically past mcp-proxy:8080, the proxy applies no further credential check. It accepts any WebSocket connection that arrives on that port. The security docs document this in the shared-responsibility model, but the practical consequence (anyone with internal network reach to :8080 can impersonate Anthropic) isn't spelled out explicitly.

Q: Who can reach mcp-proxy:8080 in a default deployment?

It depends on your network configuration. In a default Docker Compose deployment, anything on the same Docker bridge network has a route to :8080. That includes every other container in the same Compose stack. Kubernetes deployments without a NetworkPolicy have similar exposure within the namespace. The fix is explicit network isolation: only cloudflared should have a route to mcp-proxy:8080.

Q: Does requiring OAuth on the upstream MCP server fully close this gap?

Yes, for tool access. If the upstream MCP server requires OAuth, an attacker who reaches mcp-proxy without credentials still can't call tools. But OAuth on the upstream doesn't help with the audit problem: you still won't know the attacker connected, what they enumerated, or what they attempted. Network isolation closes the access gap; OAuth closes the exploitation gap; a logging control point closes the visibility gap. All three are needed.

Q: How does this compare to running an MCP server with direct internet exposure?

The tunnel architecture is meaningfully more secure. Direct internet exposure means any IP can attempt connections; the tunnel restricts the public attack surface to *.tunnel.anthropic.com, which requires Anthropic's mTLS identity. The residual risk in the tunnel model is specifically internal: it's lateral movement from within your network, not external access. That's a narrower threat model, but it's still a real one in environments with broad internal network trust.

Q: What should I check first in my own MCP tunnel deployment?

Three things: (1) verify that nothing except cloudflared can reach mcp-proxy:8080 (run netstat or check your network policy); (2) confirm that every upstream MCP server has OAuth enabled, not just recommended; (3) check whether you have any tool-call logging in place. The Compliance API surfaces MCP- and tunnel-lifecycle events, but only as metadata, not the arguments Claude passed to a tool or what it returned. If you can't reconstruct what tools Claude called and with what arguments, you don't have a useful audit trail.