SSD Nodes Learn Hosting plans →
Guides Matt ConnorPar Matt Connor · Mis à jour le 2026-08-28

Serveur MCP sans état : ce qui change vraiment

La révision MCP 2026-07-28 supprime les sessions et le handshake initialize. Découvrez les effets concrets 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. Ainsi, n’importe quel processus sur n’importe quelle machine peut traiter n’importe quelle requête. MCP (Model Context Protocol, le format filaire utilisé par les agents pour accéder aux outils) a imposé cette règle dans la révision 2026-07-28, qui a supprimé la négociation initialize et la session HTTP sous-jacente. Tout ce qui suit concerne le serveur sur cette liaison. Si le fonctionnement côté agent est encore nouveau pour vous, un parcours progressif pour apprendre les agents IA décrit la boucle qui décide d’appeler un outil, avant d’aborder ces détails HTTP.

C’est tout l’intérêt opérationnel. Un serveur qui ne conserve rien par client peut être placé derrière un load balancer classique sans affinité de session, être redémarré pendant un déploiement sans interrompre les clients et 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.

Stateless ne signifie pas que votre serveur ne stocke rien. Votre base de données, votre file d’attente et votre cache sont toujours présents. Cela signifie que le protocole ne conserve aucun état sur la connexion. Le serveur ne doit donc pas considérer une connexion, un processus ou une socket ouverte comme représentant « ce client, au milieu d’un échange ». La distinction est plus facile à voir avec une application qui possède déjà ses données : serveur MCP en lecture seule d’openGym répond aux questions sur l’historique d’entraînement stocké dans la propre base de données de l’application. Aucun élément de ce stockage ne dépend de la connexion utilisée par une requête donnée.

Éléments supprimés par la révision 2026-07-28

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 initialize et la notification notifications/initialized. Il n’y a aucun handshake (SEP-2575).
  • L’en-tête Mcp-Session-Id et la terminaison de session avec HTTP DELETE (SEP-2567).
  • Le flux HTTP GET autonome sur lequel les serveurs envoyaient les notifications. Il est remplacé par subscriptions/listen, un POST ordinaire 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-ID et les identifiants par événement disparaissent. Un flux interrompu perd donc la requête en cours, et le client doit la réémettre sous la forme d’une nouvelle requête avec un nouvel identifiant de requête.
  • ping, logging/setLevel et notifications/roots/list_changed. Le niveau de journalisation est désormais un champ de chaque requête, io.modelcontextprotocol/logLevel dans _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 fonctionnalités et son identité. C’est ce qui se rapproche le plus d’un handshake parmi les éléments restants, et son appel est 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 la 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 en mémoire sur le serveur, indexées par cet identifiant. Chacun de ces choix avait un coût opérationnel.

  • Un redémarrage supprimait la table des sessions. La spécification imposait au serveur de répondre 404 Not Found à toute requête contenant un identifiant de session obsolète, et imposait au client de recommencer avec un nouveau InitializeRequest. Chaque déploiement devenait un événement de reconnexion pour tous les clients connectés.
  • Une deuxième réplique ne connaissait pas les sessions de la première. La montée en charge nécessitait un routage persistant au niveau du load balancer, ou un session store partagé que chaque réplique devait consulter à chaque requête.
  • La table des sessions occupait de la mémoire, et sa taille augmentait avec le nombre de clients inactifs. DELETE était facultatif, et les clients qui se déconnectaient sans l’envoyer laissaient des entrées résiduelles.
  • Les résultats des listes pouvaient varier selon la connexion, ce qui rendait dangereux l’utilisation d’un cache devant le serveur.

La suppression des sessions élimine ces quatre problèmes d’un coup. C’est ce qu’il faut comprendre avant de modifier la configuration.

Tout ce que chaque requête transporte désormais

