MCP est devenu stateless : ce que la révision 2026-07-28 a retiré de notre serveur

Si vous faites tourner un serveur MCP, vous avez une session quelque part. Probablement une table, peut-être une map en mémoire. Un client se connecte, envoie initialize, récupère un Mcp-Session-Id, et trimballe ce header sur chaque requête ensuite. Vous stockez la ligne. Vous la faites expirer au bout d'un moment. Vous vous assurez qu'une requête atterrit sur l'instance qui la possède, ou vous partagez l'état entre instances.

La révision 2026-07-28 a supprimé tout ça. Pas déprécié : retiré du cœur du protocole. Le handshake a disparu, le header de session a disparu, et chaque requête porte désormais sa propre version de protocole et son identité de client. Comme le formule le post de release, "any request can now land on any server instance behind a plain round-robin load balancer without needing shared storage" — n'importe quelle requête peut désormais atterrir sur n'importe quelle instance derrière un simple load balancer round-robin, sans stockage partagé.

Le serveur MCP d'Archyl sert désormais les clients qui parlent la nouvelle révision. Ce post, c'est ce que ça a demandé, ce qu'on a mesuré après, la seule chose qu'on a eue fausse au premier passage, et ce qu'on n'a pas fait. Si vous maintenez un serveur MCP, les parties intéressantes sont probablement la décision de conception au milieu, le bug que l'audit du nouveau transport a fait remonter dans l'ancien, et la checklist à la fin pour aller regarder le vôtre.

Ce que la révision a réellement retiré

Directement du changelog, les parties qui touchent une implémentation serveur :

  • Les sessions au niveau protocole et le header Mcp-Session-Id sont retirés du transport Streamable HTTP. Les endpoints de listing ne varient plus par connexion.
  • Le handshake initialize / notifications/initialized est retiré. Chaque requête porte sa version de protocole et ses capabilities client dans _meta, et sur Streamable HTTP cette même version voyage dans le header MCP-Protocol-Version.
  • server/discover est nouveau et obligatoire. Les serveurs DOIVENT l'implémenter, pour annoncer les versions de protocole supportées, les capabilities et l'identité. Les clients PEUVENT l'appeler avant toute autre chose ; ils sont aussi libres d'envoyer une requête et de gérer une erreur de version.
  • ping, logging/setLevel et notifications/roots/list_changed sont retirés.
  • Les incompatibilités de version renvoient UnsupportedProtocolVersionError, avec la liste des versions que le serveur supporte pour que le client puisse réessayer.

Il y a plus que ça dedans (Multi Round-Trip Requests, subscriptions/listen, résultats de listing cachables, un bloc de codes d'erreur renumérotés, durcissement de l'autorisation), et je reviendrai sur lesquelles on a faites et lesquelles on a sautées. Les cinq ci-dessus sont celles qui changent la forme d'un serveur plutôt que ses fonctionnalités.

