Saltar al contenido
effidevFlutter · Edge de Cloudflare · Optimización de costes en la nube
Español

Especificación MCP 2026-07-28: adiós a las sesiones — guía de migración a stateless

Guía de migración a stateless de la especificación MCP 2026-07-28

El 28 de julio de 2026 se publicó una nueva revisión de la especificación de Model Context Protocol que pasó a ser la versión actual (Current) del protocolo. Han transcurrido ocho meses desde la revisión anterior, 2025-11-25. David Soria Parra, cocreador de MCP, describió esta entrega en el anuncio oficial como lo “más importante que le ha pasado a MCP desde que apareció remote MCP hace más de un año”.

Resumido en una línea: MCP se ha vuelto stateless. El handshake initialize ha desaparecido y la cabecera Mcp-Session-Id también. Si tienes un servidor o un cliente MCP en producción, esto no es una nota de versión que puedas leer “algún día”: es un breaking change que te obliga a tocar código.

Las ondas expansivas ya se notan. El 6 de agosto, Cloudflare marcó McpAgent —la pieza que se usaba para construir servidores MCP en Workers— como deprecated y feature-frozen, y recomendó pasarse a un handler stateless. Si desaparecen las sesiones, tampoco hace falta un Durable Object que las sostenga. De esto hablamos aparte en la sección 12.

Este artículo no se limita a copiar la lista de cambios de la especificación: se centra en qué código hay que modificar realmente. Cada punto lleva su número de SEP, así que si necesitas la fuente puedes abrir directamente la PR correspondiente.

1. Las sesiones han desaparecido (SEP-2567)

Este es el cambio grande. En el transporte Streamable HTTP se han eliminado las sesiones a nivel de protocolo y la cabecera Mcp-Session-Id.

De ahí se deriva una condición adicional: tools/list, resources/list y prompts/list ya no pueden devolver valores distintos según la conexión. Antes era viable implementar cosas del estilo “esta sesión pertenece a un usuario autenticado, así que también le mostramos las herramientas de administración”; ahora la respuesta de un listado no depende de la conexión.

¿Y qué hace entonces un servidor que necesita mantener estado entre llamadas? La respuesta de la especificación es clara: que el servidor emita un handle explícito y lo intercambie como un argumento más de la herramienta.

// Before — estructura que dependía de la cabecera de sesión
app.post('/mcp', async (c) => {
  const sessionId = c.req.header('Mcp-Session-Id');
  const state = await sessions.get(sessionId);   // ya no llega esta cabecera
  // ...
});

// After — el estado viaja explícitamente como argumento, en un handle emitido por el servidor
server.tool('open_workspace', {}, async () => ({
  resultType: 'complete',
  content: [{ type: 'text', text: 'Se ha abierto el workspace' }],
  structuredContent: { workspaceHandle: 'ws_01J8ZQ...' },   // lo emite el servidor
}));

server.tool('write_file', {
  workspaceHandle: z.string(),   // el cliente lo reenvía tal cual en la siguiente llamada
  path: z.string(),
  body: z.string(),
}, async ({ workspaceHandle, path, body }) => { /* ... */ });

El estado implícito de la conexión se convierte en dato explícito. Eso simplifica muchísimo tener varias instancias detrás de un balanceador de carga o desplegar en serverless, porque da igual qué instancia atienda cada petición.

2. El handshake initialize ha desaparecido (SEP-2575)

initialize y notifications/initialized se han eliminado por completo. En su lugar, cada petición transporta en _meta la información que necesita por sí misma.

Clave de _meta Dirección Obligatorio Contenido
io.modelcontextprotocol/protocolVersion Cliente → Servidor Versión del protocolo ("2026-07-28")
io.modelcontextprotocol/clientCapabilities Cliente → Servidor Capacidades del cliente relevantes para esta petición
io.modelcontextprotocol/clientInfo Cliente → Servidor No (SHOULD) Nombre y versión del cliente
io.modelcontextprotocol/logLevel Cliente → Servidor No Nivel mínimo de log para esta petición
io.modelcontextprotocol/serverInfo Servidor → Cliente (en el _meta del resultado) No (SHOULD) Nombre y versión del servidor
// esta es la forma que adopta ahora cualquier petición
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "write_file",
    "arguments": { "path": "a.txt", "body": "hi" },
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientCapabilities": { },
      "io.modelcontextprotocol/clientInfo": { "name": "my-client", "version": "1.0.0" }
    }
  }
}