Chaque requête POST vers le point de terminaison 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 pour 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 discordance 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 révisions précédentes ne validaient jamais l’en-tête par rapport au corps. Dans 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, limitée à la requête concernée, et non plus un échec de handshake. Un serveur qui n’implémente pas la version demandée répond avec 400 Bad Request et les erreurs -32022, UnsupportedProtocolVersion. Il indique les versions prises en charge dans data.supported. Le client en choisit une dans cette liste, puis réessaie.

Où se trouve l’état : jetons, curseurs, abonnements

L’état n’a pas disparu. Il se trouve désormais à des endroits que vous pouvez consulter et journaliser.

Les identifiants sont transmis dans chaque requête. Il n’existe aucune session à laquelle associer une identité. Le jeton d’accès est donc transmis avec chaque appel HTTP et vérifié à chaque fois. Les détails sont présentés 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, que les clients ne doivent ni analyser ni modifier. Sur un serveur à processus unique, il était courant de conserver l’offset en mémoire, associé à la session. En l’absence de session, le curseur doit suffire à n’importe quelle réplique pour 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 donné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 souhaite recevoir des notifications de changement 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 entre plusieurs appels devient un handle explicite. Lorsqu’un serveur doit réellement conserver une information entre plusieurs appels, la spécification préconise un identifiant généré par le serveur et renvoyé comme argument d’outil ordinaire. Il figure dans le schéma de l’outil, peut être journalisé et n’est jamais déduit de 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 plutôt qu’une session : l’identifiant de la boîte aux lettres ou du brouillon est un argument d’outil, de sorte que n’importe quelle réplique peut traiter l’appel suivant. De nombreux outils n’ont besoin d’aucun handle : un outil de recherche utilisant votre propre instance SearXNG reçoit une requête et renvoie les résultats, sans rien à reprendre lors de l’appel suivant et sans avoir à déterminer quelle réplique a répondu.

Déploiement : reverse proxy, délais d’attente et contrôles d’état

Le endpoint MCP est un chemin qui accepte les requêtes POST. La plupart des échanges consistent en une requête courte et une réponse JSON, ce que n’importe quel proxy sait gérer. L’exception concerne la réponse en streaming, pour laquelle les valeurs par défaut du proxy vous gênent. C’est le point qui change 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 met par défaut en mémoire tampon les réponses relayées. Les événements SSE restent alors en attente jusqu’à ce qu’un buffer soit rempli 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, et nginx respecte cet en-tête. Un serveur correctement configuré indique donc lui-même la bonne valeur au proxy. Définissez également 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 que le processus fonctionne, tandis que votre client constate que le flux a été interrompu. Augmentez cette valeur uniquement dans la location MCP, pas sur l’ensemble du serveur. Les serveurs sont également encouragés à envoyer une ligne de commentaire SSE (une ligne qui commence par deux-points) comme keep-alive pendant les périodes silencieuses. Cela empêche les intermédiaires de fermer le flux pour dépassement du délai.

Caddy nécessite moins de configuration. Pour optimiser l’utilisation du réseau, il met par défaut les réponses partielles en mémoire tampon, puis les transmet immédiatement 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
	}
}

Observez la cible de ce contrôle d’état. Ne ciblez pas activement le endpoint MCP avec GET, car un serveur qui implémente uniquement cette révision répond 405 Method Not Allowed à GET et DELETE, tandis que la méthode de contrôle utilisée par défaut par 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 une requête 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 parle 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 contrôleur a demandé une version que cette build ne prend pas en charge. C’est exactement ce que vous devez détecter après une mise à niveau d’une dépendance. nginx open source ne propose pas de contrôles d’état actifs. Utilisez donc max_fails et fail_timeout passifs sur l’upstream, et exécutez le contrôle du protocole depuis votre système de supervision.

