Skip to content
docs
Arxo ↗

HTTP, TLS, and the verified reverse proxy

For LLMs14 sections

Put exactly one verified reverse-proxy stack in front of law-mcp-server --http: TLS termination, route table, forwarded headers, timeouts, body limits, rate limits, and backend access — so that every byte the client sees can be traced to either generated configuration or server code.

Components: law-mcp-server --http (law-mcp/), edge nginx:1.27-alpine with configuration generated by arxo-ops/deploy/mcp/runtime.py (render_nginx) from the inventory arxo-ops/services/mcp.json, TLS and HTTP→HTTPS redirect by traefik (arxo-ops/deploy/mcp/docker-compose.edge.yml, letsencrypt resolver). MCP protocol versions supported by the server: 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05 (negotiated on initialize; unknown defaults to 2025-11-25). Scenario: the public host mcp.arxo.io shape from the shipped inventory. Host names, ports, and tokens below are synthetic; directives and status codes are real.

BranchCoverage in this article
MCP (law-mcp-server --http)full recipe: generated nginx + traefik, routes, headers, timeouts
law servenot covered — serve-side ingress is an operator sketch in Deploy a private HTTP service Step 3
  • One law-mcp-server --http per inventory instance, bound to the docker0 address (172.17.0.1 in the shipped inventory, field bind), e.g. port 8722 for profile all.
  • The generated nginx.conf (runtime.py render-nginx --output ...) and docker-compose.edge.yml installed under the edge directory (/opt/law-mcp/edge in runtime.py bootstrap --apply).
  • Port 80/443 on the edge host reachable; traefik attached to the external traefik_traefik-network network.
Output
client --HTTPS--> traefik (TLS, :443) --HTTP--> nginx:80 --HTTP--> 172.17.0.1:<port>
(redirect :80->:443) (routes, limits, gzip) law-mcp-server

TLS terminates at traefik (label traefik.http.routers.law-mcp.tls.certresolver=letsencrypt); nginx listens on port 80 only (listen 80; server_name _;). The backend speaks plain HTTP on a non-routable-toward-clients address; clients never reach it directly. This paragraph describes the shipped reference-setting, not a code requirement: the server itself has no TLS flag.

  1. Render the edge configuration from the inventory (real command, synthetic output path): python3 arxo-ops/deploy/mcp/runtime.py render-nginx --output /tmp/created-example-nginx.conf
  2. Confirm the generated route table matches the inventory: one upstream law_mcp_<replicaSet> per replica set, one location per route, one per healthRoute, return 410 per retiredRoutes, return 404 for /.
  3. Start the backend first, then the edge (docker compose --project-name law-mcp -f <edge-dir>/docker-compose.edge.yml up -d — the exact command bootstrap --apply runs).
  4. Send a synthetic initialize through the edge and check the negotiated version and the X-Law-Call-Id echo.
Client requestEdge actionBackend
POST /mcp (and /mcp/<slice>)rate limit, forward to backend /mcpPOST /mcp
GET /healthzforward to backend /healthz (no rate limit)GET /healthz → 200 {"status":"ok",...}
retired routes (/mcp/kz, /healthz/kz)return 410never reached
anything elsereturn 404never reached
HeaderDirectionBehavior
Hostclient → backendpassed through (proxy_set_header Host $host)
Originclient → backendpassed through; the server refuses any value not in --allowed-origin / LAW_MCP_ALLOWED_ORIGINS with 403 (empty list refuses all)
X-Forwarded-Foredge → backend$proxy_add_x_forwarded_for; the server journals the first element as the client (MCP_CLIENT) — trusted-by-layout, see Authentication and access control
X-Request-Idedge → backend$request_id; accepted as the call id, else the server mints one
X-Law-Call-Idbackend → clientcall id on every response, including refusals
MCP-Protocol-Versionclient → backendrefused with 400 outside initialize when not in the supported list
Authorization: Bearerclient → backendpassed through untouched; checked only by the backend (see Authentication and access control)
WWW-Authenticatebackend → clientBearer resource="law-mcp" on 401
CORS (Access-Control-*)—not emitted anywhere: neither the server nor the generated nginx config sets CORS headers (verified absence). Browser clients need an operator-policy-example CORS layer, which this stack does not provide.
SettingValueOwner
proxy_read_timeout120sgenerated nginx
LAW_MCP_CALL_TIMEOUT110 (reference-setting in law-mcp-private@.service)backend env
Ordering rulecall timeout below proxy timeout, so an overrun fails with a JSON-RPC response (-32001, CALL_TIMEOUT), not a broken connectioncode comment in main.rs
Request bodyclient_max_body_size 1m (nginx) and MAX_BODY 1 MiB (server const) → 413 overboth
Empty body / missing length411server
Oversize headers431server
Rate limit3r/s, burst=10 nodelay, limit_req_status 429generated nginx
Response compressiongzip for application/json ≥ 1024 bytes, level 5generated nginx
Retriesproxy_next_upstream error timeout http_502 http_503generated nginx
Streaming / SSEnone: GET /mcp → 405, DELETE → 405, JSON batch → 400, non-object → 400server (implementation-limit)