Una petición a la que le falte un campo obligatorio está malformada. El servidor debe (MUST) rechazarla con el error JSON-RPC -32602 (Invalid params) y, si es HTTP, el estado de respuesta debe ser 400 Bad Request. Si la versión no encaja, se devuelve UnsupportedProtocolVersionError (-32022).

La declaración de capacidades también incorpora una regla: el servidor no debe (MUST NOT) asumir capacidades que el cliente no haya declarado. Si para atender la petición hace falta una capacidad no declarada, hay que devolver MissingRequiredClientCapabilityError (-32021) y enumerar en data.requiredCapabilities las capacidades que faltan.

La especificación deja muy claro qué significa aquí “stateless”: el servidor no debe (MUST NOT) construir contexto apoyándose en peticiones anteriores de la misma conexión. Incluso indica de forma explícita que un proceso stdio no es una conversación ni una sesión: el cliente puede mezclar en el mismo transporte peticiones sin relación entre sí, y el servidor no debe tratar la identidad de la conexión o del proceso como sustituto de la continuidad de sesión.

Como se elimina un ida y vuelta completo del handshake, la ganancia se nota en entornos con arranques en frío frecuentes: runtimes edge o serverless. En el lado contrario, todo el código que dependía de información “recibida una vez en la inicialización y reutilizada después” hay que rehacerlo.

3. server/discover: ahora es de implementación obligatoria (SEP-2575)

Es el RPC que ocupa el hueco que deja el handshake. El servidor debe (MUST) implementar server/discover, cuya función es anunciar las versiones de protocolo soportadas, las capacidades y la identidad.

Desde el lado del cliente se puede usar de dos maneras:

Así se ve un DiscoverResult. Cuidado con los nombres de los campos: la lista de versiones soportadas es supportedVersions, y la identidad del servidor no va en la raíz del resultado, sino dentro de _meta.

{
  "resultType": "complete",
  "supportedVersions": ["2026-07-28", "2025-11-25"],
  "capabilities": { "tools": {}, "resources": {} },
  "_meta": {
    "io.modelcontextprotocol/serverInfo": { "name": "my-mcp-server", "version": "2.0.0" }
  }
}

El error que devuelve el servidor cuando la versión no encaja también está definido. Hay que incluir la lista de versiones soportadas para que el cliente pueda volver a elegir.

{
  "jsonrpc": "2.0", "id": 1,
  "error": {
    "code": -32022,
    "message": "Unsupported protocol version",
    "data": { "supported": ["2026-07-28", "2025-11-25"], "requested": "1900-01-01" }
  }
}

Si estás construyendo un servidor, este es el primer trozo de código que debes añadir en esta migración. Sin él, incumples la especificación.

Cómo atender a clientes antiguos y nuevos a la vez

La especificación clasifica las implementaciones en tres categorías.

Término Significado
Modern Envía versión, identidad y capacidades en el _meta de cada petición (2026-07-28 o posterior)
Legacy Establece una sesión con el handshake initialize (2025-11-25 o anterior)
Dual-era Implementación que soporta ambos modos

Un servidor dual-era decide según cómo le hable el cliente. Si la petición llega con el _meta moderno, la procesa como stateless; si llega un initialize, la procesa a la manera legacy. Se permite (MAY) servir ambos modos desde un mismo endpoint.

La detección del lado cliente varía según el transporte.

El resultado de esa detección es una propiedad del servidor, no de cada petición concreta. Lo correcto es cachearlo (SHOULD) durante la vida del proceso en stdio, y por origen en HTTP.

Hay una combinación que conviene tener presente: cliente legacy → servidor moderno falla. Un cliente legacy no tiene ningún mecanismo para subir a una versión superior. Por eso, en un servidor exclusivamente moderno conviene (SHOULD) indicar las versiones soportadas en el mensaje de error de la petición initialize: ese mensaje puede ser la única pista que llegue a ver el usuario del cliente legacy.

4. Cambia el modelo de suscripciones (SEP-2575)

Desaparecen el endpoint HTTP GET y resources/subscribe/resources/unsubscribe, y todo queda unificado en subscriptions/listen: un único stream de respuesta POST de larga duración.

El cliente hace un opt-in explícito a los tipos de notificación que quiere recibir.

El servidor lo confirma y etiqueta las notificaciones que envía con io.modelcontextprotocol/subscriptionId.

Aquí hay un punto que se presta a confusión: las notificaciones asociadas a una petición, como notifications/progress y notifications/message, no viajan por el stream de subscriptions/listen. Esas notificaciones siguen fluyendo por el stream de respuesta de la petición original. Si de repente deja de aparecer el indicador de progreso, sospecha de aquí.

5. MRTR — cómo pregunta ahora el servidor al cliente (SEP-2322)