Un redémarrage progressif vous fait désormais perdre uniquement les requêtes en cours, et rien d’autre. Retirez le processus du trafic, laissez les requêtes POST ouvertes se terminer, démarrez le nouveau processus, puis laissez les clients réémettre les requêtes qui ont échoué. La seule chose que vous interrompez encore est tout flux subscriptions/listen ouvert, car ce flux est une connexion active vers un processus précis. L’absence d’état supprime l’affinité de session. Elle ne supprime pas l’affinité de connexion d’un flux actuellement ouvert, et aucune règle de routage ne peut la supprimer. Un client peut faire la différence : un flux qui se termine par le résultat subscriptions/listen vide a été fermé proprement, tandis qu’un flux qui se termine sans ce résultat a été interrompu. Le client peut alors décider 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, 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 sur 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 identifiant d’authentification bearer sans audience, sans expiration ni mécanisme de révocation, émis par votre propre serveur. La suppression des sessions élimine ce raccourci et impose un remplacement 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 : le serveur doit confirmer que le token a été émis spécifiquement pour lui, conformément à la RFC 8707 (Resource Indicators for OAuth 2.0), et ne doit pas accepter ni transmettre des tokens destinés à autre chose. 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 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 avec trop peu de permissions reçoit 403 Forbidden avec error="insufficient_scope" et les scopes requis pour cette opération.

Cela a deux conséquences sur l’exploitation. La validation du token a désormais lieu à chaque requête, et non une seule fois par session. Un aller-retour réseau vers un endpoint d’introspection pour chaque appel augmente 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, avec le token comme clé. En l’absence de session qui conserve une identité, l’autorisation doit être calculée à partir du token pour chaque appel. Ce modèle est plus explicite que le modèle fondé sur les sessions. Il s’accorde aussi avec la pratique plus large qui consiste à conserver les credentials hors du processus de l’agent, décrite dans conserver les secrets hors d’un agent IA. Les scopes limitent uniquement ce qu’un token peut faire une fois la requête reçue par votre serveur. Sur la machine où l’agent s’exécute, les plugins du harness qui ajoutent des règles de permission pour les outils et des plafonds de budget déterminent quels appels sont effectués.

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, ni 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 encore 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, lorsqu’il communique avec un client plus ancien, 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 antérieure.

Vérifiez donc la chaîne de révision avant de vous fier à tout ce qui précède. Si votre SDK envoie encore initialize, les sessions restent réelles pour 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 d’agent exécuté sur votre propre machine, par exemple dans la configuration décrite dans exécuter un agent de codage sur un VPS, n’est sans état dans ce sens que si la bibliothèque qu’il utilise parle une révision moderne. Consultez la version négociée par votre runtime, puis la révision correspondante de la spécification, et considérez cette page comme la description d’une révision précise, et non 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 continuent de fonctionner exactement comme avant. En revanche, tout état qui s’étend sur plusieurs appels doit être référencé par un identifiant explicite transmis par le client dans chaque requête, par exemple un handle généré par le serveur dans l’argument d’un outil. Vous ne pouvez 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 sessions persistantes 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 identifiants 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 connexion ouverte unique vers un processus unique. 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 advenu de Mcp-Session-Id et du 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 d’en renvoyer un. Les notifications de changement initiées par le serveur circulent désormais sur le flux de réponse d’une requête subscriptions/listen, au lieu d’utiliser un flux GET autonome. Les serveurs qui doivent continuer à servir d’anciens clients implémentent le comportement de la révision précédente en parallèle de celui-ci.

Comment vérifier l’état d’un serveur MCP sans handshake ?

Utilisez deux niveaux de vérification. Configurez le contrôle 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 à tort un backend sain comme indisponible. Vérifiez ensuite le protocole lui-même en envoyant server/discover avec POST. Tout serveur 2026-07-28 doit l’implémenter. Vérifiez que la réponse est HTTP 200 et qu’elle contient 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 prend pas en charge cette méthode. Un 400 avec -32022 signifie que la version demandée n’est pas prise en charge par cette build.