A synthetic end-to-end check (created-example host/port):

Terminal
curl -s https://mcp.example.invalid/healthz
curl -s https://mcp.example.invalid/mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25"}}'

The first returns HTTP 200 with "status":"ok"; the second returns HTTP 200 whose result carries protocolVersion: "2025-11-25". Both responses carry X-Law-Call-Id.

  • Backend liveness: curl -sf http://172.17.0.1:8722/healthz from the edge host (real endpoint, synthetic address/port). Do not use law-mcp-server --healthcheck here: it dials fixed 127.0.0.1 and ignores --host, so against a docker0-bound backend it would report a healthy backend as down. --healthcheck is valid only where 127.0.0.1:<port> reaches the backend (same network namespace, loopback bind).
  • Edge log line per request (log_format mcp ... up=$upstream_addr to stdout) shows which backend answered.
  • /healthz field overrunCalls counts computations abandoned at the call limit and still running; it must return to 0.
SymptomLayerCauseFix
401 + WWW-Authenticatebackendtoken set, request without/wrong Authorizationsee Authentication and access control
403 origin ... deniedbackendOrigin header not allow-listedadd the exact origin to --allowed-origin; never strip the header at the edge to silence the refusal
400 protocol_versionbackendunknown MCP-Protocol-Version outside initializesend a supported version
405 on GET /mcpbackendSSE/streaming attempteduse one-shot POST (implementation-limit)
411 / 413edge or backendempty/oversize bodykeep bodies ≤ 1 MiB
429edgeover 3 r/s + burst 10back off; tune limit_req_zone (operator-policy-example)
410 on /mcp/kzedgeretired route from the inventoryuse the current route
-32001 CALL_TIMEOUT JSON-RPC errorbackendcall exceeded 110 snarrow the question; check overrunCalls
Broken connection at ~120 sedgeproxy timeout hit (call limit misconfigured above it)restore 110 < 120 ordering
healthcheck: unexpected /healthz responseproberbackend down or wrong portcheck systemctl status law-mcp@<port>

Why the 403 fix is “allow-list the origin”, never “strip the header”: the backend’s check keys on the header’s presence and value, so deleting Origin at the edge turns a denied origin into an absent one instead of fixing the trust decision — and client type cannot be reliably inferred from User-Agent or a self-declaration. If the gateway is meant to own origin policy, that is a different, separately verified architecture: validate the received value against the allow-list before any normalization, refuse anything not listed, and make the backend unreachable except through the gateway.

  • The server has no TLS, no client_max_body_size equivalent beyond its 1 MiB constant, and no rate limiting: all three live at the edge. Exposing the backend port directly to clients is outside the verified stack.
  • CORS is not supported by this stack (verified absence above).
  • HTTP/2, HTTP/3, and backend keep-alive pooling are not part of the generated configuration; proxy_http_version 1.1 is fixed.
  • Any proxy other than this generated nginx + traefik pair (haproxy, envoy, cloud LB) is an operator-policy-example: keep the header table and the 110 < 120 ordering, and re-verify each row.

Documentation for Arxo. Writings — blog.arxo.io.

Anonymous visit counts on stats.arxo.io, no cookies.