Antes existía un canal propio para que el servidor lanzara peticiones al cliente: roots/list, sampling/createMessage, elicitation/create y similares. Trasladando literalmente lo que dice la especificación: el servidor debe (MUST) enviar ahora ese tipo de peticiones mediante el patrón MRTR, y el antiguo patrón de peticiones iniciadas por el servidor ya no está soportado. Es un breaking change.

El nuevo flujo es este.

  1. El cliente envía una petición (id: 1)
  2. Si el servidor necesita más información, devuelve un InputRequiredResult con resultType: "input_required"
  3. El cliente pregunta al usuario y vuelve a enviar la petición original con un id nuevo, incorporando la respuesta

Y aquí está el error más habitual: inputRequests e inputResponses no son arrays, son mapas. La clave es el identificador que asigna el servidor, y el valor es un objeto de petición completo (method + params) que puede ser ElicitRequest, CreateMessageRequest o ListRootsRequest.

// paso 2 — respuesta del servidor
{
  "jsonrpc": "2.0", "id": 1,
  "result": {
    "resultType": "input_required",
    "inputRequests": {
      "github_login": {
        "method": "elicitation/create",
        "params": {
          "mode": "form",
          "message": "Introduce tu nombre de usuario de GitHub",
          "requestedSchema": {
            "type": "object",
            "properties": { "name": { "type": "string" } },
            "required": ["name"]
          }
        }
      }
    },
    "requestState": "AEAD-protected blob"
  }
}

La respuesta es un mapa con las mismas claves, y sus valores son los objetos de resultado de cada petición.

// paso 3 — se reintenta con un id nuevo, reenviando la respuesta y el requestState tal cual
{
  "jsonrpc": "2.0", "id": 2,
  "method": "tools/call",
  "params": {
    "name": "delete_file",
    "arguments": { "path": "a.txt" },
    "inputResponses": {
      "github_login": { "action": "accept", "content": { "name": "octocat" } }
    },
    "requestState": "AEAD-protected blob"
  }
}

requestState — el mecanismo que hace stateless a MRTR

requestState es una cadena opaca que solo tiene sentido para el servidor. El servidor codifica ahí el contexto que necesita y lo envía; el cliente se lo devuelve tal cual. Esa es justamente la razón por la que el servidor no necesita un almacén de estado.

Las reglas son bastante estrictas.

El requisito de seguridad es especialmente importante. Como este valor pasa por el cliente, es una entrada manipulable por un atacante (la especificación indica con MUST que debe tratarse así). Si influye en la autorización, en el acceso a recursos o en la lógica de negocio, el servidor debe proteger obligatoriamente su integridad (por ejemplo con HMAC o AEAD) y rechazar cualquier estado que no supere la verificación. Solo se puede omitir cuando manipularlo no provoque nada más allá de que la petición falle.

Para prevenir replay, se recomienda (SHOULD) incluir lo siguiente dentro del payload protegido en integridad y verificarlo en cada recepción.

Ahora bien, estas medidas solo estrechan la ventana de replay; no garantizan un uso único. Si de verdad necesitas un solo uso, el servidor debe (MUST) imponerlo por su cuenta.

Solo tres peticiones admiten MRTR

Petición del cliente ¿Admite InputRequiredResult?
prompts/get
resources/read
tools/call

En el resto de peticiones no debe (MUST NOT) enviarse. Tampoco se pueden incluir en inputRequests peticiones de un tipo que el cliente no haya declarado como capacidad: si el cliente no declaró elicitation, no puedes meter ahí un elicitation/create.

Las peticiones bidireccionales se han convertido en reintentos unidireccionales. Para el transporte todo es mucho más simple, pero el cliente tiene que implementar el bucle de reintento por su cuenta. Todos los callbacks que gestionaban peticiones iniciadas por el servidor hay que trasladarlos a esta estructura.

6. resultType pasa a ser obligatorio en todos los resultados (SEP-2322)

Todos los resultados llevan de forma obligatoria el campo resultType.

Hay una regla explícita sobre compatibilidad hacia atrás: si un servidor de un protocolo anterior omite este campo, el cliente debe (MUST) tratarlo como "complete". Si estás escribiendo un cliente, no te olvides de esta rama.

7. Lo que ha desaparecido

ping
logging/setLevel
notifications/roots/list_changed
tasks/result          (→ sustituido por polling con tasks/get)
tasks/list
notifications/elicitation/complete

El nivel de log se indica ahora en cada petición mediante io.modelcontextprotocol/logLevel dentro de _meta. Y el servidor no debe (MUST NOT) enviar notifications/message para peticiones que no lleven ese campo. Si tu servidor volcaba logs sin condiciones, tendrás que añadir esa comprobación.