Un point sur lequel il vaut la peine d'être précis, parce qu'il change la décision : ce n'est plus une release candidate. La release candidate a été gelée le 21 mai 2026 et a ouvert une fenêtre de validation de dix semaines pour les mainteneurs de SDK et les implémenteurs de clients. Cette fenêtre s'est refermée le 28 juillet 2026 quand la spécification est sortie, et la page de versioning appelle désormais 2026-07-28 "the current protocol version" — la version actuelle du protocole. Les quatre SDK Tier 1 (TypeScript, Python, Go, C#) la parlent depuis le jour de la release, avec Rust en bêta. Si vous attendiez que la RC se stabilise, c'est fait.

Ce que ça a voulu dire pour un serveur avec 181 tools

Le serveur MCP d'Archyl expose 181 tools sur le modèle C4 : projets, systèmes, containers, components, relationships, ADR, docs, contracts, conformance, drift, DORA, ownership. Avant ce changement, les 181 étaient tous derrière une session.

Concrètement, dans notre backend :

  • Chaque connexion créait une ligne dans une table mcp_sessions, avec une expiration à 24 heures et une goroutine de fond qui balayait les lignes obsolètes et expirées.
  • Les canaux de réponse SSE vivaient dans une map[string]chan *JSONRPCMessage sur le struct du serveur, indexée par session ID, ce qui épinglait une connexion au process qui l'avait ouverte. Cette map a depuis déménagé, et la raison s'est avérée être un bug plutôt qu'une préférence. J'y reviens plus bas.
  • Quatre handlers (tools/list, tools/call, resources/list, resources/read) commençaient par les trois mêmes lignes :
if !session.Initialized {
	return s.errorResponse(msg.ID, ErrCodeInvalidRequest, "Session not initialized", nil)
}

Ce guard est celui qui est intéressant. Il pose une question que le nouveau protocole a rendue impossible à répondre : est-ce que cet appelant a terminé le handshake ? Il n'y a plus de handshake à terminer.

La décision qui a gardé le changement petit

Le mouvement tentant, c'est d'apprendre la statelessness à ces quatre handlers. Ajouter une seconde condition, ou un session.Stateless || devant chaque vérification, ou remonter le tout dans un middleware.

On n'a rien fait de tout ça. Les guards sont intacts. À la place, une requête qui déclare 2026-07-28 obtient une session en mémoire construite pour cette seule requête, qui satisfait le guard par construction :

func (s *Server) NewStatelessSession(userID, organizationID uuid.UUID, protocolVersion string) *Session {
	now := time.Now()
	return &Session{
		Session: &mcpsession.Session{
			ID:              "",
			UserID:          userID,
			OrganizationID:  organizationID,
			Initialized:     true,
			ProtocolVersion: protocolVersion,
			Transport:       "streamable",
			LastAccessedAt:  now,
			CreatedAt:       now,
		},
		Stateless: true,
	}
}

Rien n'est persisté. Aucun ID n'est alloué. Aucun canal SSE n'est enregistré. Initialized: true n'est ni un mensonge ni un contournement : sous cette révision, la requête est réellement initialisée, parce que le protocole porte sa propre version et que le handshake qu'elle aurait sinon terminé n'existe plus.

Pourquoi ce cadrage compte plus qu'il n'y paraît : ces quatre guards sont sur un chemin adjacent à l'autorisation. Chacun d'eux est la différence entre un appel de tool qui s'exécute et un appel refusé. Éditer quatre points d'appel qui répondent tous à une question de forme sécuritaire, c'est quatre occasions d'affaiblir une vérification, réparties sur un diff qu'un relecteur doit tenir en tête d'un seul coup. Construire l'objet que les guards attendent déjà, c'est une nouvelle fonction, et chaque vérification existante garde son sens exact.

Ça échoue aussi dans une direction sûre. Si notre détection de version se trompe et qu'une requête stateless est lue comme une requête legacy, la conséquence est qu'une ligne de session est créée pour elle. Rien ne passe qui ne serait pas passé avant. La conception inverse, relâcher les guards et les conditionner à une chaîne de version, échoue dans l'autre sens.

La statelessness ne nous a rien coûté côté tenancy non plus, parce que l'identité n'est jamais venue de la ligne de session au départ. La session stateless porte l'utilisateur et l'organisation résolus depuis la clé d'API ou le token OAuth présenté sur cette requête, les scopes sont redérivés à chaque appel donc révoquer une clé prend effet immédiatement, et tools/call refuse toujours une session sans tenant lié. Il y a maintenant une chose de moins à voler : plus de session ID stocké qui pourrait être rejoué. Sur le chemin legacy, on a gardé la vérification correspondante, pour qu'un session ID ne puisse pas permettre à une credential d'agir sous l'identité stockée dans la session de quelqu'un d'autre.

Le routage, en un switch

Toute la décision vit dans le handler HTTP, avant même que le body JSON-RPC soit parsé :

switch {
case mcp.IsModernProtocolVersion(requestedVersion):
	// Stateless: the request describes itself, so nothing is looked up,
	// nothing is written, and no Mcp-Session-Id comes back.
	session = h.mcpServer.NewStatelessSession(auth.UserID, auth.OrganizationID, requestedVersion)

case sessionID != "":
	// Handshake-based client with a session: look it up, and check it
	// belongs to this credential.

default:
	// Legacy client that has not handshaken yet: mint a session as before.
}

Deux détails là-dedans qu'on rate facilement :

IsModernProtocolVersion est une comparaison de chaînes contre "2026-07-28". Les révisions sont en YYYY-MM-DD, donc l'ordre lexical est l'ordre chronologique, et une révision future atterrit du côté stateless par défaut plutôt que de retomber sur le handshake. Elle ne va jusque-là que si on la supporte : une version non reconnue est rejetée avant le switch, avec la liste des versions supportées dans les données de l'erreur pour que le client puisse réessayer.

Et le header de réponse :

if !session.Stateless {
	c.Set("Mcp-Session-Id", session.ID)
}

Une session stateless n'a pas d'ID. Renvoyer un Mcp-Session-Id vide dirait à un client de réutiliser quelque chose qui n'existe pas, ce qui est un bug pire que de ne pas l'envoyer, et le genre de chose qui n'apparaît que face à un client que vous n'avez pas écrit.

Le reste du changelog, lu correctement

Le switch de version est la décision intéressante. Le reste de la révision est une liste de petites exigences faciles à rater et peu coûteuses à vérifier, alors on a repris le changelog ligne par ligne. Quatre d'entre elles sont arrivées à ce passage.

resultType sur chaque résultat. La révision rend le champ obligatoire : "complete" pour une réponse terminée, "input_required" pour le résultat intermédiaire du motif multi round-trip. On dit aux clients de traiter son absence chez un serveur plus ancien comme "complete", mais un client qui lit la révision finale le cherche. Les nôtres le portent maintenant, depuis un struct Result embarqué dans chaque type de résultat plutôt que chaque type se souvenant du champ de son côté.

ttlMs et cacheScope sur les résultats de listing. Obligatoires sur tools/list, prompts/list, resources/list, resources/read et resources/templates/list, via une nouvelle interface CacheableResult. On héberge trois de ces cinq, et ils renvoient 60000 et private. Soixante secondes, c'est une indication et pas un contrat : assez long pour empêcher un agent de relister 181 tools à chaque tour, assez court pour qu'un tool enregistré en cours de session apparaisse vite. private est une décision, pas un défaut qu'on a accepté. Chaque résultat qu'on renvoie est limité à l'organisation de l'appelant, donc aucun intermédiaire partagé ne peut en cacher un et le remettre à un autre tenant.

DELETE /mcp depuis un client qui déclare 2026-07-28. DELETE terminait une session au niveau protocole, et il n'y a plus de sessions au niveau protocole. La spec dit de répondre 405, donc c'est ce qu'un client moderne obtient. Un client basé sur le handshake garde l'ancien comportement.

Une méthode non implémentée renvoie désormais un HTTP 404 portant un JSON-RPC -32601. Le code de statut seul est ambigu : un serveur legacy HTTP+SSE qui n'héberge pas du tout l'endpoint moderne répond lui aussi 404. C'est le body JSON-RPC qui distingue les deux, et la spec est explicite : un client s'en sert pour décider s'il retombe sur initialize ou s'il réessaie.

Et une qu'on a eue fausse au premier passage

Notre erreur de version non supportée renvoyait -32600, le "invalid request" générique de JSON-RPC. C'était défendable jusqu'à exactement cette révision, qui définit une politique d'allocation des codes d'erreur découpant la plage d'erreurs serveur de JSON-RPC : de -32000 à -32019 reste défini par l'implémentation, de -32020 à -32099 appartient à la spécification. Les codes introduits pendant le draft ont été renumérotés dans ce bloc. HeaderMismatch est passé de -32001-32020, MissingRequiredClientCapability de -32003-32021, et UnsupportedProtocolVersion de -32004-32022.

Un client écrit contre la révision finale cherche -32022. Il n'aurait pas reconnu ce qu'on envoyait, et le mode d'échec est exactement celui que toute cette révision est conçue pour éviter : le client ne peut pas distinguer « mauvaise version, voici celles que je parle » de « ta requête était malformée », donc il n'a rien avec quoi réessayer.

Rien n'a attrapé ça, à part relire le changelog une seconde fois, ce qui est l'argument même de ce post retourné contre nous. La renumérotation est le point 12 des changements mineurs, après les entrées sur les clés _meta d'OpenTelemetry et les mots-clés JSON Schema. C'est le genre de ligne qu'on survole.

Un renommage ne nous a rien coûté. Resource-not-found est passé de -32002 à -32602, pour s'aligner sur le "invalid params" de JSON-RPC, et resources/read répondait déjà -32602 pour une URI inconnue.

Ce qu'on a mesuré

Tout ça a été mesuré contre un conteneur qui tourne sur ce build, avec une vraie clé d'API, pour qu'on puisse compter les lignes dans Postgres directement.

Test Résultat
tools/list avec MCP-Protocol-Version: 2026-07-28, sans handshake 181 tools
Mcp-Session-Id renvoyé sur cette réponse aucun
resultType sur tools/list et sur server/discover complete
ttlMs / cacheScope sur tools/list 60000 / private
server/discover ["2026-07-28", "2025-03-26"]
Version non supportée déclarée -32022, liste des supportées dans les données de l'erreur
DELETE /mcp depuis un client qui déclare 2026-07-28 405
Méthode inconnue 404 portant -32601
Handshake initialize legacy fonctionne toujours
tools/list legacy avec un session id 181 tools
Lignes mcp_sessions créées par 10 requêtes stateless 0
Lignes mcp_sessions créées par 3 requêtes legacy 3

La dernière paire est celle qu'il faut regarder. Dix requêtes, aucune ligne. Les trois requêtes legacy sont chacune arrivées sans session ID, donc chacune en a créé une ; un client bien élevé, basé sur le handshake et qui réutilise son ID, obtient une ligne pour toute la durée de sa session, pas une par appel. Ce qui compte, c'est le zéro : sur le chemin stateless il n'y a rien à écrire, rien à faire expirer, et rien que la goroutine de nettoyage puisse trouver.

La requête qui a produit la première ligne de ce tableau, pointée sur l'endpoint public :

curl -s https://api.archyl.com/mcp \
  -H "X-API-Key: $ARCHYL_API_KEY" \
  -H "MCP-Protocol-Version: 2026-07-28" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Pas d'initialize. Pas de session. 181 tools.

Le bug que le transport déprécié cachait

Auditer le nouveau transport nous a fait regarder l'ancien, et l'ancien avait un vrai bug.

Le transport HTTP+SSE de 2024-11-05 découpe une conversation sur deux connexions. Le client ouvre un stream longue durée avec GET, le premier événement du serveur lui dit où POSTer, et à partir de là chaque message part en POST tandis que chaque réponse revient sur le stream. Ces deux connexions n'ont pas à atterrir sur la même instance.

Le nôtre supposait que si. Les canaux de réponse vivaient dans cette map[string]chan *JSONRPCMessage sur le struct du serveur, donc un POST traité par l'instance B écrivait sa réponse dans un canal qui existait sur l'instance B et que personne sur l'instance B ne lisait. Le stream était sur l'instance A. Le client attendait.

Ce qui rend ça pire qu'une odeur de conception, c'est que rien n'était loggé. Pas d'erreur, pas d'avertissement, pas de requête en échec. Le POST renvoyait 202 Accepted, ce qui était vrai, le message avait bien été accepté, et la réponse partait nulle part. De l'extérieur, c'est indiscernable d'un appel de tool lent. Ça n'arrive que sur un déploiement scalé horizontalement, ce qui est exactement là où on a le moins envie de reproduire quelque chose à la main.

La map est maintenant un routeur Redis pub/sub dans streamrouter.go. Une réponse pour un stream que ce process détient est délivrée directement et ne fait jamais l'aller-retour. Une réponse pour un stream détenu ailleurs est publiée sur mcp:stream:<sessionID>, et l'instance qui détient ce stream y est abonnée. N'importe quelle instance peut prendre le POST. Aucune affinité de session n'est requise, et aucune règle de sticky session n'a à être maintenue dans une config de load balancer que personne ne se souvient d'avoir écrite.

Deux choses valent la peine d'être dites là-dessus, parce qu'un routeur est une dépendance.

Redis est maintenant sur ce chemin. S'il est injoignable au démarrage, le routeur retombe sur une distribution purement locale et logge un avertissement plutôt que de refuser de démarrer, parce que le local seul est correct pour une instance unique et n'est faux qu'à partir du moment où il y en a une deuxième. L'échec est bruyant exprès : l'alternative, c'est le blocage silencieux qu'on vient de supprimer. Si vous déployez ça, la ligne de démarrage à chercher est MCP stream router: Redis connected. Son absence, c'est toute l'histoire.

Et le routeur répare le routage, pas la localisation. Le stream reste une connexion détenue par un process ; Redis porte les réponses jusqu'à ce process, il ne déplace pas le stream. Cette partie-là est irréductible. Une connexion ouverte vit là où elle a été ouverte, dans n'importe quel protocole.

Ce qu'on n'a pas fait

C'est là qu'une annonce s'arrête d'habitude. Deux choses valent la peine d'être dites franchement, parce que vous pouvez vérifier les deux.

Archyl parle 2026-07-28 sur le chemin qui compte. Ce n'est pas stateless de bout en bout.

Le chemin stateless est réellement stateless : pas de lookup de session, pas d'écriture de session, pas de Mcp-Session-Id, rien qui épingle une requête à un process. Ce chemin-là peut se placer derrière un simple load balancer round-robin.

Notre serveur répond aussi toujours à l'ancien transport HTTP+SSE sur /sse, mais on a arrêté de le documenter. Chaque page qui affichait cette URL affiche maintenant /mcp, et c'est le seul endpoint qu'on demande à qui que ce soit de configurer.

La raison, c'est la dépendance qu'on vient d'ajouter. Le routeur ne supprime l'exigence d'affinité que là où Redis est joignable. Là où il ne l'est pas, la distribution retombe sur le local seul, ce qui est correct sur une instance et silencieusement faux sur deux. Notre propre production ne fait pas tourner Redis aujourd'hui, donc c'est ce fallback-là qu'on exécute. On préfère orienter tout le monde vers le transport dont la correction ne dépend pas d'un nombre d'instances plutôt que d'en publier un dont elle dépend.

Ce qui reste vrai de /sse où qu'il tourne : le stream est une connexion détenue par un seul process, et une ligne de session existe dans Postgres pendant toute sa durée de vie. Supprimer l'exigence d'affinité, ce n'est pas la même chose que supprimer l'état. On n'annonce pas de date pour retirer ce transport.

L'horloge de ce transport n'est pas la nôtre, cela dit, et elle est plus courte qu'on ne le supposait. HTTP+SSE est déprécié depuis la révision 2025-03-26 ; ce que 2026-07-28 a fait, c'est le reclasser comme Deprecated sous la nouvelle politique de cycle de vie des fonctionnalités. Cette politique fixe une fenêtre minimale de douze mois entre la dépréciation et l'éligibilité au retrait, ce dont bénéficient Roots, Sampling et Logging : retrait au plus tôt dans "the first revision released on or after 2027-07-28" — la première révision publiée le 28 juillet 2027 ou après. HTTP+SSE n'a pas droit à douze mois, parce qu'il était déjà déprécié bien avant que la politique existe. Le registre des fonctionnalités dépréciées liste son retrait au plus tôt comme "Three months after SEP-2596 reaches Final" — trois mois après que SEP-2596 atteint le statut Final. Le retrait reste une décision des Core Maintainers prise à la préparation de la release et peut arriver plus tard, mais si vous faites tourner HTTP+SSE quelque part, c'est la ligne à lire.

On a implémenté la forme de la révision, pas la totalité. Ce qui sort, c'est la négociation de version, le chemin de requête stateless, server/discover, l'erreur de version non supportée avec le bon code, resultType, les indications de cache, et les 405 et 404 que le transport demande, à côté du chemin handshake pour les clients qui en ont encore besoin. Voici ce qui n'est pas là :

  • Les headers de requête Mcp-Method et Mcp-Name, et la validation qui va avec. C'est le plus gros manque. La révision exige qu'un POST recopie sa method, et son params.name ou params.uri, dans des headers, et exige que le serveur rejette toute divergence avec 400 et -32020 HeaderMismatch. La raison n'est pas la propreté. Dans les mots mêmes de la spec, ça "prevents potential security vulnerabilities when different components in the network rely on different sources of truth (e.g., a load balancer routing on the header value while the MCP server executes based on the body value)" — ça prévient d'éventuelles vulnérabilités de sécurité quand différents composants du réseau s'appuient sur différentes sources de vérité (par exemple un load balancer qui route sur la valeur du header pendant que le serveur MCP exécute selon la valeur du body). La même règle couvre MCP-Protocol-Version, dont la valeur DOIT correspondre à celle du _meta de la requête. On lit la version depuis le header uniquement et on ne regarde jamais _meta, donc on ne peut pas détecter une divergence qu'on est censés rejeter. Le header est disponible avant que le body soit parsé, c'est pour ça qu'on le lit là. Ce n'est pas une raison pour sauter la vérification croisée.
  • subscriptions/listen, et les Multi Round-Trip Requests avec InputRequiredResult. Des fonctionnalités entières plutôt que des correctifs. On n'a jamais implémenté resources/subscribe, donc la méthode qui le remplace ne nous coûte rien aujourd'hui.
  • La validation du header Origin. La spec la marque MUST, avec 403 sur une origine invalide, comme défense contre le DNS rebinding. On ne le fait pas sur /mcp.
  • extensions sur les capabilities, et un ordre déterministe depuis tools/list. Le second est un SHOULD, visant le cache côté client et le taux de hit du prompt cache des LLM. Les nôtres sortent d'une map Go, donc l'ordre est celui que cette map nous donne ce jour-là.
  • Dynamic Client Registration. Cette révision la déprécie au profit des Client ID Metadata Documents, et on expose toujours POST /register. Ça reste disponible pour les serveurs d'autorisation qui ne supportent pas le remplacement, donc c'est une migration plutôt qu'une rupture, sur la même horloge de douze mois que Roots, Sampling et Logging.
  • server/discover est derrière la même clé d'API que tout le reste sur /mcp. Il ne répondra pas à un appelant anonyme, ce qui est un choix délibéré et pas ce qu'un client qui découvre un serveur attend.

Le reste, c'est du travail, et il est sur la liste plutôt que fait.

Si vous faites tourner votre propre serveur MCP

Les vérifications qui valent la peine d'être passées sur le vôtre :

  1. Envoyez tools/list avec MCP-Protocol-Version: 2026-07-28 et sans handshake. Si vous obtenez "session not initialized", votre serveur ne sert pas la révision actuelle.
  2. Appelez server/discover. C'est obligatoire maintenant. S'il renvoie method-not-found, c'est le plus petit manque à combler.
  3. Déclarez une version que vous ne supportez pas. Vérifiez que l'erreur porte la liste des versions que vous supportez, et que son code est -32022 plutôt qu'un générique. C'est la vérification qu'on a ratée.
  4. Lisez n'importe quel résultat. Chacun d'eux a besoin de resultType, et vos résultats de listing ont besoin de ttlMs et cacheScope en plus.
  5. Regardez ce que vous renvoyez dans Mcp-Session-Id sur une requête stateless. Vide, c'est pire qu'absent.
  6. Comptez vos écritures. Envoyez dix requêtes stateless et vérifiez si quoi que ce soit a atterri dans votre stockage de sessions. Ce nombre est la réponse honnête à la question de savoir si la migration a marché.
  7. Si vous servez encore HTTP+SSE et que vous faites tourner plus d'une instance, POSTez sur l'une pendant que le stream est détenu par une autre. Un client qui bloque sans rien dans les logs, c'est le bug qu'on avait. Ensuite, lisez la ligne du registre de dépréciation ci-dessus.

L'écart entre « accepte le nouveau header de version » et « réellement stateless » est là où se trouve l'essentiel du travail, et seule l'étape 6 vous dit de quel côté vous êtes.

Connectez-le

L'endpoint est inchangé, et les deux révisions fonctionnent dessus. Pour Claude Code, un .mcp.json à la racine de votre projet :

{
  "mcpServers": {
    "archyl": {
      "type": "http",
      "url": "https://api.archyl.com/mcp",
      "headers": {
        "X-API-Key": "YOUR_API_KEY"
      }
    }
  }
}

Votre client choisit la révision. S'il parle 2026-07-28, il est servi sans handshake et sans session. Sinon, rien ne change pour lui.

La configuration complète pour Claude Code, Cursor, VS Code, Codex, Warp, Windsurf et Antigravity, avec les scopes qui décident de ce qu'un agent peut modifier, est dans la documentation du serveur MCP.