Aligns the compose service name, local config filename, and all
log/restart commands with the image and binary name. Adds an explicit
-config arg since the image CMD still defaults to the legacy
/etc/mcp-gateway path.
🏠 Remote-Dev: homespace
16 KiB
| description | argument-hint | allowed-tools | |||||
|---|---|---|---|---|---|---|---|
| Stand up an Anthropic MCP tunnel locally with Docker Compose so Claude can call a private MCP server (manual-credentials quickstart). | [deployment-dir] (default: ./mcp-tunnel) |
|
Create a Docker MCP tunnel
Drive the MCP tunnels quickstart end to end: from zero to Claude calling a private MCP server through an Anthropic-operated tunnel, using Docker Compose with manually supplied credentials (the shortest path for local testing).
MCP tunnels is in research preview. It is provided "as-is" with no uptime or support commitment and depends on a third-party transport (Cloudflare). Do not put production traffic through this without reading the security model.
You are guiding the user through a mix of local commands you run and Console actions only they can do (creating the tunnel, uploading the CA). Be a careful operator: explain each step briefly, run the commands, check the output, and stop with a clear diagnosis if something fails.
Deployment directory: use $ARGUMENTS if the user passed a path, otherwise
default to ./mcp-tunnel. Refer to it below as $DIR.
What you'll build
A container stack on the user's machine:
- mcp-proxy — Anthropic's proxy. Terminates the inner TLS handshake using a certificate the user controls, validates upstream IPs, routes by hostname.
- cloudflared — the tunnel agent. Outbound-only connection to the Anthropic tunnel edge; shares the proxy's network namespace.
- hello-mcp (optional) — a sample FastMCP server, only if the user has no MCP server of their own to expose yet.
When it's up, the routed server is reachable from Claude at
https://<subdomain>.<tunnel-domain>/<path> with nothing listening on a public
port.
Step 0 — Preflight
Run these and report what's missing before going further:
docker --version && docker compose version && openssl version
- Docker + Docker Compose are required.
openssl1.1.1+ is required (the commands below use-addext, available in 1.1.1+). - Confirm the host has outbound access to
api.anthropic.com:443and the tunnel edge (198.41.192.0/19,2606:4700:a0::/44) on 7844 TCP and UDP. No inbound ports are opened.
If docker compose (v2) is unavailable but docker-compose (v1) exists, use
that and tell the user; the compose file is v2-compatible.
Step 1 — Create the tunnel (Console — user action)
Tell the user to do this in the Claude Console (see Create a tunnel):
- Sidebar → Manage → MCP tunnels → New tunnel. Give it a name.
- Leave Set up programmatic access off — this quickstart uses manual credentials.
- Open the tunnel. From the Connection section copy two values:
- Domain — looks like
abcd1234.tunnel.anthropic.com - Token — click the eye icon, then copy
- Domain — looks like
Then ask the user, via AskUserQuestion or a direct prompt, for the Domain.
Do not ask them to paste the Token into the chat. The token is a secret
that authenticates the outbound tunnel connection; keep it out of the
transcript. Instead, tell them you will create a $DIR/.env file and they
should paste the token into it themselves (Step 3), or have them export it:
export TUNNEL_TOKEN='eyJ...' in the shell you'll run compose from.
Record the domain as TUNNEL_DOMAIN for the steps below.
Step 2 — Deployment directory
mkdir -p "$DIR"/{config,data}
cd "$DIR"
Step 3 — Credentials file
Create $DIR/.env (compose auto-loads it; this survives reboots, unlike a
shell export). Write TUNNEL_DOMAIN yourself; leave a placeholder for the
secret and have the user fill it in:
TUNNEL_DOMAIN=<the domain from step 1>
TUNNEL_TOKEN=PASTE_TUNNEL_TOKEN_HERE
Then lock it down and make sure it never gets committed:
chmod 600 "$DIR/.env"
printf '.env\ndata/\n' > "$DIR/.gitignore"
Pause and have the user replace PASTE_TUNNEL_TOKEN_HERE with the real token
(tell them the exact file path). Verify it's set without printing it:
cd "$DIR" && grep -q '^TUNNEL_TOKEN=eyJ' .env && echo "token looks set" || echo "token NOT set — edit .env"
Load it for the openssl/config steps in this shell:
cd "$DIR" && set -a && . ./.env && set +a && echo "domain: $TUNNEL_DOMAIN"
Step 4 — Generate the CA and server certificate
The proxy terminates an inner TLS handshake using a certificate signed by a CA the user controls. Generate both (Linux/macOS shown; the quickstart also has a Windows PowerShell variant — offer it if the user is on Windows):
cd "$DIR"
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout data/ca.key -out data/ca.crt \
-days 3650 -subj "/CN=mcp-tunnel-ca" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign" \
-addext "subjectKeyIdentifier=hash"
cat > data/tls.ext <<EOF
subjectAltName = DNS:${TUNNEL_DOMAIN},DNS:*.${TUNNEL_DOMAIN}
authorityKeyIdentifier = keyid,issuer
extendedKeyUsage = serverAuth
EOF
openssl req -newkey rsa:2048 -nodes \
-keyout data/tls.key -out /tmp/server.csr \
-subj "/CN=${TUNNEL_DOMAIN}"
openssl x509 -req -in /tmp/server.csr \
-CA data/ca.crt -CAkey data/ca.key -CAcreateserial \
-out data/tls.crt -days 90 -extfile data/tls.ext
chmod 644 data/tls.key
Why these flags: the explicit -addext extensions make the CA satisfy the
tunnel's certificate requirements
regardless of distro openssl.cnf defaults;
-extfile (not -copy_extensions, which is OpenSSL 3.0+ only) keeps this
working on OpenSSL 1.1.x and adds the AuthorityKeyIdentifier the proxy
requires. chmod 644 data/tls.key is required: openssl writes the key
0600 but the proxy container runs as a non-root user and must read it.
data/tls.key and data/ca.key are sensitive — they live under data/,
which the .gitignore from Step 3 already excludes.
Step 5 — Register the CA (Console — user action)
Have the user, on the tunnel detail page, scroll to Certificates →
Add certificate
(see Add a CA certificate),
and upload $DIR/data/ca.crt (or paste its contents —
print it with cat data/ca.crt so they can copy it). The tunnel status flips
to Active once a certificate is registered. The tunnel will not appear in
the agent picker until this is done.
Wait for the user to confirm the tunnel shows Active before continuing.
Step 6 — Choose the upstream MCP server
Ask the user (AskUserQuestion):
- "I have an MCP server already" — get its reachable address as
scheme://host:port(port mandatory, no path — the proxy rejects a path in the upstream value at config load). It must be reachable from the proxy container and resolve to an RFC1918 private address (10/8,172.16/12,192.168/16); the proxy refuses public/loopback upstreams by default (SSRF protection). If it runs as a Compose service, add it to the compose file so it shares the network. If it runs on the host, see Troubleshooting ("host process"). Pick a route subdomain with the user (e.g.wiki). - "Use the sample server" — scaffold the FastMCP
hello-serverbelow as a Compose servicehello-mcpand route subdomainecho.
Sample server (only if chosen)
Write $DIR/hello_server.py:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("hello-server", host="0.0.0.0", port=9000)
@mcp.tool()
def hello(name: str = "world") -> str:
"""Say hello to someone."""
return f"Hello, {name}!"
if __name__ == "__main__":
mcp.run(transport="streamable-http")
Step 7 — Proxy config
Write $DIR/config/mcp-proxy.yaml. tunnel_domain is required (the
proxy strips it from the incoming hostname to find the subdomain in routes).
routes is a flat map subdomain → upstream URL, not a list:
listen_addr: ":8080"
log_level: info
tunnel_domain: <TUNNEL_DOMAIN>
tls:
cert_file: /data/tls.crt
key_file: /data/tls.key
routes:
echo: http://hello-mcp:9000
Substitute the real TUNNEL_DOMAIN. Replace the routes: block with the
user's chosen subdomain → upstream if they brought their own server (e.g.
wiki: http://wiki-mcp.internal:8080). You can keep multiple routes.
Step 8 — Compose file
Write $DIR/docker-compose.yaml. Images are pinned by digest:
services:
mcp-proxy:
image: us-docker.pkg.dev/anthropic-public-registry/images/mcp-proxy@sha256:6b9adedbf2763143ec72f106ecaf0ce7fd3294e89b208f54a1db97a33d14c5ba
command: ["-config", "/etc/mcp-proxy/config.yaml"]
volumes:
- ./config/mcp-proxy.yaml:/etc/mcp-proxy/config.yaml:ro
- ./data:/data:ro
restart: unless-stopped
cloudflared:
image: cloudflare/cloudflared@sha256:6b599ca3e974349ead3286d178da61d291961182ec3fe9c505e1dd02c8ac31b0
command: tunnel --no-autoupdate run --url http://localhost:8080
environment:
- TUNNEL_TOKEN
network_mode: "service:mcp-proxy"
restart: unless-stopped
--url http://localhost:8080 is required in the manual flow: no ingress
rules are pushed server-side, so without it cloudflared 503s every request.
network_mode: "service:mcp-proxy" shares the proxy's netns so
localhost:8080 reaches it. environment: - TUNNEL_TOKEN (no value) passes
the variable through from .env.
If the sample server was chosen, append the service:
hello-mcp:
image: python:3.13-slim
working_dir: /app
volumes:
- ./hello_server.py:/app/hello_server.py:ro
command: sh -c "pip install --quiet mcp && python hello_server.py"
restart: unless-stopped
If the user brought their own server and it's containerized, add its service here too so it shares the Compose network with the proxy.
(For a hardened single-host deployment — non-root user, read-only rootfs,
cap_drop: ALL, no-new-privileges — point the user at
Deploy with Docker Compose;
this quickstart keeps it minimal for fast local testing.)
Step 9 — Start and verify
cd "$DIR" && docker compose up -d
sleep 5
docker compose logs mcp-proxy | grep -i "route configured"
docker compose logs cloudflared | grep -i "Registered tunnel connection"
Expect one route configured line per route and four
Registered tunnel connection lines. Containers take a few seconds; rerun the
log greps if they come back empty (don't conclude failure on the first empty
result). If they stay empty, go to Troubleshooting.
Step 10 — Call it from Claude
Tell the user both options:
Managed Agents (Console): Managed Agents → Sessions → new session →
agent picker Create new agent → + MCP Server → select the tunnel →
Subdomain = the route (echo), Path = mcp (FastMCP
streamable-http serves at /mcp). Then ask: "Use the hello tool to greet
tunnel." — expect a tool call and its result.
Messages API: the host is <subdomain>.<tunnel-domain>; the path is
whatever the upstream serves (/mcp for FastMCP). Use an API key for the
workspace the tunnel was created in.
curl https://api.anthropic.com/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "anthropic-beta: mcp-client-2025-11-20" \
-d "{
\"model\": \"claude-opus-4-7\",
\"max_tokens\": 1024,
\"mcp_servers\": [{\"type\": \"url\", \"name\": \"echo\", \"url\": \"https://echo.${TUNNEL_DOMAIN}/mcp\"}],
\"tools\": [{\"type\": \"mcp_toolset\", \"mcp_server_name\": \"echo\"}],
\"messages\": [{\"role\": \"user\", \"content\": \"call hello with name=tunnel\"}]
}"
The tunnel carries encrypted traffic but does not authenticate to the upstream. If the upstream MCP server requires its own auth, the user supplies it the same as for any other MCP server.
Troubleshooting (diagnose in this order)
| Symptom | Cause | Fix |
|---|---|---|
Caller sees HTTP 500; cloudflared logs No ingress rules were defined |
cloudflared has no local target | Ensure --url http://localhost:8080 and network_mode: "service:mcp-proxy" are both present, then docker compose up -d |
Proxy exits cannot unmarshal !!seq into map[string]string |
routes written as a YAML list |
Use routes: { name: http://host:port }, not a list of objects |
Proxy exits open /data/tls.key: permission denied |
key is 0600, proxy runs non-root |
chmod 644 data/tls.key |
Proxy logs no route for host (caller gets 502 No route configured for host) |
tunnel_domain missing or wrong |
Set it to the exact domain on the tunnel detail page; then restart the proxy (next row) |
| Edited config but nothing changed | proxy does not hot-reload config.yaml (only tls.cert_file) |
docker compose restart mcp-proxy — up -d alone won't recreate it on a file-content change |
tls handshake failed ... unknown certificate authority |
CA not registered/revoked on this tunnel | Re-upload data/ca.crt in the Console (Step 5) |
tls handshake failed ... bad certificate |
server cert SAN ≠ *.<tunnel-domain>, or expired |
Regenerate the server cert (Step 4) with the correct TUNNEL_DOMAIN |
IP validation failed: <ip> is not a private address |
upstream resolves outside RFC1918 (e.g. 127.0.0.1, public IP) |
Run the upstream as a Compose service on the proxy's network; or narrow upstream.allowed_ips deliberately (avoid 0.0.0.0/0 outside local testing) |
dial tcp ...: connect: connection refused for host.docker.internal |
rootless Docker can't reach the host netns | Run the MCP server as a Compose service instead of a host process |
HTTP 502, no request started in proxy log |
cloudflared hadn't finished registering, or rolling update | Wait for ×4 Registered tunnel connection and retry |
| Tunnel missing from agent + MCP Server picker | no active certificate, or wrong workspace | Register a CA cert (Step 5); open the session in the tunnel's workspace |
curl https://<proxy>:8080 fails wrong version number |
expected — listener is plaintext WS, TLS is inside the WS stream | Don't curl the proxy directly; verify via Managed Agent or Messages API |
docker compose logs cloudflared (token/edge reachability) and
docker compose logs mcp-proxy (config/cert/routing) are the two primary
diagnostics. Check the outbound connection first, then the inner TLS handshake,
then upstream routing. See
Troubleshooting
for additional cases.
Operational notes (mention briefly, don't run unprompted)
- Token rotation: Console → Rotate token invalidates the old token
immediately. Update
TUNNEL_TOKENin.envanddocker compose up -d cloudflared. - Cert renewal: the server cert is valid 90 days. Re-sign with the same CA
(the registered CA doesn't change) and replace
data/tls.crt; the proxy polls and reloads it, no restart needed. - Config changes always need
docker compose restart mcp-proxy.
Wrap up
Summarize: deployment dir, route(s) configured, tunnel domain, and the exact
URL Claude reaches the server at. Remind the user the token is a live secret in
$DIR/.env (chmod 600, gitignored) and that this is a research-preview,
local-testing setup — point them at
Deploy with Docker Compose /
Deploy with Helm
for a hardened or programmatic-access deployment.