También desaparece la reanudación del stream SSE. Al eliminarse de Streamable HTTP la cabecera Last-Event-ID y los IDs de evento SSE, si se corta el stream de respuesta, la petición en curso se pierde sin más. El cliente debe (MUST) reenviarla con un nuevo ID de petición. Si tienes código escrito esperando que al reconectar se retome donde iba, aquí se romperá en silencio.

8. Tasks sale del core y pasa a ser una extensión (SEP-2663)

Tasks, que era una función experimental, se ha sacado del protocolo core y se ha trasladado a la extensión oficial io.modelcontextprotocol/tasks. No es una simple mudanza: el diseño se ha rehecho.

Cambio Detalle
Se elimina tasks/result Era bloqueante. Se sustituye por polling con tasks/get
Se añade tasks/update Para enviar entrada del cliente al servidor
Se elimina tasks/list
Devolución de handles El servidor puede devolver un handle de tarea por iniciativa propia, sin opt-in por petición

Las extensiones se anuncian en el mapa extensions de las capacidades (capabilities). El identificador sigue la convención de nombres de las claves _meta, con prefijo obligatorio.

{
  "capabilities": {
    "tools": {},
    "extensions": { "io.modelcontextprotocol/tasks": {} }
  }
}

También está definido qué ocurre cuando solo una de las partes soporta la extensión: quien la soporta debe (MUST) recurrir al comportamiento del core o rechazar con un error apropiado.

9. Deprecated — Roots, Sampling y Logging (SEP-2577)

Tres funcionalidades quedan marcadas como obsoletas. Durante el periodo de deprecación siguen funcionando por completo, pero la idea es que las implementaciones nuevas no las usen. Estas son las alternativas que recomienda la especificación.

Funcionalidad obsoleta Alternativa
Roots Pasar directorios y ficheros como parámetros de herramienta, URIs de recurso o configuración del servidor
Sampling Integrarse directamente con la API del proveedor de LLM
Logging stderr en stdio; OpenTelemetry en el resto de casos

Hay más elementos reclasificados como obsoletos.

Lo relevante aquí es la política de ciclo de vida de funcionalidades que se introduce junto con todo esto. Define tres estados —Active / Deprecated / Removed— y establece que una funcionalidad obsoleta permanece en la especificación un mínimo de 12 meses antes de ser candidata a eliminación. Pero no es incondicional: si se aplica la excepción de eliminación acelerada (expedited-removal) de la política, ese plazo baja a un mínimo de 90 días, por ejemplo cuando existe un riesgo de seguridad activo. Es decir, estas funcionalidades no van a desaparecer en la próxima revisión, pero no hay que leerlo como “tengo un año garantizado”.

10. Caché y enrutado — cambios discretos con mucho impacto práctico

No aparecen en la lista de cambios mayores, pero afectan de inmediato a la implementación real.

Los resultados de listado pasan a ser cacheables (SEP-2549). Los resultados de tools/list, prompts/list, resources/list, resources/read y resources/templates/list incorporan de forma obligatoria ttlMs y cacheScope. ttlMs es una pista de frescura (en milisegundos) y cacheScope toma los valores "public" / "private" para determinar si una caché intermedia puede compartir el resultado. Sirve para reducir el polling.

Ciertas cabeceras de petición pasan a ser obligatorias (SEP-2243). En un POST de Streamable HTTP hay que enviar obligatoriamente Mcp-Method y Mcp-Name. El objetivo es poder enrutar sin parsear el cuerpo. Si te tocaba distinguir tráfico MCP delante de un reverse proxy o un WAF, ahora basta con mirar las cabeceras.

tools/list debería (SHOULD) devolverse en un orden determinista. El motivo es el caché del cliente y la tasa de aciertos del caché de prompts del LLM. Si estabas sacando la lista de herramientas de un mapa y emitiéndola en un orden distinto cada vez, añade una ordenación: es muy probable que estuvieras invalidando el caché de prompts en cada llamada.

Se estandariza el contexto de trazas de OpenTelemetry (SEP-414). Se propagan traceparent, tracestate y baggage dentro de _meta.

11. Renumeración de los códigos de error

Se ha establecido una política de asignación de códigos de error que divide así el rango de errores de servidor de JSON-RPC.

En consecuencia, algunos códigos han cambiado.

Error Antes Ahora
HeaderMismatch -32001 -32020
MissingRequiredClientCapability -32003 -32021
UnsupportedProtocolVersion -32004 -32022
Recurso no encontrado -32002 -32602 (Invalid Params)

