MCP se volvió stateless: qué le quitó la revisión 2026-07-28 a nuestro servidor
Si operas un servidor MCP, tienes una sesión en algún sitio. Probablemente una tabla, quizá un map en memoria. Un cliente se conecta, envía initialize, recibe un Mcp-Session-Id de vuelta, y arrastra esa cabecera en cada request posterior. Tú guardas la fila. La expiras al cabo de un rato. Te aseguras de que un request aterrice en la instancia que la posee, o compartes el estado entre instancias.
La revisión 2026-07-28 borró eso. No lo marcó como deprecated: lo sacó del núcleo del protocolo. El handshake ya no está, la cabecera de sesión ya no está, y cada request lleva ahora su propia versión de protocolo y su identidad de cliente. Como lo pone el post de release, "any request can now land on any server instance behind a plain round-robin load balancer without needing shared storage" — cualquier request puede aterrizar ahora en cualquier instancia del servidor detrás de un load balancer round-robin normal, sin necesidad de almacenamiento compartido.
El servidor MCP de Archyl ya atiende a los clientes que hablan la nueva revisión. Este post es lo que costó, lo que medimos después, la única cosa que tuvimos mal en la primera pasada, y lo que no hemos hecho. Si mantienes un servidor MCP, las partes interesantes son probablemente la decisión de diseño del medio, el bug que auditar el transporte nuevo destapó en el viejo, y la checklist del final para ir a mirar el tuyo.
Qué eliminó realmente la revisión
Directo del changelog, las partes que tocan una implementación de servidor:
- Las sesiones a nivel de protocolo y la cabecera
Mcp-Session-Idse eliminan del transporte Streamable HTTP. Los endpoints de listado ya no varían por conexión. - El handshake
initialize/notifications/initializedse elimina. Cada request lleva su versión de protocolo y sus capabilities de cliente en_meta, y sobre Streamable HTTP esa misma versión viaja en la cabeceraMCP-Protocol-Version. server/discoveres nuevo y obligatorio. Los servidores DEBEN implementarlo, para anunciar las versiones de protocolo soportadas, las capabilities y la identidad. Los clientes PUEDEN llamarlo antes que nada; también son libres de enviar un request y manejar un error de versión.ping,logging/setLevelynotifications/roots/list_changedse eliminan.- Los desajustes de versión devuelven
UnsupportedProtocolVersionError, listando las versiones que el servidor sí soporta para que el cliente pueda reintentar.
Hay más ahí dentro (Multi Round-Trip Requests, subscriptions/listen, resultados de listado cacheables, un bloque renumerado de códigos de error, endurecimiento de la autorización), y volveré sobre cuáles de esas hicimos y cuáles nos saltamos. Las cinco de arriba son las que cambian la forma de un servidor, no sus funcionalidades.
Un punto que conviene precisar, porque cambia la decisión: esto ya no es un release candidate. El release candidate quedó congelado el 21 de mayo de 2026 y abrió una ventana de validación de diez semanas para los maintainers de SDK y los implementadores de clientes. Esa ventana se cerró el 28 de julio de 2026, cuando salió la especificación, y la página de versionado llama ahora a 2026-07-28 "the current protocol version" — la versión actual del protocolo. Los cuatro SDK de Tier 1 (TypeScript, Python, Go, C#) la hablan desde el día del release, con Rust en beta. Si estabas esperando a que el RC se asentara, ya se asentó.
Qué significó eso para un servidor con 181 tools
El servidor MCP de Archyl expone 181 tools sobre el modelo C4: proyectos, sistemas, containers, components, relationships, ADRs, docs, contracts, conformance, drift, DORA, ownership. Antes de este cambio, los 181 estaban detrás de una sesión.
En concreto, en nuestro backend:
- Cada conexión creaba una fila en una tabla
mcp_sessions, con una expiración de 24 horas y una goroutine de fondo barriendo las filas obsoletas y expiradas. - Los canales de respuesta SSE vivían en un
map[string]chan *JSONRPCMessagesobre el struct del servidor, indexado por session ID, lo que ataba una conexión al proceso que la había abierto. Ese map se ha mudado desde entonces, y la razón resultó ser un bug más que una preferencia. Vuelvo sobre ello más abajo. - Cuatro handlers (
tools/list,tools/call,resources/list,resources/read) abrían con las mismas tres líneas:
if !session.Initialized {
return s.errorResponse(msg.ID, ErrCodeInvalidRequest, "Session not initialized", nil)
}
Ese guard es el interesante. Hace una pregunta que el nuevo protocolo ha vuelto irrespondible: ¿completó este llamante el handshake? No hay handshake que completar.
La decisión que mantuvo el cambio pequeño
El movimiento tentador es enseñarles a esos cuatro handlers qué es la statelessness. Añadir una segunda condición, o un session.Stateless || delante de cada comprobación, o subir todo el asunto a un middleware.
No hicimos nada de eso. Los guards están intactos. En su lugar, un request que declara 2026-07-28 recibe una sesión en memoria construida para ese único request, que satisface el guard por construcción:
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,
}
}
No se persiste nada. No se asigna ningún ID. No se registra ningún canal SSE. Initialized: true no es una mentira ni un bypass: bajo esta revisión el request genuinamente está inicializado, porque el protocolo lleva su propia versión y el handshake que de otro modo habría completado ya no existe.
Por qué este encuadre importa más de lo que parece: esos cuatro guards están sobre un camino adyacente a la autorización. Cada uno de ellos es la diferencia entre que una llamada a un tool se ejecute o se rechace. Editar cuatro puntos de llamada que responden todos a una pregunta con forma de seguridad son cuatro oportunidades de debilitar una comprobación, repartidas por un diff que un reviewer tiene que sostener entero en la cabeza a la vez. Construir el objeto que los guards ya esperan es una función nueva, y cada comprobación existente conserva su significado exacto.
También falla en una dirección segura. Si nuestra detección de versión se equivoca y un request stateless se lee mal como uno legacy, la consecuencia es que se crea una fila de sesión para él. No se deja pasar nada que no se hubiera dejado pasar antes. El diseño inverso, aflojar los guards y condicionarlos a un string de versión, falla del otro lado.
La statelessness tampoco nos costó nada de tenancy, porque la identidad nunca vino de la fila de sesión, para empezar. La sesión stateless lleva el usuario y la organización resueltos a partir de la API key o el token OAuth presentados en ese request, los scopes se vuelven a derivar en cada llamada, así que revocar una key surte efecto de inmediato, y tools/call sigue rechazando una sesión sin tenant asociado. Ahora hay una cosa menos que robar: no hay session ID almacenado que pueda reproducirse. En el camino legacy mantuvimos la comprobación correspondiente, de modo que un session ID no puede permitir que una credencial actúe con la identidad guardada en la sesión de otra persona.
El enrutado, en un switch
Toda la decisión vive en el handler HTTP, antes incluso de que se parsee el body JSON-RPC:
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.
}
Dos detalles ahí dentro que son fáciles de pasar por alto:
IsModernProtocolVersion es una comparación de strings contra "2026-07-28". Las revisiones son YYYY-MM-DD, así que el orden lexicográfico es el orden cronológico, y una revisión futura cae por defecto del lado stateless en vez de recurrir al handshake. Solo llega tan lejos si la soportamos: una versión no reconocida se rechaza antes del switch, con la lista de soportadas en los datos del error para que el cliente pueda reintentar.
Y la cabecera de respuesta:
if !session.Stateless {
c.Set("Mcp-Session-Id", session.ID)
}
Una sesión stateless no tiene ID. Devolver un Mcp-Session-Id vacío le diría a un cliente que reutilice algo que no existe, lo cual es un bug peor que no enviarlo, y del tipo que solo aparece contra un cliente que no escribiste tú.
El resto del changelog, leído bien
El switch de versión es la decisión interesante. El resto de la revisión es una lista de requisitos pequeños, fáciles de pasar por alto y baratos de comprobar, así que volvimos sobre el changelog línea a línea. Cuatro de ellos aterrizaron en esa pasada.
resultType en cada result. La revisión hace el campo obligatorio: "complete" para una respuesta terminada, "input_required" para el resultado intermedio del patrón multi round-trip. A los clientes se les dice que traten su ausencia en un servidor más viejo como "complete", pero un cliente que lee la revisión final lo busca. Los nuestros lo llevan ahora, desde un struct Result embebido en cada tipo de resultado en vez de que cada tipo recuerde el campo por su cuenta.
ttlMs y cacheScope en los resultados de listado. Obligatorios en tools/list, prompts/list, resources/list, resources/read y resources/templates/list, a través de una nueva interfaz CacheableResult. Nosotros alojamos tres de esos cinco, y devuelven 60000 y private. Sesenta segundos es una pista, no un contrato: lo bastante largo para evitar que un agente vuelva a listar 181 tools en cada turno, lo bastante corto para que un tool registrado a mitad de sesión aparezca rápido. private es una decisión, no un default que aceptamos. Todos los resultados que devolvemos están acotados a la organización del llamante, así que ningún intermediario compartido puede cachear uno y entregárselo a otro tenant.
DELETE /mcp desde un cliente que declara 2026-07-28. DELETE terminaba una sesión a nivel de protocolo, y ya no hay sesiones a nivel de protocolo. La especificación dice que se responda 405, así que eso es lo que recibe un cliente moderno. Un cliente basado en handshake conserva el comportamiento antiguo.
Un método no implementado devuelve ahora HTTP 404 llevando JSON-RPC -32601. El código de estado por sí solo es ambiguo: un servidor legacy HTTP+SSE que ni siquiera aloja el endpoint moderno también responde 404. El body JSON-RPC es lo que distingue a los dos, y la especificación es explícita en que un cliente lo usa para decidir si vuelve a initialize o reintenta.
Y una que tuvimos mal en la primera pasada
Nuestro error de versión no soportada devolvía -32600, el "invalid request" genérico de JSON-RPC. Eso fue defendible hasta justo esta revisión, que define una política de asignación de códigos de error que parte el rango de errores de servidor de JSON-RPC: de -32000 a -32019 sigue siendo definido por la implementación, de -32020 a -32099 pertenece a la especificación. Los códigos introducidos durante el draft fueron renumerados dentro de ese bloque. HeaderMismatch pasó de -32001 → -32020, MissingRequiredClientCapability de -32003 → -32021, y UnsupportedProtocolVersion de -32004 → -32022.
Un cliente escrito contra la revisión final busca -32022. No habría reconocido lo que estábamos enviando, y el modo de fallo es exactamente el que toda esta revisión está diseñada para evitar: el cliente no puede distinguir "versión equivocada, aquí están las que hablo" de "tu request estaba malformado", así que no tiene nada con lo que reintentar.
Nada detectó eso salvo leer el changelog una segunda vez, que es el propio argumento de este post apuntado hacia nosotros. La renumeración es el punto 12 de los cambios menores, después de las entradas sobre las claves _meta de OpenTelemetry y las keywords de JSON Schema. Es el tipo de línea que uno lee por encima.
Un renombrado no nos costó nada. Resource-not-found se movió de -32002 a -32602, para alinearse con el "invalid params" de JSON-RPC, y resources/read ya respondía -32602 para una URI desconocida.
Lo que medimos
Todo esto se midió contra un contenedor en marcha sobre esta build, con una API key real, para poder contar las filas en Postgres directamente.
| Test | Resultado |
|---|---|
tools/list con MCP-Protocol-Version: 2026-07-28, sin handshake |
181 tools |
Mcp-Session-Id devuelto en esa respuesta |
ninguno |
resultType en tools/list y en server/discover |
complete |
ttlMs / cacheScope en tools/list |
60000 / private |
server/discover |
["2026-07-28", "2025-03-26"] |
| Versión no soportada declarada | -32022, lista de soportadas en los datos del error |
DELETE /mcp desde un cliente que declara 2026-07-28 |
405 |
| Método desconocido | 404 llevando -32601 |
Handshake initialize legacy |
sigue funcionando |
tools/list legacy con un session id |
181 tools |
Filas de mcp_sessions creadas por 10 requests stateless |
0 |
Filas de mcp_sessions creadas por 3 requests legacy |
3 |
El último par es el que hay que mirar. Diez requests, ninguna fila. Los tres requests legacy llegaron cada uno sin session ID, así que cada uno creó una; un cliente basado en handshake que se porte bien y reutilice su ID obtiene una fila para toda la vida de su sesión, no una por llamada. El punto es el cero: en el camino stateless no hay nada que escribir, nada que expirar, y nada que la goroutine de limpieza pueda encontrar.
El request que produjo la primera fila de esa tabla, apuntado al endpoint público:
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"}'
Sin initialize. Sin sesión. 181 tools.
El bug que el transporte deprecated estaba escondiendo
Auditar el transporte nuevo nos hizo mirar el viejo, y el viejo tenía un bug de verdad.
El transporte HTTP+SSE de 2024-11-05 parte una conversación en dos conexiones. El cliente abre un stream de larga duración con GET, el primer evento del servidor le dice dónde hacer POST, y a partir de ahí cada mensaje sale por un POST mientras cada respuesta vuelve por el stream. Esas dos conexiones no tienen por qué aterrizar en la misma instancia.
El nuestro asumía que sí. Los canales de respuesta vivían en ese map[string]chan *JSONRPCMessage sobre el struct del servidor, así que un POST atendido por la instancia B escribía su respuesta en un canal que existía en la instancia B y que nadie en la instancia B estaba leyendo. El stream estaba en la instancia A. El cliente esperaba.
Lo que hace eso peor que un mal olor de diseño es que no se registraba nada. Ningún error, ninguna advertencia, ningún request fallido. El POST devolvía 202 Accepted, lo cual era cierto, el mensaje había sido aceptado, y la respuesta no iba a ninguna parte. Desde fuera es indistinguible de una llamada a un tool lenta. Solo pasa en un despliegue escalado horizontalmente, que es exactamente donde menos ganas tienes de reproducir algo a mano.
El map es ahora un router Redis pub/sub en streamrouter.go. Una respuesta para un stream que este proceso sostiene se entrega directamente y nunca hace el viaje de ida y vuelta. Una respuesta para un stream sostenido en otro sitio se publica en mcp:stream:<sessionID>, y la instancia que sostiene ese stream está suscrita a él. Cualquier instancia puede coger el POST. No se requiere afinidad de sesión, y no hay que mantener ninguna regla de sticky session en una config de load balancer que nadie recuerda haber escrito.
Hay dos cosas que vale la pena decir sobre eso, porque un router es una dependencia.
Redis está ahora en este camino. Si no es alcanzable al arrancar, el router recurre a entrega solo local y registra una advertencia en vez de negarse a arrancar, porque solo local es correcto para una única instancia y solo es incorrecto en cuanto hay una segunda. El fallo es ruidoso a propósito: la alternativa es el cuelgue silencioso que acabamos de eliminar. Si despliegas esto, la línea de arranque que hay que buscar es MCP stream router: Redis connected. Su ausencia es toda la historia.
Y el router arregla el enrutado, no la ubicación. El stream sigue siendo una conexión sostenida por un proceso; Redis lleva las respuestas a ese proceso, no mueve el stream. Esa parte es irreducible. Una conexión abierta vive donde fue abierta, en cualquier protocolo.
Lo que no hemos hecho
Aquí es donde un anuncio suele parar. Hay dos cosas que vale la pena decir sin rodeos, porque puedes comprobar las dos.
Archyl habla 2026-07-28 en el camino que importa. No es stateless de extremo a extremo.
El camino stateless es genuinamente stateless: sin lookup de sesión, sin escritura de sesión, sin Mcp-Session-Id, nada que ate un request a un proceso. Ese camino puede estar detrás de un load balancer round-robin normal.
Nuestro servidor también sigue respondiendo el transporte HTTP+SSE más antiguo en /sse, pero hemos dejado de documentarlo. Cada página que antes imprimía esa URL ahora imprime /mcp, y ese es el único endpoint que pedimos configurar.
La razón es la dependencia que acabamos de añadir. El router quita el requisito de afinidad solo donde Redis es alcanzable. Donde no lo es, la entrega recae en solo local, que es correcto con una instancia y silenciosamente incorrecto con dos. Nuestra propia producción no corre Redis hoy, así que el fallback es lo que estamos ejecutando. Preferimos apuntar a todo el mundo al transporte cuya corrección no depende de un número de instancias, antes que publicar uno cuya corrección sí depende de él.
Lo que sigue siendo cierto de /sse allá donde corra: el stream es una conexión sostenida por un único proceso, y existe una fila de sesión en Postgres mientras dure. Quitar el requisito de afinidad no es lo mismo que quitar el estado. No estamos anunciando una fecha para retirar ese transporte.
El reloj de ese transporte no es nuestro, eso sí, y va más corto de lo que suponíamos. HTTP+SSE está deprecated desde la revisión 2025-03-26; lo que hizo 2026-07-28 fue reclasificarlo como Deprecated bajo la nueva política de ciclo de vida de funcionalidades. Esa política fija una ventana mínima de doce meses entre la deprecación y la elegibilidad para la eliminación, que es lo que reciben Roots, Sampling y Logging: eliminación más temprana en "the first revision released on or after 2027-07-28" — la primera revisión publicada en o después del 28 de julio de 2027. HTTP+SSE no recibe doce meses, porque ya estaba deprecated mucho antes de que la política existiera. El registro de funcionalidades deprecated lista su eliminación más temprana como "Three months after SEP-2596 reaches Final" — tres meses después de que SEP-2596 alcance Final. La eliminación sigue siendo una decisión de los Core Maintainers tomada durante la preparación de la release y puede ocurrir más tarde, pero si estás operando HTTP+SSE en algún sitio, esa es la fila que hay que leer.
Implementamos la forma de la revisión, no toda ella. Lo que sale es negociación de versión, el camino de request stateless, server/discover, el error de versión no soportada con el código correcto, resultType, las pistas de caché, y el 405 y el 404 que pide el transporte, junto al camino de handshake para los clientes que todavía lo necesitan. Esto es lo que no está:
- Las cabeceras de request
Mcp-MethodyMcp-Name, y la validación que viene con ellas. Este es el hueco más grande. La revisión exige que un POST refleje sumethod, y suparams.nameoparams.uri, en cabeceras, y exige que el servidor rechace cualquier desajuste con400y-32020 HeaderMismatch. La razón no es la pulcritud. En las propias palabras de la especificación, "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)" — previene posibles vulnerabilidades de seguridad cuando componentes distintos de la red se apoyan en fuentes de verdad distintas (por ejemplo, un load balancer enrutando por el valor de la cabecera mientras el servidor MCP ejecuta según el valor del body). La misma regla cubreMCP-Protocol-Version, cuyo valor DEBE coincidir con el del_metadel request. Nosotros leemos la versión solo de la cabecera y nunca miramos_meta, así que no podemos detectar un desajuste que estamos obligados a rechazar. La cabecera está disponible antes de que se parsee el body, que es por lo que la leemos ahí. Eso no es razón para saltarse la comprobación cruzada. subscriptions/listen, y Multi Round-Trip Requests conInputRequiredResult. Funcionalidades enteras, más que arreglos. Nunca implementamosresources/subscribe, así que el método que lo reemplaza no nos cuesta nada hoy.- Validación de la cabecera
Origin. La especificación la marca como MUST, con403ante un origin inválido, como defensa contra el DNS rebinding. Nosotros no lo hacemos en/mcp. extensionsen las capabilities, y orden determinista desdetools/list. Lo segundo es un SHOULD, apuntado al cacheo del lado cliente y a las tasas de acierto de la prompt cache de los LLM. Los nuestros salen de un map de Go, así que el orden es el que ese map nos dé ese día.- Dynamic Client Registration. Esta revisión la deprecia en favor de los Client ID Metadata Documents, y nosotros seguimos exponiendo
POST /register. Se mantiene disponible para los authorization servers que no soportan el reemplazo, así que esto es una migración y no una ruptura, con el mismo reloj de doce meses que Roots, Sampling y Logging. server/discoverestá detrás de la misma API key que todo lo demás en/mcp. No responderá a un llamante anónimo, lo cual es una decisión deliberada y no lo que espera un cliente que está descubriendo un servidor.
El resto es trabajo, y está en la lista en vez de estar hecho.
Si operas tu propio servidor MCP
Las comprobaciones que vale la pena correr contra el tuyo:
- Envía
tools/listconMCP-Protocol-Version: 2026-07-28y sin handshake. Si recibes "session not initialized", tu servidor no está sirviendo la revisión actual. - Llama a
server/discover. Ahora es obligatorio. Si devuelve method-not-found, ese es el hueco más pequeño que puedes cerrar. - Declara una versión que no soportes. Comprueba que el error lleva la lista de las que sí soportas, y que su código es
-32022en vez de uno genérico. Esta es la comprobación que fallamos. - Lee cualquier result. Todos ellos necesitan
resultType, y tus resultados de listado necesitan ademásttlMsycacheScope. - Mira qué devuelves en
Mcp-Session-Iden un request stateless. Vacío es peor que ausente. - Cuenta tus escrituras. Envía diez requests stateless y comprueba si algo aterrizó en tu almacén de sesiones. Ese número es la respuesta honesta a si la migración funcionó.
- Si todavía sirves HTTP+SSE y corres más de una instancia, haz POST a una mientras el stream lo sostiene otra. Un cliente que se cuelga sin nada en los logs es el bug que teníamos. Después lee la fila del registro de deprecaciones de arriba.
El hueco entre "acepta la nueva cabecera de versión" y "realmente stateless" es donde está la mayor parte del trabajo, y solo el paso 6 te dice de qué lado estás.
Conéctalo
El endpoint no ha cambiado, y ambas revisiones funcionan contra él. Para Claude Code, un .mcp.json en la raíz de tu proyecto:
{
"mcpServers": {
"archyl": {
"type": "http",
"url": "https://api.archyl.com/mcp",
"headers": {
"X-API-Key": "YOUR_API_KEY"
}
}
}
}
Tu cliente escoge la revisión. Si habla 2026-07-28, se le sirve sin handshake y sin sesión. Si no, para él no cambia nada.
La configuración completa para Claude Code, Cursor, VS Code, Codex, Warp, Windsurf y Antigravity, más los scopes que deciden qué puede cambiar un agente, está en la documentación del servidor MCP.