claude-plugins-official/plugins/mcp-tunnels/commands/create-docker-mcp-tunnel.md
Den Delimarsky 529d105a78
Rename mcp-gateway -> mcp-proxy throughout
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
2026-05-21 06:06:15 +00:00

16 KiB
Raw Blame History

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)
Bash
Read
Write
Edit
AskUserQuestion

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. openssl 1.1.1+ is required (the commands below use -addext, available in 1.1.1+).
  • Confirm the host has outbound access to api.anthropic.com:443 and 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):

  1. Sidebar → Manage → MCP tunnelsNew tunnel. Give it a name.
  2. Leave Set up programmatic access off — this quickstart uses manual credentials.
  3. Open the tunnel. From the Connection section copy two values:
    • Domain — looks like abcd1234.tunnel.anthropic.com
    • Token — click the eye icon, then copy

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 CertificatesAdd 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-server below as a Compose service hello-mcp and route subdomain echo.

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-proxyup -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_TOKEN in .env and docker 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.