Hay un matiz que se malinterpreta con facilidad: esos tres primeros códigos nacieron en la fase de borrador de esta misma revisión, así que no existen en ningún código que implemente una versión publicada anterior. Lo único que puede estar realmente incrustado en código existente es la última fila.

El cambio de “recurso no encontrado” de -32002 a -32602 (Invalid Params) responde al ajuste con la especificación JSON-RPC. Quien implemente esta versión no debe (MUST NOT) emitir -32002, pero el cliente debería (SHOULD) seguir aceptando el -32002 que envíen servidores de versiones anteriores.

12. Si usas Cloudflare Workers — McpAgent queda obsoleto

El mejor ejemplo del alcance real de este cambio de especificación es Cloudflare. El 6 de agosto de 2026, con el anuncio “The next generation of MCP”, Cloudflare retiró McpAgent.

Si has leído las secciones 1 y 2, ya intuirás por qué era el desenlace natural. Hasta ahora, ejecutar un servidor MCP en Workers hacía prácticamente imprescindibles los Durable Objects: había que mantener la sesión identificada por Mcp-Session-Id y el stream SSE asociado a ella, así que hacía falta algo que fijara las peticiones a la misma instancia. Al desaparecer la sesión, desaparece esa premisa.

Cloudflare lo expresa así:

This release of the MCP 2026-07-28 specification … removes the need for McpAgent. While Durable Objects remain the right primitive when an application itself needs state, MCP itself no longer requires a Durable Object to speak the protocol.

Según la documentación, el estado actual de McpAgent es “deprecated and feature-frozen”. No es que hoy mismo deje de funcionar, pero no recibirá funcionalidades nuevas. El reemplazo es createMcpHandler, descrito oficialmente como algo que “crea un handler stateless de peticiones MCP invocable a partir de una factoría de servidores del MCP SDK v2”.

export default {
  fetch(request, env, ctx) {
    return createMcpHandler(createServer)(request, env, ctx);
  },
} satisfies ExportedHandler;

Ni clase de Durable Object ni enrutado de sesiones. En cada petición se crea el servidor, se atiende y se acabó.

Aquí hay otro punto fácil de malinterpretar: no es que los Durable Objects hayan dejado de ser necesarios. Lo que ha dejado de ser necesario es el DO “para hablar el protocolo MCP”. Si tu propia aplicación necesita estado, el DO sigue siendo la herramienta adecuada. La guía oficial señala exactamente esa distinción:

Store cross-request data behind an authenticated handle in a Durable Object, D1, KV, or R2 rather than an MCP session ID.

Es justo el “handle explícito emitido por el servidor” de la sección 1. El estado sigue viviendo en DO, D1, KV o R2; lo que cambia es que la llave que apunta a ese estado deja de ser el ID de sesión MCP y pasa a ser un handle autenticado.

La carga de migración tampoco cae toda de golpe. Cloudflare aclara que el endpoint /mcp “acepta tanto el nuevo protocolo como las peticiones stateless de clientes Streamable HTTP de 2025”. Aunque te queden usuarios con clientes antiguos, puedes migrar el servidor primero.

Con los costes conviene ser prudente. Cloudflare solo empleó una expresión cualitativa —el ahorro que corresponde a tener menos piezas móviles— y no ofreció ninguna cifra concreta de reducción. Que quitar instancias de DO va en la dirección de abaratar la factura es evidente, pero no es algo a lo que acercarse esperando un porcentaje.

13. Checklist de migración

Si tienes un servidor en producción, repásalo en orden.

Si desarrollas un cliente, añade además esto.

Conclusión

Los cuatro SDK de Tier 1 —TypeScript, Python, Go y C#— ya hablan 2026-07-28 desde el momento del lanzamiento. El SDK de Rust lo soporta en beta. El propio anuncio oficial reconoce que “para quienes dependían de identificadores de sesión hay cierto coste de migración”.

Aun así, la dirección es inequívoca: eliminar el estado implícito de la conexión y poner todo de forma explícita dentro de cada petición. Si ejecutas servidores MCP en entornos donde las instancias aparecen y desaparecen —edge o serverless—, este cambio no es una pérdida, es una ganancia: ya no hay motivo para seguir arrastrando un almacén de sesiones.

Como el periodo de deprecación es de 12 meses como mínimo, no hace falta reescribirlo todo hoy mismo. Pero server/discover es la excepción: no es una deprecación, sino un nuevo requisito obligatorio, así que si quieres decir que soportas la nueva especificación, tienes que añadirlo ya.