# HTTP, TLS, and the verified reverse proxy ## Goal 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. ## Scope 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. ## Applies to | Branch | Coverage in this article | |---|---| | MCP (`law-mcp-server --http`) | full recipe: generated nginx + traefik, routes, headers, timeouts | | `law serve` | not covered — serve-side ingress is an operator sketch in [Deploy a private HTTP service](/operate/private-http-service/) Step 3 | ## Prerequisites - 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. ## Architecture (verified) ```text client --HTTPS--> traefik (TLS, :443) --HTTP--> nginx:80 --HTTP--> 172.17.0.1: (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. ## Steps 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_` 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 /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. ## Routes (generated) | Client request | Edge action | Backend | |---|---|---| | `POST /mcp` (and `/mcp/`) | rate limit, forward to backend `/mcp` | `POST /mcp` | | `GET /healthz` | forward to backend `/healthz` (no rate limit) | `GET /healthz` → 200 `{"status":"ok",...}` | | retired routes (`/mcp/kz`, `/healthz/kz`) | `return 410` | never reached | | anything else | `return 404` | never reached | ## Headers (generated + code) | Header | Direction | Behavior | |---|---|---| | `Host` | client → backend | passed through (`proxy_set_header Host $host`) | | `Origin` | client → backend | passed through; the server refuses any value not in `--allowed-origin` / `LAW_MCP_ALLOWED_ORIGINS` with 403 (empty list refuses all) | | `X-Forwarded-For` | edge → 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](/operate/authentication-access-control/) | | `X-Request-Id` | edge → backend | `$request_id`; accepted as the call id, else the server mints one | | `X-Law-Call-Id` | backend → client | call id on every response, including refusals | | `MCP-Protocol-Version` | client → backend | refused with 400 outside `initialize` when not in the supported list | | `Authorization: Bearer` | client → backend | passed through untouched; checked only by the backend (see [Authentication and access control](/operate/authentication-access-control/)) | | `WWW-Authenticate` | backend → client | `Bearer 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. | ## Timeouts, limits, streaming | Setting | Value | Owner | |---|---|---| | `proxy_read_timeout` | `120s` | generated nginx | | `LAW_MCP_CALL_TIMEOUT` | `110` (reference-setting in `law-mcp-private@.service`) | backend env | | Ordering rule | call timeout **below** proxy timeout, so an overrun fails with a JSON-RPC response (`-32001`, `CALL_TIMEOUT`), not a broken connection | code comment in `main.rs` | | Request body | `client_max_body_size 1m` (nginx) and `MAX_BODY` 1 MiB (server const) → 413 over | both | | Empty body / missing length | 411 | server | | Oversize headers | 431 | server | | Rate limit | `3r/s`, `burst=10 nodelay`, `limit_req_status 429` | generated nginx | | Response compression | gzip for `application/json` ≥ 1024 bytes, level 5 | generated nginx | | Retries | `proxy_next_upstream error timeout http_502 http_503` | generated nginx | | Streaming / SSE | **none**: `GET /mcp` → 405, `DELETE` → 405, JSON batch → 400, non-object → 400 | server (implementation-limit) | ## Expected result A synthetic end-to-end check (created-example host/port): ```bash 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`. ## Result check - 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:` 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`. ## Failures and diagnostics | Symptom | Layer | Cause | Fix | |---|---|---|---| | 401 + `WWW-Authenticate` | backend | token set, request without/wrong `Authorization` | see [Authentication and access control](/operate/authentication-access-control/) | | 403 `origin ... denied` | backend | `Origin` header not allow-listed | add the exact origin to `--allowed-origin`; never strip the header at the edge to silence the refusal | | 400 `protocol_version` | backend | unknown `MCP-Protocol-Version` outside `initialize` | send a supported version | | 405 on `GET /mcp` | backend | SSE/streaming attempted | use one-shot `POST` (implementation-limit) | | 411 / 413 | edge or backend | empty/oversize body | keep bodies ≤ 1 MiB | | 429 | edge | over 3 r/s + burst 10 | back off; tune `limit_req_zone` (operator-policy-example) | | 410 on `/mcp/kz` | edge | retired route from the inventory | use the current route | | `-32001 CALL_TIMEOUT` JSON-RPC error | backend | call exceeded 110 s | narrow the question; check `overrunCalls` | | Broken connection at ~120 s | edge | proxy timeout hit (call limit misconfigured above it) | restore 110 < 120 ordering | | `healthcheck: unexpected /healthz response` | prober | backend down or wrong port | check `systemctl status law-mcp@` | 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. ## Support boundaries - 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. ## Next step - To choose the backend auth mode behind this edge: [Authentication and access control](/operate/authentication-access-control/). - To narrow which tools each slice serves: [Tool profiles and input policy](/operate/tool-profiles-input-policy/).