Main issue: https://github.com/modelcontextprotocol/registry/issues/1323 Related: registry#1252 (load test / origin scaling), dns#26 (poc/registry-edge-cache) Repos affected: modelcontextprotocol/registry, modelcontextprotocol/dns

Scope: Cloudflare as the edge/CDN layer, Let's Encrypt (via cert-manager) retained as the origin certificate authority.


1. Problem statement

Load testing against ~19k server entries found the registry's GET /v0/servers endpoint breaks down around 300 RPS: goroutine count climbs to 8k–12k, GC-driven CPU usage rises sharply, and requests start timing out waiting on Postgres or on CPU availability. Below that threshold the API is fast and DB queries are not the bottleneck — the ceiling is concurrent in-process load on the Go service itself.

/v0/servers traffic is dominated by repeated or near-repeated queries — the default listing, common searches — which makes it a good fit for a caching layer rather than a compute-scaling fix: caching reduces both origin load and client-facing latency, whereas scaling out compute only addresses the former, at ongoing cost that scales with traffic instead of being absorbed at the edge.

2. Current architecture

Today, Cloudflare is authoritative for DNS but is not in the request path. There is exactly one TLS session, client straight through to the origin.

sequenceDiagram
    participant C as Client
    participant CF as Cloudflare (DNS only)
    participant LB as GCP passthrough NLB
    participant N as ingress-nginx
    participant P as registry pod

    C->>CF: DNS lookup registry.modelcontextprotocol.io
    CF-->>C: CNAME chain resolves to real GCP LB IP
    C->>LB: TCP + TLS handshake, packets forwarded not terminated
    LB->>N: same connection, unmodified
    N->>P: plain HTTP, TLS already terminated here
    Note over C,N: One TLS session, client all the way to nginx.<br/>Real client IP arrives intact, no forwarded-header logic needed.

Two details matter for what follows:

No Cache-Control headers are set anywhere in the application, and no firewall rule restricts who can reach the origin load balancer directly.

3. Proposed architecture

Introducing Cloudflare as a proxy changes the single-hop picture above into two independent hops, each with its own TLS session:

sequenceDiagram
    participant C as Client
    participant CF as Cloudflare edge
    participant LB as GCP passthrough NLB
    participant N as ingress-nginx
    participant P as registry pod

    C->>CF: TLS leg 1, Cloudflare's own edge cert
    alt cache HIT, GET /v0/servers within 60s
        CF-->>C: served directly from edge, origin untouched
    else cache MISS
        CF->>LB: TLS leg 2, new connection, existing Lets Encrypt cert
        LB->>N: forwarded unmodified, same as today
        N->>P: plain HTTP
        P-->>N: response
        N-->>CF: response
        CF-->>C: response, now cached for next request
    end

The sections below explain each decision behind this shape, and why.

3.1 DNS / proxying

Flip the registry CNAME's proxied flag from false to true (dns#26 also generalizes dns.ts to read this per-record instead of hardcoding it, since nothing was ever proxied before). The underlying prod.registry.modelcontextprotocol.io A record is deliberately left un-proxied.

Why leave prod.registry un-proxied: it preserves a direct path to origin for debugging and health checks without any extra tooling — you can still curl/browse it directly, bypassing the edge entirely, at zero implementation cost. The alternative (proxying everything) would mean every debugging session has to first reason about which of the two TLS legs, or which cache state, might be responsible for what's being observed — a real cost in operational clarity for no corresponding benefit.