Serveur MCP sans état : ce qui a vraiment changé
La révision MCP 2026-07-28 supprime les sessions et le handshake initialize. Découvrez les effets sur reverse proxy, health checks, timeouts et auth.
Ce qu’est un serveur MCP sans état
Un serveur MCP sans état ne conserve aucun état propre au client entre les requêtes. Chaque requête contient la version du protocole, les capacités du client et les identifiants dont le serveur a besoin pour y répondre. N’importe quel processus, sur n’importe quelle machine, peut donc traiter n’importe quelle requête. MCP (Model Context Protocol, le format filaire utilisé par les agents pour accéder aux outils) a fait de ce fonctionnement une règle dans la révision 2026-07-28, qui a supprimé la négociation initialize et la session HTTP sous-jacente.
C’est tout l’intérêt opérationnel. Un serveur qui ne conserve rien pour chaque client peut être placé derrière un load balancer standard, sans affinité de session. Il peut être redémarré pendant un déploiement sans interrompre les clients. Il peut aussi fonctionner avec quatre processus identiques au lieu d’un seul. Un serveur orienté session ne permet rien de tout cela sans composants supplémentaires.
Le Model Context Protocol est un protocole sans état : toutes les informations nécessaires au traitement d’une requête sont contenues dans la requête elle-même. Le serveur traite chaque requête indépendamment. Aucun état ne doit être déduit des requêtes précédentes, même lorsqu’elles utilisent la même connexion ou le même flux.
Sans état ne signifie pas que votre serveur ne stocke rien. Votre base de données, votre queue et votre cache sont toujours présents. Cela signifie que le protocole ne transporte aucun état sur la connexion. Le serveur ne doit donc pas considérer une connexion, un processus ou un socket ouvert comme l’équivalent de « ce client, au milieu d’une conversation ».
Ce que la révision du 2026-07-28 a supprimé
2026-07-28 est la révision actuelle de la spécification en août 2026. Par rapport à 2025-11-25, elle supprime cinq éléments qui servaient à prendre en charge les sessions.
- La requête
initializeet la notificationnotifications/initialized. Il n’y a plus de handshake (SEP-2575). - L’en-tête
Mcp-Session-Idet la terminaison de session avec HTTPDELETE(SEP-2567). - Le flux HTTP
GETautonome sur lequel les serveurs envoyaient les notifications. Il est remplacé parsubscriptions/listen, un POST standard dont la réponse est un flux de longue durée. - La reprise des flux SSE (server-sent events). L’en-tête
Last-Event-IDet les identifiants par événement ont disparu. Un flux interrompu perd donc la requête en cours, et le client doit la renvoyer dans une nouvelle requête avec un nouvel identifiant de requête. ping,logging/setLeveletnotifications/roots/list_changed. Le niveau de journalisation est désormais un champ par requête,io.modelcontextprotocol/logLeveldans_meta.
Une méthode a été ajoutée, et chaque serveur doit l’implémenter. server/discover renvoie en un seul appel les versions de protocole prises en charge par le serveur, ses capacités et son identité. C’est ce qui se rapproche le plus d’un handshake, mais son appel reste facultatif pour les clients.
Pourquoi le transport par session était difficile à exploiter en production
Dans 2025-11-25 et les versions antérieures, un serveur pouvait générer un identifiant de session lors de son initialisation et le renvoyer dans l’en-tête Mcp-Session-Id de InitializeResult. Le client devait ensuite envoyer cet en-tête dans chaque requête suivante. La version de protocole négociée et les capacités du client étaient conservées dans la mémoire du serveur, associées à cet identifiant. Chacun de ces choix avait un coût opérationnel.
- Un redémarrage supprimait la table des sessions. La spécification exigeait que le serveur réponde
404 Not Foundà toute requête contenant un identifiant de session invalide. Elle exigeait également que le client recommence avec un nouveauInitializeRequest. Chaque déploiement provoquait donc une reconnexion de tous les clients connectés. - Une deuxième replica ne connaissait pas les sessions de la première. La montée en charge nécessitait un routage sticky sur le load balancer ou un session store partagé, consulté par chaque replica à chaque requête.
- La table des sessions occupait une mémoire qui augmentait avec le nombre de clients inactifs.
DELETEétait facultatif. Les clients qui se déconnectaient sans l’envoyer laissaient des entrées en mémoire. - Les résultats des listes pouvaient varier selon la connexion. La mise en cache devant le serveur était donc risquée.
La suppression des sessions élimine ces quatre problèmes en même temps. C’est le changement qu’il faut comprendre avant de modifier la configuration.
Ce que chaque requête transporte désormais
Chaque requête POST vers le endpoint MCP est autonome. La version du protocole et les capacités du client sont transmises dans le corps de la requête, sous _meta. Certains champs sont également recopiés dans des en-têtes HTTP afin qu’un intermédiaire puisse effectuer le routage sans analyser le JSON.
POST /mcp HTTP/1.1
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: get_weather
Authorization: Bearer <access token>
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_weather",
"arguments": {"location": "Seattle, WA"},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "ExampleClient", "version": "1.0.0"},
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}io.modelcontextprotocol/protocolVersion et io.modelcontextprotocol/clientCapabilities sont obligatoires dans chaque requête. clientInfo ne l’est pas, mais les clients devraient l’envoyer. Une requête à laquelle il manque un champ obligatoire est malformée. Le serveur doit donc la rejeter avec l’erreur JSON-RPC -32602 et le code HTTP 400 Bad Request.
L’en-tête Mcp-Method est obligatoire dans chaque requête. Mcp-Name est obligatoire avec tools/call, resources/read et prompts/get. Sa valeur doit correspondre à celle du corps de la requête. Un serveur qui traite le corps doit rejeter toute divergence avec 400 Bad Request et les codes d’erreur -32020, HeaderMismatch. Cette règle est nécessaire, car un load balancer qui effectue le routage à partir de l’en-tête et un serveur qui exécute la requête à partir du corps utilisent deux sources de vérité différentes. Si vous utilisez ces en-têtes pour le routage ou la limitation de débit, vérifiez d’abord MCP-Protocol-Version. Les versions précédentes ne validaient jamais l’en-tête par rapport au corps. Avec ces versions, la valeur de l’en-tête n’est donc pas fiable.
Un désaccord sur la version est désormais une erreur ordinaire propre à la requête, et non plus un échec du handshake. Un serveur qui n’implémente pas la version demandée répond avec 400 Bad Request, l’erreur -32022, UnsupportedProtocolVersion, et indique les versions qu’il prend en charge dans data.supported. Le client en sélectionne une dans cette liste, puis réessaie.
Où est passé l’état : tokens, curseurs, abonnements
L’état n’a pas disparu. Il se trouve désormais à des endroits que vous pouvez consulter et journaliser.
Les identifiants d’authentification sont transmis dans chaque requête. Il n’y a aucune session à laquelle associer une identité. Le token d’accès est donc transmis dans chaque appel HTTP et validé à chaque fois. Les détails figurent dans la section consacrée à l’authentification ci-dessous.
Les curseurs doivent contenir leur propre position. La pagination sur tools/list, resources/list, prompts/list et resources/templates/list utilise une chaîne de curseur opaque. Les clients ne doivent pas l’analyser ni la modifier. Sur un serveur à processus unique, il était courant de conserver l’offset en mémoire et de l’associer à la session. Sans session, le curseur doit suffire à permettre à n’importe quelle réplique de reprendre la liste. Encodez donc la position dans le curseur et signez-le, ou conservez-la dans un stockage partagé par toutes les répliques. Un curseur non valide doit renvoyer -32602. Signez-le, car un curseur opaque reste une entrée fournie par le client, que votre code décode et à laquelle il fait confiance.
Les abonnements sont associés à une requête, pas à une connexion. Un client qui veut recevoir des notifications de modification envoie subscriptions/listen avec un filtre indiquant les types souhaités : toolsListChanged, promptsListChanged, resourcesListChanged et resourceSubscriptions. Le serveur répond avec notifications/subscriptions/acknowledged et maintient ce flux de réponse ouvert. Si le flux est interrompu, le serveur ne conserve rien et le client renvoie subscriptions/listen pour le rétablir.
L’état applicatif réparti sur plusieurs appels devient un handle explicite. Lorsqu’un serveur doit réellement mémoriser une information entre plusieurs appels, la spécification prévoit un identifiant généré par le serveur et renvoyé comme argument d’outil ordinaire. Il apparaît dans le schéma de l’outil, peut être journalisé et n’est jamais implicite dans la connexion. Un serveur qui gère réellement des données par utilisateur, comme un serveur de messagerie MCP auto-hébergé, utilise ce modèle au lieu d’une session : l’identifiant de la boîte aux lettres ou du brouillon est un argument d’outil. N’importe quelle réplique peut ainsi traiter l’appel suivant.
Déploiement : reverse proxy, délais d’expiration et health checks
Le endpoint MCP est un chemin qui accepte POST. La plupart des requêtes sont courtes et renvoient une réponse JSON, ce que tous les proxies gèrent. L’exception concerne la réponse en streaming, pour laquelle les valeurs par défaut du proxy vous posent problème. C’est la différence principale lorsque vous passez d’une démonstration sur un laptop à un serveur MCP exécuté sur un VPS.
location /mcp {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
}proxy_buffering off est important, car nginx bufferise par défaut les réponses transmises par proxy. Les événements SSE restent ainsi en attente jusqu’à ce qu’un buffer soit plein ou que la réponse se termine. La spécification demande également aux serveurs d’envoyer X-Accel-Buffering: no dans les réponses SSE. nginx respecte cet en-tête. Un serveur correctement configuré indique donc de lui-même la bonne valeur au proxy. Définissez aussi la directive, car c’est la partie que vous contrôlez.
proxy_read_timeout vaut 60 secondes par défaut. Un flux subscriptions/listen qui reste silencieux plus longtemps est fermé par nginx, et non par votre serveur. Vos journaux indiquent alors un processus sain, tandis que votre client voit le flux interrompu. Augmentez cette valeur uniquement pour la location MCP, et non pour l’ensemble du serveur. Les serveurs sont également encouragés à envoyer une ligne de commentaire SSE, c’est-à-dire une ligne qui commence par deux-points, comme keep-alive pendant les périodes silencieuses. Cela empêche les intermédiaires d’expirer le flux.
Caddy nécessite moins de configuration. Il bufferise partiellement les réponses par défaut pour optimiser l’efficacité sur le réseau, puis envoie immédiatement les données lorsque la réponse contient Content-Type: text/event-stream. Le streaming fonctionne donc sans directive supplémentaire.
mcp.example.com {
reverse_proxy 127.0.0.1:8080 {
health_uri /healthz
health_interval 10s
}
}Vérifiez la cible de ce health check. Ne dirigez pas un contrôle actif vers l’endpoint MCP avec GET. Un serveur qui implémente uniquement cette révision répond 405 Method Not Allowed à GET et DELETE, tandis que la méthode de health check par défaut de Caddy est GET. Le proxy considérerait alors un backend parfaitement sain comme indisponible. Servez un chemin simple tel que /healthz pour le proxy, puis vérifiez le protocole séparément avec POST.
curl -sS https://mcp.example.com/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: server/discover' \
-d '{"jsonrpc":"2.0","id":"health-1","method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'Un 200 contenant une liste supportedVersions signifie que le processus fonctionne et utilise le protocole. Un 404 avec l’erreur JSON-RPC -32601 signifie que le processus fonctionne, mais qu’il ne fournit pas server/discover, que tout serveur 2026-07-28 doit implémenter. Un 400 avec -32022 signifie que votre vérificateur a demandé une version que cette build ne prend pas en charge. C’est précisément le problème à détecter après la mise à niveau d’une dépendance. La version open source de nginx ne propose pas de health checks actifs. Utilisez donc max_fails et fail_timeout passifs sur l’upstream, puis exécutez le contrôle du protocole depuis votre système de monitoring.
Un rolling restart ne vous fait désormais perdre que les requêtes en cours, et rien d’autre. Arrêtez d’accepter de nouvelles requêtes, laissez les POST ouverts se terminer, démarrez le nouveau processus, puis laissez les clients renvoyer celles qui ont échoué. La seule chose qui reste interrompue est tout flux subscriptions/listen ouvert, car ce flux est une connexion active vers un processus précis. La suppression de l’état a supprimé l’affinité de session. Elle n’a pas supprimé l’affinité de connexion d’un flux actuellement ouvert, et aucune règle de routage ne peut corriger cela. Un client peut faire la différence : un flux qui se termine avec le résultat subscriptions/listen vide s’est fermé proprement. Un flux qui se termine sans ce résultat a été interrompu, ce que le client peut interpréter comme une raison de se reconnecter.
La mise en cache devient possible pour la première fois. Les résultats des méthodes de liste contiennent désormais ttlMs et cacheScope, et cacheScope: "public" indique aux intermédiaires partagés qu’ils peuvent mettre la réponse en cache. Cette opération est sûre uniquement parce que les résultats des listes ne varient plus selon la connexion. C’est une conséquence directe de la suppression des sessions.
Pourquoi l’authentification change en l’absence de session
Avec une session, il était tentant de s’authentifier une fois à initialize, puis de considérer l’ID de session comme une preuve pour toutes les opérations suivantes. Utilisé ainsi, un ID de session est un bearer credential sans audience, sans expiration ni mécanisme de révocation, généré par votre propre serveur. La suppression des sessions supprime ce raccourci, et le mécanisme de remplacement est plus strict.
Un serveur MCP protégé agit comme un resource server OAuth 2.1. Chaque requête HTTP du client doit contenir Authorization: Bearer <access token>, et le serveur valide le token à chaque requête. Cette validation inclut l’audience : conformément à la RFC 8707 (Resource Indicators for OAuth 2.0), le serveur doit confirmer que le token a été émis spécifiquement pour lui. Il ne doit ni accepter ni transmettre des tokens destinés à un autre service. Les clients demandent la bonne audience en envoyant le paramètre resource avec l’URI canonique du serveur.
La découverte repose sur un challenge. Lorsqu’une requête arrive sans token utilisable, le serveur répond avec 401 Unauthorized.
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
scope="files:read"Le client lit resource_metadata, récupère ce document (RFC 9728, OAuth 2.0 Protected Resource Metadata, que les serveurs MCP doivent implémenter), identifie l’authorization server, puis exécute le flow. Un token valide auquel il manque des permissions reçoit 403 Forbidden avec error="insufficient_scope" et les scopes requis pour cette opération.
Cela entraîne deux conséquences pour l’exploitation. La validation du token a maintenant lieu à chaque requête, et non plus une fois par session. Un aller-retour réseau vers un endpoint d’introspection pour chaque appel augmentera donc la latence. Préférez des tokens que vous pouvez vérifier localement à partir d’une signature, d’une audience et d’une expiration, ou mettez en cache le résultat de la validation pendant une courte durée, en l’indexant par le token. En l’absence de session pour conserver une identité, les droits doivent être calculés à partir du token lors de chaque appel. Ce fonctionnement est plus conforme à la réalité que le modèle par session. Il s’inscrit aussi dans la pratique plus large qui consiste à garder les credentials hors du processus de l’agent, décrite dans garder les secrets hors d’un agent IA.
Ce qui est vrai pour cette révision, et ce qui ne l’est pas
Tout ce qui précède décrit la révision 2026-07-28. Cela ne décrit pas MCP pour toujours et cela ne décrit pas le serveur que vous avez déployé l’année dernière.
Les clients et les serveurs en 2025-11-25 et dans les révisions antérieures utilisent toujours le modèle de handshake. La spécification qualifie ces révisions de legacy et appelle modernes les révisions qui utilisent des métadonnées par requête. Un serveur qui prend uniquement en charge cette révision et qui reçoit un ancien client doit répondre 405 Method Not Allowed à GET ou DELETE sur l’endpoint MCP, ignorer tout en-tête Mcp-Session-Id sans en générer ni en renvoyer un, et ignorer Last-Event-ID, car les streams ne peuvent pas être repris. Un serveur compatible avec les deux générations peut servir les deux modèles sur un même endpoint : une requête contenant _meta est traitée sans état, tandis qu’une requête initialize sélectionne la sémantique de session précédente.
Vérifiez donc la chaîne de révision avant de considérer ces informations comme applicables. Si votre SDK envoie encore initialize, les sessions existent toujours dans votre déploiement et vous devez toujours gérer les problèmes liés aux sessions décrits plus haut. La même règle s’applique côté client : un processus agent exécuté sur votre propre machine, comme dans la configuration décrite dans exécuter un agent de programmation sur un VPS, est sans état dans ce sens uniquement si la bibliothèque qu’il utilise parle une révision moderne. Lisez la version négociée par votre runtime, puis consultez la révision correspondante de la spécification. Considérez ensuite cette page comme la description d’une révision nommée, et non comme celle du protocole en général.
FAQ
Un serveur MCP sans état signifie-t-il que je ne peux rien stocker ?
Non. « Sans état » décrit le protocole, pas votre application. Les bases de données, les files d’attente et les caches fonctionnent exactement comme avant. Ce qui change, c’est que l’état qui s’étend sur plusieurs appels doit être référencé par un identifiant explicite que le client transmet dans chaque requête, par exemple un handle généré par le serveur dans un argument d’outil. Vous ne devez pas déduire le contexte de la connexion : la spécification indique qu’un serveur ne doit pas s’appuyer sur les requêtes précédentes de la même connexion pour établir les capacités, la version du protocole ou l’identité du client, car chaque requête fournit ces informations dans _meta.
Dois-je toujours utiliser des sticky sessions sur mon load balancer ?
Pas pour les requêtes ordinaires. Avec la révision 2026-07-28, chaque POST contient sa propre version du protocole, ses capacités et ses informations d’authentification. N’importe quelle réplique peut donc répondre à n’importe quelle requête, et le round-robin convient. Le seul élément de longue durée restant est le flux de réponse subscriptions/listen, qui correspond à une seule connexion ouverte vers un seul processus. Il se termine lorsque ce processus s’arrête, puis le client renvoie subscriptions/listen pour le rétablir. Il s’agit de la durée de vie de la connexion, et non de l’affinité de session ; aucune règle de routage ne l’empêche.
Qu’est-il arrivé à Mcp-Session-Id et au flux HTTP GET ?
Les deux ont été supprimés dans la révision 2026-07-28, conformément aux SEP-2567 et SEP-2575. Un serveur qui implémente uniquement cette révision doit répondre 405 Method Not Allowed à GET et DELETE sur l’endpoint MCP, et doit ignorer un en-tête Mcp-Session-Id au lieu de le renvoyer. Les notifications de changement initiées par le serveur passent désormais par le flux de réponse d’une requête subscriptions/listen, et non par un flux GET autonome. Les serveurs qui doivent continuer à prendre en charge les anciens clients implémentent en parallèle le comportement de la révision précédente.
Comment effectuer un health check d’un serveur MCP sans handshake ?
Utilisez deux niveaux de contrôle. Configurez le check actif du proxy sur un chemin HTTP standard servi par votre application, car un GET vers l’endpoint MCP renvoie correctement 405 et ferait considérer un backend sain comme indisponible. Vérifiez ensuite le protocole lui-même en envoyant un POST à server/discover, que tout serveur 2026-07-28 doit implémenter, puis vérifiez que la réponse est HTTP 200 et qu’elle liste une version du protocole utilisée par vos clients. Un 404 avec l’erreur JSON-RPC -32601 signifie que le processus fonctionne, mais qu’il ne sert pas cette méthode. Un 400 avec -32022 signifie que la version demandée n’est pas prise en charge par cette build.