Transportes: stdio contra Streamable HTTP en un protocolo sin sesiones
El servidor de Logística Cerro de la Silla funciona por stdio en la laptop de Mariana. Para que lo usen ocho personas del centro de atención tiene que escuchar en un puerto y recibir solicitudes por red. En esta lección cambias de transporte y, de paso, entiendes la decisión más importante de la revisión 2026-07-28 de MCP: el protocolo dejó de tener sesiones. Eso simplifica mucho el despliegue, pero te obliga a pensar dónde guardas el estado.
Al terminar podrás:
- explicar cómo viajan los mensajes por stdio y por Streamable HTTP en la revisión 2026-07-28;
- reconocer los encabezados obligatorios de una solicitud HTTP y para qué sirven;
- diseñar estado entre llamadas con identificadores explícitos en lugar de sesiones;
- servir tu servidor por HTTP con la lista de hosts permitidos correcta;
- distinguir qué cambia cuando el cliente todavía habla una versión anterior.
Lo que es igual en los dos transportes
Un transporte no cambia el significado de los mensajes. La especificación lo describe como un "binding": define cómo se empaquetan y entregan los mensajes, cómo viajan los metadatos y cómo se cancela una solicitud. Las herramientas, los recursos y los prompts funcionan igual por stdio que por HTTP, y los mensajes siguen siendo JSON-RPC 2.0 codificado en UTF-8.
Hay una regla nueva que aplica a los dos: el cliente envía solicitudes y notificaciones; el servidor envía respuestas y notificaciones. Nada más. El servidor ya no inicia solicitudes JSON-RPC hacia el cliente. Cuando necesita algo del cliente, lo pide dentro de su respuesta (lo ves en la lección 3.2).
stdio: el transporte local
Por stdio, el cliente lanza tu servidor como subproceso:
- El servidor lee mensajes JSON-RPC de
stdiny escribe enstdout, un mensaje por línea, sin saltos de línea dentro del mensaje. - El servidor no puede escribir en
stdoutnada que no sea un mensaje MCP válido. Por eso usaslogging, que escribe enstderr. - El cliente puede capturar o ignorar
stderry no debe asumir que algo escrito ahí es un error. - Para cancelar una solicitud en curso, el cliente envía la notificación
notifications/cancelled.
stdio sigue siendo la opción correcta para herramientas que corren en la máquina del usuario, como un servidor que lee archivos locales. Su frontera de seguridad es el proceso: quien lo lanza, lo controla.
Streamable HTTP: el transporte para desplegar
En Streamable HTTP tu servidor es un proceso independiente que atiende a muchos clientes. Así funciona en la revisión 2026-07-28:
- Hay un solo endpoint que acepta POST, por ejemplo
https://mcp.lcs.mx/mcp. - Cada mensaje del cliente es un POST nuevo. El cliente debe aceptar
application/jsonytext/event-stream. - El servidor responde cada solicitud con un objeto JSON o con un flujo SSE propio de esa solicitud, que puede llevar notificaciones relacionadas (como progreso) antes de la respuesta final.
- Ya no existe el endpoint GET para recibir mensajes sueltos del servidor. Los avisos de cambios de larga duración se piden con una solicitud
subscriptions/listen. - Cerrar el flujo de respuesta equivale a cancelar esa solicitud. El servidor debe dejar de trabajar en ella en cuanto pueda.
- Ya no hay reanudación de flujos con
Last-Event-ID: si un flujo se corta, el cliente reenvía la solicitud con un id nuevo.
Los encabezados obligatorios
Streamable HTTP copia algunos campos del cuerpo JSON a encabezados HTTP para que balanceadores, gateways y herramientas de observabilidad puedan enrutar sin leer el cuerpo. Una llamada a la herramienta de LCS se ve así:
POST /mcp HTTP/1.1
Host: mcp.lcs.mx
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: rastrear_envio
{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "rastrear_envio",
"arguments": {"guia": "LCS-10000001"},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "atencion-clientes", "version": "1.0.0"},
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}
MCP-Protocol-Versiones obligatorio en cada POST y debe coincidir con el valor del_meta. Si no coinciden, el servidor responde400 Bad Requestcon un errorHeaderMismatch.Mcp-Methodva en todas las solicitudes.Mcp-Nameva entools/call,resources/readyprompts/get, con el nombre de la herramienta o prompt, o la URI del recurso.- Si el servidor no soporta la versión pedida, responde
400conUnsupportedProtocolVersionErrory la lista de versiones que sí soporta.
Buena noticia: no escribes nada de esto. El SDK lo arma en el cliente y lo valida en el servidor. Pero cuando configures un proxy o leas registros del balanceador, vas a ver estos encabezados y conviene saber qué significan.
Encabezados a partir de parámetros
La especificación permite marcar un parámetro de herramienta con x-mcp-header para que el cliente lo copie también a un encabezado Mcp-Param-{nombre}. LCS lo usa para que el gateway mande las consultas de inventario al almacén correcto sin abrir el cuerpo:
from typing import Annotated
from pydantic import Field
from mcp.server import MCPServer
mcp = MCPServer("lcs-inventario")
@mcp.tool()
def existencias_en_almacen(
sku: Annotated[str, Field(pattern=r"^[A-Z0-9-]{3,20}$")],
almacen: Annotated[str, Field(pattern=r"^(APO|SLW)$", json_schema_extra={"x-mcp-header": "Almacen"})],
) -> str:
"""Existencias de un SKU en un almacén: APO (Apodaca) o SLW (Saltillo)."""
return f"{sku}: 120 piezas en {almacen}."
En HTTP con la versión 2026-07-28, el cliente manda Mcp-Param-Almacen: APO junto al cuerpo y el servidor rechaza la llamada si los dos valores no coinciden. Solo se pueden marcar parámetros str, int o bool. La mayoría de los servidores no lo necesita; úsalo cuando tu infraestructura de verdad enrute por ese dato.
Sin sesiones: dónde queda el estado
Las versiones anteriores tenían un saludo initialize y un encabezado Mcp-Session-Id. Muchos servidores guardaban cosas "en la sesión". La revisión 2026-07-28 eliminó ambas cosas. Ahora:
- Cada solicitud es autosuficiente. Trae su versión, sus capacidades y, en HTTP, su token de autorización.
- Para conocer al servidor, el cliente puede llamar
server/discover, que todos los servidores deben implementar. Devuelve versiones soportadas, capacidades, instrucciones e identidad. ElClientdel SDK lo hace por ti en su modo automático. - Si necesitas estado entre llamadas, tu servidor emite un identificador explícito (el id de un lote, de un carrito, de un flujo) y el cliente lo manda de vuelta como argumento normal de la siguiente herramienta.
Ejemplo de LCS: un coordinador arma un lote de guías para reprogramar en varias llamadas.
import secrets
from typing import Annotated
from pydantic import Field
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
mcp = MCPServer("lcs-lotes")
LOTES: dict[str, list[str]] = {} # En producción: Redis o PostgreSQL, no memoria.
@mcp.tool()
def crear_lote() -> str:
"""Crea un lote vacío de reprogramación y devuelve su identificador."""
lote_id = secrets.token_urlsafe(16)
LOTES[lote_id] = []
return lote_id
@mcp.tool()
def agregar_a_lote(
lote_id: str,
guia: Annotated[str, Field(pattern=r"^LCS-\d{8}$")],
) -> int:
"""Agrega una guía a un lote existente y devuelve cuántas guías tiene."""
if lote_id not in LOTES:
raise ToolError("Ese lote no existe o ya expiró. Crea uno nuevo con crear_lote.")
LOTES[lote_id].append(guia)
return len(LOTES[lote_id])
Dos decisiones que vas a profundizar en la lección 2.2: el identificador es aleatorio y difícil de adivinar (secrets.token_urlsafe), y en producción debe quedar ligado al usuario autenticado, porque tener el identificador no es prueba de identidad. La guía de seguridad de la especificación le llama a ese ataque "secuestro de identificadores de estado".
Sirve el servidor por HTTP
Con el SDK 2 cambiar de transporte es un argumento de run():
if __name__ == "__main__":
mcp.run(transport="streamable-http", port=8000)
Los clientes se conectan a http://127.0.0.1:8000/mcp. Las opciones del transporte (host, port, streamable_http_path, json_response) van en run(), nunca en el constructor de MCPServer.
Prueba desde otra terminal con el cliente del SDK:
import anyio
from mcp import Client
async def main() -> None:
async with Client("http://127.0.0.1:8000/mcp") as cliente:
print("Versión negociada:", cliente.protocol_version)
resultado = await cliente.call_tool("rastrear_envio", {"guia": "LCS-10000001"})
print(resultado.content[0].text)
anyio.run(main)
Debes ver 2026-07-28 y el JSON del envío. El cliente envió un server/discover, adoptó la versión moderna y desde ahí cada llamada fue un POST independiente.
La lista de hosts permitidos
Cuando despliegues detrás de un nombre real, vas a encontrarte con esto: cada solicitud regresa 421 Misdirected Request con el texto Invalid Host header. No es un error tuyo de MCP. La especificación pide validar el encabezado Origin para evitar ataques de DNS rebinding, y el SDK, si no le dices nada, solo acepta 127.0.0.1, localhost y [::1]. La solución es declarar qué nombres sirves:
from mcp.server.transport_security import TransportSecuritySettings
seguridad = TransportSecuritySettings(
allowed_hosts=["mcp.lcs.mx", "mcp.lcs.mx:*"],
allowed_origins=["https://atencion.lcs.mx"],
)
app = mcp.streamable_http_app(transport_security=seguridad)
app es una aplicación ASGI que despliegas con uvicorn; lo haces completo en la lección 4.1. allowed_origins solo importa si un navegador llama a tu servidor. Y para pruebas locales, la especificación recomienda escuchar solo en 127.0.0.1, no en 0.0.0.0.
Clientes de versiones anteriores
El SDK atiende en el mismo endpoint a clientes 2025-11-25 y anteriores. Con ellos sí hay sesión: el SDK la guarda en la memoria de un proceso, así que con varias réplicas necesitas sesiones pegajosas en el balanceador, o la opción stateless_http=True, que solo afecta a esos clientes y les quita las solicitudes del servidor al cliente. Para clientes modernos no hay nada que configurar: cualquier réplica atiende cualquier solicitud.
Una advertencia: json_response=True responde cada POST con un solo JSON. Es cómodo, pero las notificaciones de progreso de esa llamada se pierden y el servidor no se entera si el cliente cancela. Para LCS lo dejamos en su valor por defecto.
Práctica
Dedica 40 minutos.
- Cambia el prototipo de LCS a
transport="streamable-http"y conéctate con el cliente de esta lección. Anota la versión negociada. - Con
curl, manda un POST vacío (-d '{}') a/mcpy observa el código de respuesta. Explica por qué el servidor rechaza la solicitud. - Crea
appconstreamable_http_app()sintransport_security, sírvela conuvicorny haz una solicitud con un encabezadoHost: mcp.lcs.mx. Luego agrega la lista de hosts y repite. - Implementa
crear_loteyagregar_a_lotey llámalas desde el cliente. ¿Qué pasa si reinicias el servidor entre dos llamadas? Anota la respuesta para la lección 4.1.
Resumen
- En la revisión 2026-07-28, el servidor nunca inicia solicitudes hacia el cliente; solo responde y envía notificaciones.
- stdio usa un mensaje JSON-RPC por línea en stdin y stdout;
stderrqueda para registros. - Streamable HTTP usa un solo endpoint por POST; cada respuesta es JSON o un flujo SSE propio de esa solicitud, y cerrar ese flujo cancela la solicitud.
MCP-Protocol-Version,Mcp-MethodyMcp-Nameson encabezados obligatorios que el SDK arma y valida por ti.- Sin sesiones, el estado entre llamadas viaja en identificadores explícitos y aleatorios que tu servidor emite y guarda en un almacén compartido.
- Detrás de un nombre real, configura
TransportSecuritySettingso recibirás421 Invalid Host header.