Tutoriel
Déployer un serveur MCP distant en production : MCP sans état 2026 + équilibreur HTTP + JSON-RPC
Le sans-état du protocole ne vaut que si vous utilisez vraiment un équilibreur HTTP ordinaire. En production, le MCP distant concerne l’échelle des réplicas, les proxys qui ne doivent pas tamponner le SSE, et un drain qui n’égorge pas les longs flux — pas une nouvelle session.
Après 2026-07-28, le MCP distant ne cloue plus un client à un processus avec initialize plus Mcp-Session-Id. Chaque requête JSON-RPC porte version de protocole et capacités client dans _meta ; Streamable HTTP recopie les champs utiles en en-têtes HTTP pour que l’équilibreur et la passerelle routent, quotaient et mesurent sans parser le corps. Le dur en production n’est pas d’écrire tools/call. C’est d’étendre les réplicas, de choisir la politique d’équilibrage, d’empêcher nginx de tamponner la progression SSE, de vider subscriptions/listen lors d’un roll-out, et de savoir où vit l’état applicatif. Nous avons déjà dit pourquoi le protocole est sans état dans MCP sans état et qui peut appeler dans OAuth 2.1. Cet article n’ajoute que « comment le suspendre derrière un équilibreur HTTP ». Texte du transport : Streamable HTTP. Règles d’enveloppe : JSON-RPC 2.0. N’imposez pas cette topologie publique à un processus stdio local.
Le piège production : l’affinité de session ramène l’échelle à un point unique
Beaucoup entendent « sans état » et livrent un seul conteneur. Le gain n’apparaît que avec un équilibreur HTTP normal — round-robin, least-conn, pondération CPU — et non une affinité de session. Les révisions anciennes tenaient une session liée à la connexion : après la poignée de main, les tools/call suivants devaient frapper le processus qui tenait Mcp-Session-Id. Plus de réplicas impliquait un cookie collant ou un hachage IP sur l’ALB ou nginx ; une boîte morte tuait toute la session agent. 2026-07-28 a retiré les sessions de protocole. N’importe quel réplica doit terminer n’importe quel POST tout seul.
Cursor, Claude Desktop, le MCP distant OpenAI et les hôtes d’agents maison ne sérialisent pas poliment vers un seul point. La même seconde peut porter tools/list, tools/call et un subscriptions/listen de longue durée. Ces trois sauts n’ont pas à atterrir sur la même instance. L’affinité par IP client ramène l’échelle horizontale à un point unique et fige aussi quotas et canaris sur une boîte. Entrée protocole : qu’est-ce que MCP. Boucle agent : qu’est-ce qu’un agent IA.
Contre nos autres couches : le sans-état a retiré la session de protocole, pas les données métier. Brouillons de facture, paniers, appels d’outils multi-tours inachevés vont encore dans Redis ou une base et reviennent via un draftId dans arguments. Une map en mémoire de processus n’est pas un état de production. L’auth n’est pas cette couche non plus — le Bearer du MCP distant public vit dans les en-têtes HTTP ; voir l’article OAuth. Ici les trois couches sont déjà séparées ; on ne parle que topologie et contrat d’exploitation.
| Approche | Ce que voit l’équilibreur | Résultat production |
|---|---|---|
| Affinité cookie / IP + session en processus | Doit recoller le même client au même upstream | Difficile à étendre, roll-out cassant, point unique |
| Réplicas sans état + LB HTTP ordinaire | Tout réplica peut prendre tout POST JSON-RPC | Échelle horizontale, canaris, quotas par outil |
| Démarrage à froid serverless + un POST | Aucun processus long pour « se souvenir » d’une poignée | Bon pour un RPC court ; un long SSE a ses délais |
Architecture cible : client → LB HTTP → N réplicas sans état
Gardez la topologie mince : DNS public → terminaison TLS (ALB, NLB plus sidecar, nginx, Caddy, LB cloud) → un jeu de réplicas MCP à même image et même config. N’accrochez pas un « magasin de session MCP » devant pour rattraper le protocole. Les sondes de santé utilisent un GET /healthz séparé : processus vivant, dépendances joignables. Ne POSTez pas un corps vide sur /mcp, et n’utilisez pas un tools/list réel comme sonde — cela touche le registre et peut 401, évacuant le réplica à tort.
Chaque réplica doit finir un saut seul : valider Origin (rebinding DNS → 403), lire MCP-Protocol-Version / Mcp-Method / Mcp-Name, valider le Bearer si la ressource est protégée, parser l’enveloppe JSON-RPC, exécuter l’outil, renvoyer un objet JSON unique ou un SSE borné à la requête. La spécification veut un seul point MCP qui accepte POST, par exemple https://mcp.example.com/mcp. Les flux GET et les sessions de protocole ont disparu en 2026-07-28. Ne rouvrez pas GET /sse « pour les anciennes sondes ».
Ce que les réplicas partagent, ce sont les dépendances applicatives : base, stockage objet, API tierces, Redis optionnel. Ils ne partagent pas « quelles connexions MCP sont ouvertes ». Cloud Run, Cloud Functions, Knative à l’échelle par requête collent à ce modèle. Ce que vous réglez encore, c’est le délai d’inactivité des longs SSE, pas l’adhérence de session. Faites tourner le processus en utilisateur non privilégié derrière le proxy ; ne liez pas MCP à 0.0.0.0:80 sur l’internet public.
Comment JSON-RPC 2.0 traverse l’équilibreur
MCP encode les messages en JSON-RPC 2.0, UTF-8 obligatoire. Sur Streamable HTTP, chaque requête ou notification client est un nouveau POST HTTP ; les serveurs n’initient pas de requêtes JSON-RPC. L’équilibreur n’a pas besoin du sens métier de method — il transmet des octets. Le transport 2026 recopie method vers Mcp-Method et les noms d’outil / ressource / prompt vers Mcp-Name pour que les intermédiaires quotaient par outil, découpent par méthode et fassent un canari par version sans parser le corps.
Le corps reste la source de vérité. MCP-Protocol-Version en en-tête doit matcher octet pour octet params._meta.io.modelcontextprotocol/protocolVersion, sinon le serveur DOIT renvoyer 400 avec HeaderMismatch. Les clients doivent aussi envoyer Accept: application/json, text/event-stream. Un POST notification accepté rend 202 Accepted sans corps ; une requête rend un objet JSON ou un flux SSE. L’id JSON-RPC apparie requête et réponse de ce saut. Ce n’est pas une clé de session et il ne sert pas à « récupérer le contexte » entre réplicas.
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"jsonrpc": "2.0",
"id": 42,
"error": {
"code": -32600,
"message": "HeaderMismatch: MCP-Protocol-Version does not match params._meta"
}
}
Ci-dessous un tools/call de forme production : auth sur l’en-tête HTTP, enveloppe dans le corps, clés de routage visibles par la passerelle aussi sur l’en-tête. Fourrer un Access Token dans params ou _meta n’est pas le MCP distant 2026. La forme des arguments a encore besoin d’un Schema : MCP et JSON Schema.
POST /mcp HTTP/1.1
Host: mcp.example.com
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: searchInvoices
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
{
"jsonrpc": "2.0",
"id": 42,
"method": "tools/call",
"params": {
"name": "searchInvoices",
"arguments": {
"startDate": "2026-01-01",
"endDate": "2026-01-31",
"status": "paid"
},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {},
"io.modelcontextprotocol/clientInfo": {
"name": "jsonvue-demo",
"version": "1.0.0"
}
}
}
}
Streamable HTTP : tampon, délais et SSE
Les appels courts doivent renvoyer Content-Type: application/json. Un travail long peut renvoyer text/event-stream : d’abord les notifications/progress liées à cette requête, puis une réponse JSON-RPC finale qui ferme le flux. La plupart des incidents production sont dans le reverse proxy, pas le SDK MCP. Le proxy_buffering nginx par défaut accumule les événements de progression et les envoie d’un coup ; l’agent semble coincé. La spécification dit d’envoyer X-Accel-Buffering: no pour les intermédiaires. Voir nginx proxy_buffering.
Sur Streamable HTTP, le signal d’annulation est le client qui ferme ce SSE, pas un POST notifications/cancelled de suivi (c’est la liaison stdio). Le LB doit propager la coupure backend vers le client et la coupure client vers le réplica pour que le worker s’arrête. N’intercalez pas une passerelle qui « réessaie le POST » : les requêtes JSON-RPC ne sont pas idempotentes par défaut, et tools/call a pu déjà écrire la base.
subscriptions/listen est un autre long flux : la réponse reste ouverte et porte des changements comme tools/list_changed, pas la progression d’un appel. La spécification encourage des lignes de commentaire SSE périodiques (une ligne qui commence par deux-points) comme keep-alive pour que les intermédiaires inactifs ne raccrochent pas. Le SSE reprise via Last-Event-ID n’est pas pris en charge. Dimensionnez les délais idle/read du LB au-dessus de votre intervalle de keep-alive ; les 60 s par défaut de Cloudflare, ALB et nginx sont souvent trop courtes. L’extrait ci-dessous est un croquis de reverse proxy — pas une base de sécurité. TLS, quotas et WAF se configurent à part.
upstream mcp_replicas {
least_conn;
server 10.0.1.11:8080;
server 10.0.1.12:8080;
server 10.0.1.13:8080;
}
server {
listen 443 ssl;
server_name mcp.example.com;
location /healthz {
proxy_pass http://mcp_replicas;
proxy_connect_timeout 2s;
proxy_read_timeout 3s;
}
location /mcp {
proxy_pass http://mcp_replicas;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
Déploiement roulant, drain et subscriptions/listen
Les réplicas sans état simplifient le roll-out ; le SSE reste une requête HTTP en vol. L’ordre : retirer l’instance du groupe cible → arrêter le nouveau trafic /mcp → attendre la fin des réponses JSON et SSE ouverts, ou le délai de drain annoncé → puis SIGTERM au worker. Ne SIGKILL pas un processus qui pousse encore la progression. Ne le remettez qu’après des sondes vertes.
La nouvelle version doit encore accepter l’ancienne forme de requête. Il n’y a pas d’étape protocole qui « met à jour la poignée, puis bascule le trafic ». Un canari est un pourcentage de POST vers la nouvelle image. MCP-Protocol-Version peut servir de clé de routage : seuls les clients qui déclarent 2026-07-28 entrent dans le nouveau bassin ; les plus anciens restent sur un bassin de compatibilité. En désaccord de version, renvoyez 400 plus UnsupportedProtocolVersionError. Ne descendez pas en silence pour continuer à exécuter des outils.
subscriptions/listen tombera presque toujours pendant une fenêtre de publication. Les clients doivent rouvrir listen et ne pas supposer que les événements n’ont pas été perdus. Les serveurs ne doivent pas empiler les list_changed non livrés en mémoire de processus. Pour une livraison fiable, écrivez une file externe ; listen n’est que la bouche d’abonnement. MRTR (entrée multi-tours) est aussi des POST indépendants : gardez le résultat intermédiaire en stockage partagé pour que le saut suivant puisse atterrir sur un autre réplica.
Limites passerelle, auth, observabilité et JSONVue
Si la passerelle voit Mcp-Method et Mcp-Name, posez le QPS par outil, pas seulement par IP source. Les outils chers (écritures, paiements, SQL long) ont leur propre quota ; tools/list peut être plus souple. Sur l’internet public, validez Origin et jouez le serveur de ressources OAuth 2.1 — 401, Protected Resource Metadata, Bearer à chaque saut ; voir MCP OAuth 2.1. Auth sur le transport, limites sur la passerelle, Schema dans la couche métier. Ne mélangez pas les trois dans un seul middleware.
| Champ d’observabilité | D’où il vient | À quoi il sert |
|---|---|---|
| MCP-Protocol-Version / Mcp-Method / Mcp-Name | En-têtes de requête (alignés sur le _meta du corps) | Quotas par outil, canaris, tableaux de bord |
| id JSON-RPC | Enveloppe de ce saut | Relier les tentatives client aux journaux de réplica |
| Statut HTTP + JSON-RPC error.code | Couche transport vs méthode | Séparer 401 / HeaderMismatch / erreur métier |
Les journaux d’accès doivent garder au moins ces trois groupes. Les journaux de réplica ajoutent si jsonrpc vaut 2.0 et si l’outil a tourné avant un effet de bord. Désaccord en-tête/corps, Accept manquant et mauvais Origin doivent mourir à la passerelle ou au bord du réplica, pas dans la fonction outil. Gardez un échantillon de chaque échec : HeaderMismatch, 401, champs d’arguments manquants, SSE tamponné (le client n’a vu que le dernier morceau).
Vous pouvez finir le labo dans le navigateur :Formateur JSON pour voir si l’enveloppe parse ;Validateur JSON Schema pour params.arguments ;JSON Diff pour comparer deux réponses d’erreur ;décodeur JWT pour l’aud Bearer d’un déploiement public. Les données restent sur cette machine.
Pour aller plus loin : qu’est-ce que MCP, MCP sans état, OAuth 2.1, MCP et JSON Schema, A2A vs MCP.
FAQ
Faut-il encore des sessions collantes en production ?
Pas à la couche protocole pour 2026-07-28. Les sessions collantes vous font seulement croire qu’il reste une session. Si l’appli doit rester sur une région ou un shard, routez sur Mcp-Param-* ou une clé locataire dans arguments — routage applicatif — pas une affinité cookie vers un processus MCP.
Puis-je sonder l’équilibreur avec GET /mcp ?
Non. Le point MCP moderne n’accepte que POST ; les flux GET ont disparu. Des GET au hasard sur /mcp donnent 405 ou brouillent une ancienne logique de compatibilité. Sondez un /healthz séparé qui vérifie processus et dépendances et n’exécute pas d’outils.
Si le SSE tombe, le client doit-il reprendre avec Last-Event-ID ?
La spécification ne prend pas en charge un SSE reprenable. Les clients rouvrent la requête correspondante (listen → un nouveau subscriptions/listen ; un long outil → réessai seulement si le métier est idempotent). Des commentaires keep-alive plus un proxy non tamponné collent mieux au contrat qu’un cache d’event id maison.
Un MCP stdio local s’assoit-il aussi derrière un équilibreur ?
Non. stdio est un processus enfant lancé par le client ; les octets circulent sur stdin/stdout sans saut HTTP. Équilibrage, contrôles Origin, Bearer et X-Accel-Buffering appartiennent à Streamable HTTP / distant. Les mêmes outils peuvent exposer les deux transports ; la topologie production n’enveloppe que la face HTTP.
Résumé et suite
L’architecture production du MCP distant tient en une phrase : une requête JSON-RPC sans état traverse un équilibreur HTTP ordinaire et atterrit sur n’importe quel réplica identique ; les longs flux s’ouvrent dans la portée de la requête, pas dans celle de la connexion. Les en-têtes sont pour la passerelle, le corps est la vérité, l’état applicatif vit dans un stockage externe.
Livrez dans cet ordre : n’importe quel réplica termine un tools/call seul, puis coupez le tampon, levez les délais et ajoutez le drain, puis quotas par outil et canaris. Gardez une enveloppe succès, un HeaderMismatch et un 401 dans JSONVue sur cette machine. Sémantique protocole : MCP sans état. Qui peut appeler : OAuth 2.1.