Run behind a load balancer

Problem: you front mcpgw with NGINX, HAProxy, an AWS NLB, or a Cloudflare Tunnel. Without care, every request looks like it came from the load balancer’s IP and IP-based rate-limit buckets collapse onto a single client.

Solution: pick one of the two supported topologies below. Header-based forwarding (X-Forwarded-For) is intentionally not an mcpgw input — see Explanation: rate-limit identity for the security reasoning.

The simplest deployment. mcpgw terminates TLS itself; there is no proxy in the path; RemoteAddr is the real client IP.

listen: 0.0.0.0:7332
tls:
  cert_file: /etc/mcpgw/tls/fullchain.pem
  key_file: /etc/mcpgw/tls/privkey.pem

Renew certs out-of-band (cert-manager, certbot, ACM-side renewals + bind-mount swap, etc.). mcpgw re-reads cert/key on SIGHUP.

This is the recommended v1 topology because it eliminates an entire class of misconfiguration.

To require client certificates, add client_ca:

tls:
  cert_file: /etc/mcpgw/tls/fullchain.pem
  key_file: /etc/mcpgw/tls/privkey.pem
  client_ca: /etc/mcpgw/tls/client-ca.pem

Changing certificate file contents can be applied with SIGHUP. Changing TLS from off to on, or on to off, requires a restart.

Topology B — PROXY protocol front

Put HAProxy or NGINX in front in PROXY-protocol mode. They prepend a small header that contains the real client RemoteAddr ahead of the TCP stream; mcpgw consumes it.

NGINX example:

stream {
    upstream mcpgw {
        server 10.0.0.10:7332;
    }
    server {
        listen 443 ssl;
        ssl_certificate     /etc/nginx/tls/fullchain.pem;
        ssl_certificate_key /etc/nginx/tls/privkey.pem;
        proxy_pass mcpgw;
        proxy_protocol on;
    }
}

mcpgw config:

listen: 0.0.0.0:7332
proxy_protocol:
  enabled: true
  trusted_cidrs:
    - 10.0.0.0/24    # only accept PROXY headers from these source IPs

trusted_cidrs is mandatory when proxy_protocol.enabled: true. Without it, an attacker who reaches :7332 directly could spoof their own client IP. The CIDR list is the IP range your LB is reachable from.

Native PROXY-protocol parsing is on the roadmap; today the proxy_protocol block is parsed but rejected at startup with a clear error. For now, prefer Topology A.

What does NOT work

# DO NOT rely on this for rate-limit identity
proxy_set_header X-Forwarded-For $remote_addr;

mcpgw ignores X-Forwarded-For and Forwarded for rate-limit purposes, by design. The reason is that any client can send those headers; if mcpgw trusted them, an attacker could spoof their identity to evade per-IP throttles. A future release will add an explicit trusted_proxies allowlist for operators with mature LB configs.

Verifying RemoteAddr

# From a client outside your LB, hit the gateway and check what IP was logged
curl -s -X POST https://mcpgw.acme.com/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"ping"}'

# On the gateway:
tail -1 /var/log/mcpgw/audit.jsonl | jq '.client_ip'

The IP should be your real client, not the LB.

Pitfalls

  • HTTP-only LBs (Layer 7 ALBs that re-originate TCP) cannot pass PROXY headers. Use Topology A (terminate TLS at mcpgw) or upgrade to a Layer 4 LB.
  • Cloudflare Tunnel terminates TLS at Cloudflare and forwards over a tunnel. The tunnel hides the real client IP from the origin — Cloudflare puts it in CF-Connecting-IP, which mcpgw also ignores. Cloudflare’s WAF and rate-limit features sit at the edge; let them do the throttling and treat mcpgw rate-limits as a backstop with degraded identity.
  • Health checks consume one bucket entry. Configure your LB’s health check to hit /healthz (which is excluded from rate-limit), not /mcp.