TLS devant la passerelle
Le transport Streamable HTTP parle du HTTP nu, délibérément : la liste des
dépendances de la passerelle fait partie du produit, et une pile TLS gonflerait
l’arbre qu’un auditeur doit lire, pour un travail qu’un reverse proxy fait déjà
bien. Mais l’identité voyage dans l’en-tête Authorization: Bearer de chaque
requête : les jetons SSO ne doivent jamais traverser un réseau en clair.
Le motif : la passerelle écoute là où TLS se termine. Même hôte que le terminateur TLS, lié à la boucle locale, ou le même réseau de conteneurs privé, sans port publié. Les configurations nginx (1.27) et Caddy (2.11) ci-dessous ont été exécutées face à la passerelle et ont fait passer le flux MCP complet.
Le contrat
Section intitulée « Le contrat »- N’exposez jamais le port en clair.
--http 127.0.0.1:8080sur un hôte mutualisé ; en compose, aucune entréeports:sur la passerelle. - Transmettez
AuthorizationetMcp-Session-Idintacts. nginx et Caddy le font tous deux par défaut. - Les requêtes amont portent
Content-Length. L’analyseur de la passerelle est strict : l’encodage de transfert chunked est refusé (411). Gardez leproxy_request_bufferingde nginx à sa valeur par défaut (on). - Ne mettez pas les réponses en tampon. Le flux GET est en server-sent
events ;
proxy_buffering off(nginx),flush_interval -1(Caddy). - Délai de lecture plus long que l’outil le plus lent. Un
tools/callposté ne produit aucun octet tant que l’outil n’a pas répondu. Le flux SSE est sûr à tout réglage au-dessus de 15 s (intervalle de keep-alive) ; dimensionnez le délai pour les outils. - La liste d’
Originautorisées nomme l’origine publique. Les clients navigateur ont besoin de--allowed-origin https://mcp.example.com, c’est-à-dire l’origine TLS, pas celle de la boucle locale. Les clients MCP hors navigateur n’envoient pas d’Originet passent.
La passerelle plafonne les corps de requête à 4 Mio ; alignez le proxy dessus
(client_max_body_size 4m).
server { listen 443 ssl; server_name mcp.example.com;
ssl_certificate /etc/nginx/certs/mcp.example.com.pem; ssl_certificate_key /etc/nginx/certs/mcp.example.com.key; ssl_protocols TLSv1.2 TLSv1.3;
location = /mcp { proxy_pass http://127.0.0.1:8080;
# The gateway speaks HTTP/1.1 with Content-Length bodies only. proxy_http_version 1.1; proxy_set_header Connection "";
# SSE: deliver events as they are written, do not spool. proxy_buffering off;
# Must outlast the slowest tool call (contract point 5). proxy_read_timeout 300s;
# The gateway caps request bodies at 4 MiB; match it. client_max_body_size 4m; }
location / { return 404; }}location = /mcp est en correspondance exacte à dessein : la passerelle sert
un seul point d’entrée, et tout le reste n’a rien à faire à l’atteindre.
mcp.example.com { reverse_proxy 127.0.0.1:8080 { # SSE: flush each event immediately. flush_interval -1 }}Caddy obtient et renouvelle le certificat lui-même (ACME) ; ses valeurs par
défaut diffusent déjà le SSE en flux et ne fixent aucun délai de lecture. Pour
un laboratoire sans nom public, tls internal émet un certificat depuis
l’autorité locale de Caddy.
En compose
Section intitulée « En compose »La passerelle perd entièrement son entrée ports:. Le proxy est le seul
service qui écoute sur l’hôte :
services: gateway: image: my-gateway # extends ghcr.io/obsign/obsign-proxy command: ["obsign-proxy", "...", "--http", "0.0.0.0:8080", "--allowed-origin", "https://mcp.example.com", "--", "..."] # no ports: — reachable only on the compose network
tls: image: nginx:1.27-alpine volumes: - ./nginx.conf:/etc/nginx/nginx.conf:ro - ./certs:/etc/nginx/certs:ro ports: - "443:443"avec proxy_pass http://gateway:8080; dans la configuration nginx.
Là où la population de clients est faite de machines plutôt que de navigateurs, le proxy peut aussi exiger un certificat client. Cela authentifie le canal ; le jeton bearer reste l’identité à laquelle le journal attribue les actes. Le mTLS restreint qui peut présenter un jeton ; il n’en remplace pas un.
Ce que TLS n’est pas
Section intitulée « Ce que TLS n’est pas »TLS protège le jeton et le trafic en transit. Il ne fait pas partie de l’histoire de la preuve : ce qui rend le journal démontrable, c’est la chaîne de signatures dans le WAL et le scellé du ledger, dont aucun n’implique le transport.
Contrôler un déploiement
Section intitulée « Contrôler un déploiement »Quatre sondes, chacune arrimée à un point du contrat :
BASE=https://mcp.example.com/mcpTOKEN=... # a valid SSO tokenSID=$(curl -si "$BASE" -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \ | tr -d '\r' | awk 'tolower($1)=="mcp-session-id:" {print $2}')
# Token passes through, SSE unbuffered: a ": keep-alive" comment must# appear within ~15 s. Silence means the proxy is spooling the stream.curl -N --max-time 20 "$BASE" -H "Authorization: Bearer $TOKEN" \ -H "Mcp-Session-Id: $SID" -H 'Accept: text/event-stream'
# No token → the *gateway's* 401 (WWW-Authenticate: Bearer), proving the# proxy did not answer in its place.curl -si "$BASE" -H 'Content-Type: application/json' -d '{}'
# A hostile Origin → 403 from the gateway, through the proxy.curl -s -o /dev/null -w '%{http_code}\n' "$BASE" \ -H "Authorization: Bearer $TOKEN" -H 'Origin: https://evil.example' \ -H 'Content-Type: application/json' -d '{}'
# A chunked request → 200: the proxy buffered it into Content-Length.# A 411 means requests are being relayed chunked; restore request buffering.curl -s -o /dev/null -w '%{http_code}\n' "$BASE" \ -H "Authorization: Bearer $TOKEN" -H 'Transfer-Encoding: chunked' \ -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'Et le port en clair ne doit pas répondre depuis l’extérieur de l’hôte : que
curl http://mcp.example.com:8080/mcp expire est le résultat correct.