7 de agosto de 2026

Migrar tu servidor MCP a la spec 2026-07-28: qué se rompe

Foto de Marco Orta Marco Orta | 16 min de lectura
Compartir
Un cable de conexión persistente desconectándose mientras paquetes independientes de luz viajan en paralelo hacia varios servidores
Tabla de Contenidos

    El 28 de julio de 2026 el Model Context Protocol publicó la revisión que más cambia desde que existe: el núcleo del protocolo deja de tener estado. Desaparecen el handshake initialize, el header Mcp-Session-Id y el stream GET; llegan cabeceras obligatorias que el servidor debe validar contra el cuerpo de la petición.

    Con casi 9 650 servidores en el registro oficial, hay una cola larga de migraciones por delante. Y en español no hay absolutamente nada escrito sobre esto, así que aquí va el detalle, contrastado contra la especificación y no contra resúmenes de terceros.

    Advertencia de caducidad: este artículo es útil durante la ventana de deprecación, que es de doce meses como mínimo. Pasado ese plazo, deja de tener sentido.

    Qué cambió, en una pantalla

    Hasta 2025-11-25Desde 2026-07-28
    Sesióninitialize + Mcp-Session-IdSin estado. No hay handshake
    MetadatosEn la sesiónEn cada petición, en _meta
    Cabeceras HTTPOpcionalesMCP-Protocol-Version, Mcp-Method, Mcp-Name obligatorias
    Stream del servidorGET independientesubscriptions/listen
    Peticiones servidor → clienteJSON-RPC sobre SSEMRTR: InputRequiredResult
    Reanudar streamsLast-Event-IDNo soportado
    ListasSe repetían siempreCacheables con ttlMs
    Registro de clienteDCRCIMD (DCR deprecado)
    Roots, Sampling, LoggingVigentesDeprecados, 12 meses
    HTTP+SSE (2024-11-05)DeprecadoSigue deprecado, y ya retirable

    El motivo de fondo es operativo: sin sesión, cualquier petición puede caer en cualquier instancia detrás de un balanceador sin almacenamiento compartido. MCP pasa a ser una carga HTTP normal.


    Lo que desaparece

    El handshake initialize

    Ya no existe. Antes el cliente abría la conversación con initialize, el servidor respondía capacidades y el cliente confirmaba con initialized. Ese intercambio ataba al cliente a una instancia concreta.

    Ahora cada petición viaja completa: lleva su versión de protocolo, la información del cliente y sus capacidades en el propio cuerpo.

    Mcp-Session-Id

    Fuera, junto con el DELETE que terminaba la sesión. Si tu servidor guardaba estado por sesión, tienes que sacarlo del protocolo: la especificación sugiere pasarlo explícitamente como handles en los argumentos de las tools.

    El stream GET

    Antes el cliente abría un GET al endpoint para recibir mensajes iniciados por el servidor. Ese endpoint ya no existe; su sustituto es subscriptions/listen, que veremos abajo.

    Last-Event-ID

    Los streams ya no son reanudables. Si tu implementación dependía de reanudar tras una desconexión, hay que rediseñar esa parte.


    Las cabeceras obligatorias (y la trampa)

    Esta es la parte que rompe en silencio, así que va con detalle.

    Toda petición POST al endpoint MCP debe llevar:

    CabeceraDe dónde saleObligatoria en
    MCP-Protocol-VersionTodas
    Mcp-MethodmethodTodas
    Mcp-Nameparams.name o params.uritools/call, resources/read, prompts/get

    Una petición real, tal como la define la especificación:

    POST /mcp HTTP/1.1
    Content-Type: application/json
    MCP-Protocol-Version: 2026-07-28
    Mcp-Method: tools/call
    Mcp-Name: get_weather
    
    {
      "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": {}
        }
      }
    }
    

    El propósito es que un balanceador o un rate limiter pueda enrutar y medir sin parsear el JSON. Muy sensato.

    La trampa: las cabeceras deben coincidir con el cuerpo

    Aquí es donde se cae la mitad de las migraciones. El servidor debe rechazar cualquier petición donde una cabecera no coincida con su campo equivalente en el cuerpo:

    {
      "jsonrpc": "2.0",
      "id": 1,
      "error": {
        "code": -32020,
        "message": "Header mismatch: Mcp-Name header value 'foo' does not match body value 'bar'"
      }
    }
    

    400 Bad Request con el código -32020 (HeaderMismatch). Y no es opcional: es un requisito de seguridad. Si el balanceador enruta por la cabecera y el servidor ejecuta según el cuerpo, un atacante que las descuadre consigue que la petición se enrute a un sitio y se ejecute como otra cosa.

    Condiciones que obligan a rechazar:

    • Falta una cabecera obligatoria.
    • El valor de la cabecera no coincide con el del cuerpo.
    • El valor contiene caracteres inválidos.

    Un detalle fácil de pasar por alto: los valores enteros hay que compararlos numéricamente, no como cadenas42 y 42.0 son iguales.

    Para depurar esto, lo práctico es lanzar la petición a mano y ver qué contesta tu servidor antes de conectar ningún cliente:

    Cuando el valor no cabe en una cabecera

    Las cabeceras HTTP solo admiten ASCII visible. Si un nombre de tool o una URI llevan acentos, saltos de línea o espacios al principio, hay que codificarlos con un formato centinela:

    Mcp-Name: =?base64?SGVsbG8sIOS4lueVjA==?=
    

    El prefijo =?base64? y el sufijo ?= son sensibles a mayúsculas y deben aparecer exactamente así. El servidor tiene que decodificar antes de comparar con el cuerpo.

    Y hay un caso rebuscado que conviene tener presente: si un valor ASCII normal resulta empezar por =?base64? y terminar en ?=, hay que codificarlo igualmente para que no se confunda con el centinela.

    En español y portugués esto no es un caso raro: cualquier URI de recurso con una ñ o una tilde entra por aquí.


    MRTR: cómo se pide algo al usuario ahora

    Antes, si una tool necesitaba una decisión del usuario a mitad de ejecución, el servidor emitía su propia petición JSON-RPC por el stream SSE. Eso ya no está permitido: los servidores no inician peticiones.

    El sustituto se llama Multi Round-Trip Requests. El servidor devuelve un resultado especial y el cliente vuelve a llamar:

    sequenceDiagram
        participant C as Cliente
        participant S as Servidor
        C->>S: POST tools/call (id: 1)
        Note over S: Necesita input del usuario
        S-->>C: InputRequiredResult<br/>(inputRequests)
        Note over C: Recoge lo que le piden
        C->>S: POST tools/call (id: 2)<br/>params originales + inputResponses
        S-->>C: Resultado final

    En la práctica: el servidor responde con resultType: "input_required" e inputRequests, y el cliente reintenta la petición original añadiendo inputResponses.

    Esto afecta a sampling, elicitation y roots, que antes eran peticiones del servidor y ahora son campos dentro de un resultado. Si tu servidor usaba cualquiera de las tres, es la reescritura más grande de la migración — y ojo, porque el servidor ahora tiene que ser capaz de reanudar el trabajo desde una segunda llamada, no desde una conexión abierta.

    subscriptions/listen para notificaciones

    Las notificaciones de cambio (notifications/tools/list_changed, notifications/resources/updated) ya no llegan por un stream GET. El cliente abre un stream largo con una petición:

    sequenceDiagram
        participant C as Cliente
        participant S as Servidor
        C->>S: POST subscriptions/listen<br/>(filtro de notificaciones)
        S-->>C: SSE: subscriptions/acknowledged
        Note over C,S: El stream queda abierto
        S-->>C: SSE: tools/list_changed
        S-->>C: SSE: resources/updated

    Dos cosas que la especificación deja claras y que conviene no confundir:

    • Las notificaciones de la petición (notifications/progress, notifications/message) viajan por el stream de esa petición, no por el de listen.
    • En streams largos conviene emitir periódicamente un comentario SSE (una línea que empieza por dos puntos, :\r\n) como keep-alive, para que ningún intermediario cierre la conexión en los ratos tranquilos.

    Y si sirves SSE detrás de nginx, la cabecera que evita que el proxy acumule eventos en un búfer:

    X-Accel-Buffering: no
    

    Listas cacheables

    tools/list, prompts/list, resources/list y resources/read ahora pueden declarar cuánto vale su respuesta:

    • ttlMs — cuánto tiempo puede cachearse
    • cacheScope — con qué alcance

    Es la mejora más barata de adoptar y la que más tráfico ahorra: el catálogo de tools se repetía en cada arranque de cliente sin ningún motivo.

    Autorización: de DCR a CIMD

    El registro dinámico de clientes (DCR) queda deprecado en favor de los Client ID Metadata Documents (CIMD). DCR sigue funcionando durante la ventana de doce meses.

    Tres endurecimientos que acompañan al cambio:

    • Los servidores deben devolver el parámetro iss conforme a la RFC 9207, y los clientes deben validarlo antes de canjear el código de autorización.
    • Las credenciales de cliente quedan ligadas al servidor de autorización que las emitió, lo que impide reutilizarlas contra otro.
    • Los clientes declaran application_type al registrarse, que es lo que por fin resuelve los errores de redirección a localhost en aplicaciones de escritorio y CLI.

    Deprecados con doce meses de plazo

    Tres funciones entran en cuenta atrás:

    • Roots
    • Sampling
    • Logging

    Y el transporte HTTP+SSE de 2024-11-05, que ya estaba deprecado desde 2025-03-26, entra en su rampa final: la especificación lo declara elegible para eliminación en una revisión futura.

    Si tu servidor sigue exponiendo el par de endpoints SSE + POST del transporte viejo, ese es el trabajo que no puedes aplazar mucho más.

    Cómo atender a los clientes que aún no migraron

    Un servidor que solo hable la revisión nueva y reciba tráfico antiguo debe comportarse así:

    RecibeResponde
    GET o DELETE al endpoint MCP405 Method Not Allowed
    Cabecera Mcp-Session-IdIgnorarla. No generar ni devolver IDs de sesión
    Cabecera Last-Event-IDIgnorarla; los streams no son reanudables
    Método que no implementa404 + JSON-RPC -32601
    Versión de protocolo no soportada400 + UnsupportedProtocolVersionError con la lista de versiones que sí soporta

    Ese 404 con cuerpo JSON-RPC tiene un motivo concreto: distingue «no implemento ese método» de un 404 de un servidor HTTP+SSE antiguo que ni siquiera tiene el endpoint moderno.

    La detección funciona al revés en el cliente: intenta primero lo moderno y, si recibe un 400, mira el cuerpo antes de rendirse. Si trae un error JSON-RPC reconocible, el servidor es moderno y hay que corregir la petición; si viene vacío o ilegible, entonces sí toca caer a initialize.

    Seguridad, que sigue igual de olvidada

    La especificación insiste en tres cosas que no son nuevas pero se siguen incumpliendo:

    1. Validar la cabecera Origin en toda conexión entrante, y devolver 403 Forbidden si es inválida. Sin esto, una web cualquiera puede hacer DNS rebinding contra tu servidor MCP local.
    2. Escuchar solo en 127.0.0.1 cuando corre en local, nunca en 0.0.0.0.
    3. Autenticar todas las conexiones.

    El primero es el que más se salta, y es el que convierte un servidor MCP de desarrollo en una puerta abierta desde el navegador de quien visite la página equivocada.

    Estado de los SDK, y qué pasa con PHP

    Los SDK oficiales Tier 1 ya soportan 2026-07-28: TypeScript, Python, Go y C#. El de Rust va en beta.

    PHP no está en esa lista, ni en Tier 1 ni con soporte anunciado. Si tienes un servidor MCP en Laravel con el paquete laravel/mcp —como el que expliqué en cómo construir un agente de IA con Laravel y MCP—, la buena noticia es que el paquete abstrae el protocolo y la mayor parte de este cambio no te toca el código de las tools.

    La mala es que dependes de que el paquete se actualice para hablar la revisión nueva, y de que tu proyecto siga usando el transporte SSE mientras tanto. Merece la pena fijar la versión y vigilar el repositorio antes de que los clientes empiecen a exigir la spec nueva.

    Checklist de migración

    • Fuera el handshake initialize / initialized
    • Fuera Mcp-Session-Id y el DELETE de fin de sesión
    • Estado de sesión convertido en handles explícitos en los argumentos
    • Endpoint GET eliminado; GET/DELETE devuelven 405
    • MCP-Protocol-Version, Mcp-Method y Mcp-Name leídas y validadas contra el cuerpo
    • Error -32020 implementado con 400
    • Decodificación del centinela =?base64?…?= antes de comparar
    • Sampling, elicitation y roots migrados a MRTR
    • subscriptions/listen implementado para notificaciones de cambio
    • X-Accel-Buffering: no y keep-alive en streams largos
    • ttlMs y cacheScope declarados en las respuestas de lista
    • Validación de Origin con 403
    • Plan para DCR → CIMD antes de que cierre la ventana
    • Transporte HTTP+SSE con fecha de retirada puesta en el calendario

    Conclusión

    Esta revisión no añade funciones: quita las que impedían escalar. Un protocolo con sesión obliga a pegar cada cliente a una instancia; sin ella, un servidor MCP se pone detrás de un balanceador de reparto circular y ya está.

    El coste de esa simplificación se paga en tres sitios: la sesión hay que reconstruirla como handles explícitos, sampling y roots hay que rehacerlos con MRTR, y hay que validar cabeceras contra el cuerpo, que es el requisito nuevo que más gente va a implementar mal o directamente ignorar.

    Y sobre las prisas: la ventana de deprecación es de doce meses como mínimo, así que no hay incendio. Pero si tu servidor todavía vive en el transporte HTTP+SSE de 2024, ese sí lleva dos revisiones deprecado y ya es elegible para eliminación.

    Compartir

    Buscar

    Etiquetas

    Laravel PHP Tutorial IA JavaScript Desarrollo Web Buenas Prácticas Laravel 13 Seguridad Migración Herramientas Claude SEO Expresiones Regulares Manipulación de Texto