HTTP, TLS, and the verified reverse proxy
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.
Applies to
Section titled “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 Step 3 |
Prerequisites
Section titled “Prerequisites”- One
law-mcp-server --httpper inventory instance, bound to the docker0 address (172.17.0.1in the shipped inventory, fieldbind), e.g. port8722for profileall. - The generated
nginx.conf(runtime.py render-nginx --output ...) anddocker-compose.edge.ymlinstalled under the edge directory (/opt/law-mcp/edgeinruntime.py bootstrap --apply). - Port 80/443 on the edge host reachable; traefik attached to the
external
traefik_traefik-networknetwork.
Architecture (verified)
Section titled “Architecture (verified)”client --HTTPS--> traefik (TLS, :443) --HTTP--> nginx:80 --HTTP--> 172.17.0.1:<port> (redirect :80->:443) (routes, limits, gzip) law-mcp-serverTLS 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.
- 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 - Confirm the generated route table matches the inventory: one
upstream law_mcp_<replicaSet>per replica set, onelocationperroute, one perhealthRoute,return 410perretiredRoutes,return 404for/. - Start the backend first, then the edge (
docker compose --project-name law-mcp -f <edge-dir>/docker-compose.edge.yml up -d— the exact commandbootstrap --applyruns). - Send a synthetic
initializethrough the edge and check the negotiated version and theX-Law-Call-Idecho.
Routes (generated)
Section titled “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)
Section titled “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 |
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) |
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
Section titled “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
Section titled “Expected result”A synthetic end-to-end check (created-example host/port):
curl -s https://mcp.example.invalid/healthzcurl -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
Section titled “Result check”- Backend liveness:
curl -sf http://172.17.0.1:8722/healthzfrom the edge host (real endpoint, synthetic address/port). Do not uselaw-mcp-server --healthcheckhere: it dials fixed127.0.0.1and ignores--host, so against a docker0-bound backend it would report a healthy backend as down.--healthcheckis valid only where127.0.0.1:<port>reaches the backend (same network namespace, loopback bind). - Edge log line per request (
log_format mcp ... up=$upstream_addrto stdout) shows which backend answered. /healthzfieldoverrunCallscounts computations abandoned at the call limit and still running; it must return to0.
Failures and diagnostics
Section titled “Failures and diagnostics”| Symptom | Layer | Cause | Fix |
|---|---|---|---|
401 + WWW-Authenticate | backend | token set, request without/wrong Authorization | see Authentication and 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
Section titled “Support boundaries”- The server has no TLS, no
client_max_body_sizeequivalent 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.1is 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
Section titled “Next step”- To choose the backend auth mode behind this edge: Authentication and access control.
- To narrow which tools each slice serves: Tool profiles and input policy.
Documentation for Arxo. Writings — blog.arxo.io.
Anonymous visit counts on stats.arxo.io, no cookies.