Markdown for LLMs
HTTP, TLS, and the verified reverse proxy
The source Markdown for this article. Copy it into your assistant or download it as a text file.
# 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:<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.
## 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_<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.
## Routes (generated)
| Client request | Edge action | Backend |
|---|---|---|
| `POST /mcp` (and `/mcp/<slice>`) | 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:<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`.
## 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@<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.
## 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/).