Migrar un servidor MCP a 2026-07-28: guía con TypeScript y Python

Cómo migrar un servidor MCP de la era 2025 a 2026-07-28 con los SDK v2 de TypeScript y Python: codemod, elicitation, HTTP sin sesiones y clientes antiguos.

Por

Datos a 9 de octubre de 2026 · especificación MCP 2026-07-28

Migrar un servidor MCP escrito en 2025 a la especificación 2026-07-28 tiene dos partes que conviene no mezclar. Una es actualizar el SDK, que en TypeScript arranca con un codemod oficial y en Python con un cambio de imports. La otra es que el servidor hable de verdad la versión nueva, y para eso actualizar el paquete no basta. Hemos ejecutado casi todo lo que sigue con el SDK v2 de TypeScript (@modelcontextprotocol/server 2.3.0) y con el SDK oficial de Python (mcp 2.2.0). Cuando algo sale solo de la documentación y no lo hemos probado, lo decimos.

Si todavía no tienes claro qué cambió en el protocolo, empieza por MCP 2026-07-28: el protocolo pasa a ser sin estado. Aquí damos por sabido que ya no hay initialize ni sesiones y nos centramos en cómo se traduce eso al código.

Antes de empezar

Los paquetes v2 de TypeScript piden Node.js 20 o superior y Zod 4.2 o superior, porque Zod 3 no está soportado. El SDK de Python 2.x declara Python 3.10 o superior (nosotros lo probamos con 3.13). El Inspector, que usarás para comprobar la migración, exige Node 22.19.0 o superior.

Nadie te obliga a abandonar ya la versión anterior. El paquete v1 de TypeScript, @modelcontextprotocol/sdk, sigue publicándose (1.32.0), y su README promete correcciones durante al menos seis meses desde la salida de v2. En Python, la rama 1.x sigue en mantenimiento (1.30.0). Para saber qué clientes y plataformas hablan ya 2026-07-28, el artículo «MCP 2.0» en producción lo repasa con fuente y fecha.

Nuestra recomendación es migrar a un servidor que atienda las dos eras desde el mismo endpoint. Los dos SDK lo hacen por defecto, y así no rompes a nadie mientras los clientes se ponen al día.

TypeScript: del SDK v1 al v2

Paso 1: ejecuta el codemod

El SDK publica un codemod oficial. Ejecútalo en la raíz del proyecto:

npx @modelcontextprotocol/codemod@latest v1-to-v2 .

Lo probamos sobre un servidor v1 pequeño, de un solo archivo, con una tool, dos resources y un prompt. Esta fue la salida:

Changes: 10 across 1 file(s)

Warnings (1):
  src/server.ts:1 - [WARNING] ErrorCode split into ProtocolErrorCode and SdkErrorCode. Verify the migration is correct.

Info (2):
  src/server.ts:18 - [INFO] Raw object literal wrapped with z.object().
  src/server.ts:7 - [INFO] Raw object literal wrapped with z.object().

package.json updated:
  package.json
    Removed: @modelcontextprotocol/sdk
    Added:   @modelcontextprotocol/server
    Warning: zod range '^3.25.0' cannot satisfy v2's floor: zod >=4.2.0 is required. [...]

El codemod no sube Zod a la versión 4, solo te avisa, así que esa actualización la haces tú. El aviso sobre ErrorCode también pide revisarlo a mano. En v2, McpError y ErrorCode pasan a ser ProtocolError y ProtocolErrorCode, y los códigos de tiempo de espera y conexión cerrada se van a SdkError con SdkErrorCode.

Paso 2: revisa lo que ha cambiado de nombre

Conviene leer el diff del codemod con la tabla de equivalencias del SDK al lado. Este esquema junta cuatro filas de la columna de v1 (no es un archivo que compile: name, desc y cb son marcadores).

import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';

server.tool(name, desc, { a: z.number() }, cb)
await server.connect(new StdioServerTransport())

En v2 cada tool se registra con un objeto de configuración y un esquema z.object(...). Este es el principio de build-server.ts, la fábrica que compartieron nuestras pruebas de stdio, de HTTP y los tests (el archivo sigue con un resource fijo, otro con plantilla y un prompt):

import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';

export function buildServer(): McpServer {
    const server = new McpServer({ name: 'curso-demo', version: '1.0.0' });

    server.registerTool(
        'sumar',
        {
            title: 'Sumar',
            description: 'Suma dos números',
            inputSchema: z.object({
                a: z.number().describe('Primer sumando'),
                b: z.number().describe('Segundo sumando')
            })
        },
        async ({ a, b }) => ({ content: [{ type: 'text', text: String(a + b) }] })
    );

Todo queda dentro de una función que devuelve el servidor, y esa fábrica es la que vas a necesitar en el paso siguiente. server.resource() pasa a registerResource() con los metadatos obligatorios ({} si no tienes), y server.prompt() a registerPrompt() con argsSchema.

Dentro de los handlers, el segundo parámetro ya no se llama extra sino ctx. extra.signal pasa a ctx.mcpReq.signal, extra.sendNotification(...) a ctx.mcpReq.notify(...) y extra.authInfo a ctx.http?.authInfo. Ya no hay imports profundos del tipo .../server/mcp.js, porque los paquetes solo exponen sus rutas públicas.

Paso 3: cambia el punto de entrada

Este paso es fácil de saltarse porque el codemod no lo hace. La guía oficial del SDK lo dice así: «Nothing in v2 puts a 2026-07-28 byte on the wire by default». Si dejas el connect de siempre, el código compila contra v2 y funciona, pero tu servidor sigue negociando 2025-11-25:

// Cableado "a mano" heredado de v1: sigue compilando en v2 pero SOLO sirve la era 2025.
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
import { buildServer } from './build-server.js';

await buildServer().connect(new StdioServerTransport());

Nuestro cliente de prueba, fijado a 2026-07-28 con mode: { pin: '2026-07-28' }, imprime esto al conectarse a ese servidor:

pin  -> ERA_NEGOTIATION_FAILED: Version negotiation failed: the server did not offer pinned protocol version 2026-07-28 via server/discover (no fallback in pin mode)

La solución es servir la fábrica con serveStdio:

import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { buildServer } from './build-server.js';

// serveStdio(factory) sirve 2026-07-28 y, por defecto, también la era 2025 (legacy: 'serve').
const handle = serveStdio(() => buildServer());
console.error('curso-demo escuchando por stdio'); // stdout es el canal JSON-RPC: logs siempre a stderr

process.on('SIGINT', () => void handle.close());

Paso 4: el transporte HTTP, sin sesiones

Un servidor HTTP v1 sin sesiones creaba un StreamableHTTPServerTransport por petición con sessionIdGenerator: undefined. En v2 lo sustituye createMcpHandler(fábrica), que devuelve un handler web estándar. En Node lo montas en Express con toNodeHandler:

import { createMcpExpressApp } from '@modelcontextprotocol/express';
import { toNodeHandler } from '@modelcontextprotocol/node';
import { createMcpHandler } from '@modelcontextprotocol/server';
import { buildServer } from './build-server.js';

// Sin estado: la fábrica se ejecuta UNA VEZ POR PETICIÓN HTTP. No hay Mcp-Session-Id.
const handler = createMcpHandler(({ era }) => {
    console.error(`[servidor] petición de la era ${era}`);
    return buildServer();
});

const app = createMcpExpressApp(); // express() + express.json() + validación de Host/Origin
const node = toNodeHandler(handler);
app.all('/mcp', (req, res) => void node(req, res, req.body));

const PORT = Number(process.env.PORT ?? 3000);
const httpServer = app.listen(PORT, '127.0.0.1', () => console.error(`MCP en http://127.0.0.1:${PORT}/mcp`));

process.on('SIGTERM', async () => {
    await handler.close();
    httpServer.close();
});

createMcpHandler no valida Host, Origin ni tokens. De eso se encarga createMcpExpressApp(), que al enlazar a 127.0.0.1 solo acepta hosts locales. Si escuchas en 0.0.0.0, tienes que pasarle allowedHosts. Y como createMcpExpressApp() ya instala express.json(), el ejemplo pasa req.body como tercer argumento.

Si tu servidor v1 usaba StreamableHTTPServerTransport con sesiones, en v2 lo encuentras como NodeStreamableHTTPServerTransport en @modelcontextprotocol/node, pero solo sirve la era 2025 (no lo hemos probado). SSEServerTransport desaparece y queda una copia congelada en @modelcontextprotocol/server-legacy/sse.

Lo que cambia sin dar error de compilación

Algunos cambios no rompen la compilación, pero sí los tests. Una tool desconocida ya no devuelve isError: true, sino que callTool rechaza con un ProtocolError de código -32602. Un resource no encontrado se lanza con ResourceNotFoundError y viaja con -32602, no con -32002. En el cliente, structuredContent pasa a ser de tipo unknown, así que tendrás que estrechar el tipo antes de leer sus propiedades.

La lección Servidor en TypeScript construye un servidor completo con la API v2, y el tutorial Crear un servidor MCP en TypeScript es la versión corta.

Python: de mcp 1.x o fastmcp a mcp 2.x

Si vienes del paquete fastmcp

Aquí el cambio es pequeño. El paquete independiente fastmcp va por la 4.0.10, está construido sobre el SDK oficial v2 y negocia 2026-07-28. Lo comprobamos con un servidor escrito al estilo de 2025, sin tocar una línea. Este es su principio (el archivo sigue con dos resources, un prompt y mcp.run()):

from fastmcp import FastMCP

mcp = FastMCP("Biblioteca")

LIBROS = {"dune": "Frank Herbert", "neuromante": "William Gibson"}


@mcp.tool()
def sumar(a: int, b: int) -> int:
    """Suma dos números."""
    return a + b

Las tres primeras líneas que imprime un cliente de fastmcp 4.0.10 contra ese servidor:

$ python cliente_fastmcp.py
protocolo: 2026-07-28 | servidor: Biblioteca 4.0.10
tools: ['sumar']
sumar -> 5 {'result': 5} False

Lo que se rompe está en otra parte. Cualquier ctx.elicit() falla en conexiones 2026-07-28, lo vemos en la sección de elicitation. Según la documentación de fastmcp 4, además, ctx.sample() y ctx.list_roots() se eliminaron (eso no lo hemos ejecutado).

Si vienes de mcp 1.x

En el SDK oficial 2.x la clase FastMCP se llama MCPServer. Si actualizas el paquete y no cambias el import, el error ya te dice qué hacer:

$ python -c "from mcp.server.fastmcp import FastMCP"
ModuleNotFoundError: No module named 'mcp.server.fastmcp'. This is mcp 2.x, where FastMCP was renamed to MCPServer (from mcp.server.mcpserver import MCPServer) and other APIs changed; see the migration guide at https://py.sdk.modelcontextprotocol.io/v2/migration/#fastmcp-renamed-to-mcpserver or pin 'mcp<2' to keep running v1 code.

Los decoradores no cambian. Este es el principio del servidor migrado (siguen dos resources, un prompt y mcp.run()):

from mcp.server import MCPServer

mcp = MCPServer("Biblioteca", version="1.0.0", instructions="Servidor de ejemplo del curso.")

LIBROS = {"dune": "Frank Herbert", "neuromante": "William Gibson"}


@mcp.tool()
def sumar(a: int, b: int) -> int:
    """Suma dos números enteros."""
    return a + b

En nuestras pruebas salieron tres errores típicos de código v1. Pasar el puerto al constructor ya no vale, porque las opciones de transporte van en run(). El decorador necesita los paréntesis. Y en el cliente los campos pasan a snake_case:

$ MCPServer('x', port=9000)
TypeError: MCPServer.__init__() got an unexpected keyword argument 'port'

$ @m.tool   (sin paréntesis)
TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool

$ tool.inputSchema
AttributeError: 'Tool' object has no attribute 'inputSchema'

tool.inputSchema, result.isError y result.structuredContent pasan a ser tool.input_schema, result.is_error y result.structured_content. El JSON que viaja por la red sigue en camelCase. ToolError se importa ahora de mcp.server.mcpserver.exceptions, y McpError(ErrorData(...)) pasa a MCPError(code=..., message=...).

Si tu cliente usa el estilo antiguo, con stdio_client(...), ClientSession y await session.initialize(), sigue funcionando, pero negocia 2025-11-25. Para la versión nueva usa async with Client(StdioServerParameters(...)) as client:.

HTTP en Python

Para servir por Streamable HTTP sin estado, pasa las opciones a run():

from servidor import mcp

if __name__ == "__main__":
    mcp.run(transport="streamable-http", host="127.0.0.1", port=8765, stateless_http=True, json_response=True)

stateless_http=True solo cambia cómo se atiende a los clientes de 2025, porque el tráfico 2026-07-28 nunca abre sesión. Si montas el servidor dentro de Starlette o FastAPI con mcp.streamable_http_app(), tienes que ejecutar mcp.session_manager.run() en el lifespan de tu aplicación. Con mcp.run(...) ya lo hace el SDK.

Importante: con json_response=True el servidor responde con un único cuerpo JSON y se pierden las notificaciones de progreso de la llamada en curso. Si tus tools informan del progreso, deja esa opción en su valor por defecto.

La lección Servidor en Python recorre un servidor entero con MCPServer, y el tutorial Crear un servidor MCP en Python es la versión corta.

Elicitation y sampling: el servidor ya no pregunta, devuelve

De todos los cambios de comportamiento, este es el que más probablemente te romperá algo. En 2026-07-28 el servidor no puede iniciar peticiones. Cuando necesita un dato del usuario, la tool devuelve un resultado input_required y el cliente reintenta la misma llamada con las respuestas. La lección Interacción con el usuario y extensiones explica el patrón mensaje a mensaje.

En Python pusimos la tool antigua, que pedía datos con ctx.elicit(), y la nueva en el mismo servidor. Este extracto omite los imports y el modelo Pydantic Confirmacion (plazas y confirmar):

async def pedir_confirmacion(destino: str) -> Elicit[Confirmacion]:
    """Resolver: recibe los argumentos de la tool por nombre y devuelve la pregunta."""
    return Elicit(f"¿Cuántas plazas reservo para {destino}?", Confirmacion)


@mcp.tool()
async def reservar(
    destino: str,
    respuesta: Annotated[ElicitationResult[Confirmacion], Resolve(pedir_confirmacion)],
    ctx: Context,
) -> str:
    """Reserva plazas para un destino; pregunta al usuario cuántas."""
    if isinstance(respuesta, AcceptedElicitation) and respuesta.data.confirmar:
        return f"Reservadas {respuesta.data.plazas} plazas a {destino} (protocolo {ctx.protocol_version})."
    return f"Reserva no realizada (acción del usuario: {respuesta.action})."


@mcp.tool()
async def reservar_push(destino: str, ctx: Context) -> str:
    """Patrón ANTIGUO: elicitation iniciada por el servidor. Solo funciona en conexiones legacy."""
    r = await ctx.elicit(f"¿Cuántas plazas para {destino}?", Confirmacion)
    return f"acción={r.action}"

Con un cliente 2026-07-28, reservar funciona y reservar_push falla con este error:

MCPError -32600 Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.

Con fastmcp 4.0.10 el mensaje es otro: ToolError: elicitation via server-initiated requests is unavailable on 2026-07-28 connections. Allí no hay Resolve, y su documentación indica devolver InputRequiredResult a mano y leer ctx.input_responses. Ese camino no lo hemos ejecutado.

La versión con Resolve sirve con el mismo cuerpo a clientes de 2025, porque el SDK convierte la pregunta en un elicitation/create clásico. En la migración eso ahorra mantener dos tools. El parámetro resuelto tampoco aparece en el input_schema, así que el modelo solo ve destino.

Importante: la tool se ejecuta desde el principio en cada ronda. Si escribe en una base de datos antes de tener la respuesta, lo hará dos veces. Pon los efectos después de la pregunta.

En TypeScript, server.server.elicitInput(...) pasa a ctx.mcpReq.elicitInput(...), que solo funciona en la era 2025. Para las dos eras, el SDK recomienda devolver inputRequired(...) desde el handler. Este extracto de nuestra prueba omite los imports (McpServer, inputRequired, inputResponse, acceptedContent y createRequestStateCodec salen de @modelcontextprotocol/server):

type Estado = { paso: 'confirmado'; entorno: string };
// Firmado (HMAC-SHA256), no cifrado. En producción la clave se comparte entre instancias.
const codec = createRequestStateCodec<Estado>({ key: crypto.getRandomValues(new Uint8Array(32)), ttlSeconds: 600 });

const confirmacion = z.object({ confirmar: z.boolean().meta({ title: '¿Confirmas el despliegue?' }) });
const version = z.object({ version: z.string().meta({ title: 'Versión a desplegar' }) });

function buildServer() {
    const server = new McpServer({ name: 'despliegues', version: '1.0.0' }, { requestState: { verify: codec.verify } });

    server.registerTool(
        'desplegar',
        { description: 'Despliega tras pedir confirmación y versión al usuario', inputSchema: z.object({ entorno: z.string() }) },
        async ({ entorno }, ctx) => {
            const estado = ctx.mcpReq.requestState<Estado>();

            if (estado?.paso !== 'confirmado') {
                // Ronda 1 (o reentrada con la respuesta de la ronda 1)
                const vista = inputResponse(ctx.mcpReq.inputResponses, 'confirmar');
                if (vista.kind === 'elicit' && vista.action !== 'accept') {
                    return { content: [{ type: 'text', text: `Despliegue cancelado por el usuario (${vista.action})` }], isError: true };
                }
                const ok = acceptedContent(ctx.mcpReq.inputResponses, 'confirmar', confirmacion);
                if (ok?.confirmar !== true) {
                    return inputRequired({
                        inputRequests: { confirmar: inputRequired.elicit({ message: `¿Desplegar en ${entorno}?`, requestedSchema: confirmacion }) }
                    });
                }
                // Ronda 2: lo ya demostrado viaja en requestState (el servidor no guarda nada entre rondas)
                return inputRequired({
                    inputRequests: { version: inputRequired.elicit({ message: '¿Qué versión?', requestedSchema: version }) },
                    requestState: await codec.mint({ paso: 'confirmado', entorno })
                });
            }

            const v = acceptedContent(ctx.mcpReq.inputResponses, 'version', version);
            if (v === undefined) {
                return { content: [{ type: 'text', text: 'No se indicó versión' }], isError: true };
            }
            return { content: [{ type: 'text', text: `Desplegado ${v.version} en ${estado.entorno}` }] };
        }
    );
    return server;
}

inputResponses solo trae las respuestas de la última ronda. Lo que ya sabías de rondas anteriores tiene que viajar en requestState, y ese estado vuelve desde el cliente, así que la especificación obliga a tratarlo como entrada controlada por un atacante. Con createRequestStateCodec y requestState.verify, un estado manipulado se rechaza con -32602 antes de llegar al handler. Si tienes varias instancias, la clave de firma tiene que ser la misma en todas. En Python, la documentación de despliegue del SDK usa RequestStateSecurity(keys=[...]) para varias réplicas; no lo hemos probado.

Al probar es fácil toparse con el -32021, que aparece cuando el cliente no declara la capacidad de elicitation en la petición. En el cliente Python, la capacidad se declara al pasar elicitation_callback.

Sampling sigue el mismo camino. En TypeScript, server.server.createMessage(...) pasa a ctx.mcpReq.requestSampling(...), ya deprecado, o a inputRequired.createMessage dentro del patrón de varias rondas. Antes de migrarlo, lee la sección siguiente.

Roots, Sampling y Logging: deprecados, no retirados

Las tres features siguen funcionando en 2026-07-28, pero entran en la ventana de deprecación. La retirada más temprana posible es la primera revisión publicada a partir del 28 de julio de 2027. La especificación pide que las implementaciones existentes migren, y para cada una propone una ruta. Los roots pasan a ser parámetros de la tool, URIs de resources o configuración del servidor. Para sampling, tu servidor se integra directamente con la API del proveedor del modelo. Y el logging va a stderr si usas stdio, o a OpenTelemetry.

Logging cambia además de forma visible. logging/setLevel ya no existe, y el cliente pide el nivel en cada petición con la clave io.modelcontextprotocol/logLevel de _meta. Si la petición no la trae, el servidor no debe emitir notifications/message. Según la tabla de eras del SDK de TypeScript, eso significa que en peticiones 2026-07-28 tus ctx.mcpReq.log() no producen nada salvo que el cliente pida un nivel. En Python, ctx.info() sigue existiendo, pero la documentación del SDK dice que emite un MCPDeprecationWarning y recomienda logging, que escribe en stderr. Ninguna de las dos cosas la hemos ejecutado.

Sesiones: dónde va ahora el estado

Sin sesiones, todo lo que guardabas por sesión necesita otro sitio. La especificación exige referenciar ese estado con un identificador explícito que el cliente pasa en cada petición. Su ejemplo es una tool create_basket que devuelve un basket_id, y una add_item que lo recibe como argumento.

El carrito o el inquilino elegido pasan entonces a un almacenamiento de tu aplicación, direccionado por ese identificador y ligado al usuario autenticado. La guía de migración de Cloudflare lo resume en una tabla: los datos que antes indexabas por sesión MCP van a un Durable Object, D1, KV o R2, detrás de un identificador autenticado que emite el servidor.

En TypeScript, createMcpHandler ejecuta la fábrica una vez por petición HTTP. Las tools y los resources se registran dentro de la fábrica, pero los pools de conexiones y las cachés van fuera, a nivel de módulo. Si abres un pool dentro de la fábrica, abrirás uno por petición. En Python, según las notas de la versión 2 del SDK, el lifespan de Streamable HTTP se ejecuta una sola vez al arrancar, así que un pool creado ahí se comparte entre todas las peticiones.

Tasks pasa a ser una extensión

Si tu servidor usaba las tasks experimentales de 2025-11-25, esta es la parte que más trabajo da. Tasks sale del núcleo y pasa a la extensión oficial io.modelcontextprotocol/tasks, con otro diseño. El cliente consulta el estado con tasks/get en lugar de bloquearse en tasks/result, que desaparece igual que tasks/list, y envía datos con tasks/update. Ahora decide el servidor si una llamada se convierte en task, y de momento solo se aplica a tools/call.

Hoy solo uno de los tres SDK que probamos la implementa en el servidor. El SDK v2 de TypeScript eliminó registerToolTask, taskStore e InMemoryTaskStore sin sustituto, y @modelcontextprotocol/ext-tasks 0.2.2 es solo de lado cliente. El oficial de Python, mcp 2.2.0, tampoco la trae: importar mcp.server.experimental da un ModuleNotFoundError. La que sí la implementa es fastmcp 4, con el paquete opcional fastmcp-tasks:

import asyncio

from fastmcp import Context, FastMCP
from fastmcp_tasks import TasksExtension

mcp = FastMCP("Informes")
mcp.add_extension(TasksExtension())  # backend por defecto: memory://


@mcp.tool(task=True)
async def generar_informe(paginas: int, ctx: Context) -> str:
    """Genera un informe largo en segundo plano."""
    for i in range(1, paginas + 1):
        await asyncio.sleep(0.4)
        await ctx.report_progress(i, paginas, f"página {i}/{paginas}")
    return f"Informe de {paginas} páginas listo."

task=True exige una función async. El backend por defecto, memory://, vive en un solo proceso y se pierde al reiniciar. Un cliente de 2025 que llama a esta tool la ejecuta de forma síncrona y recibe el resultado directamente.

Atender a clientes de 2025 y de 2026 a la vez

Los dos SDK oficiales, y también fastmcp 4, sirven las dos eras desde el mismo punto de entrada sin configuración extra. La especificación llama dual-era a este servidor. Cuando la petición llega con el _meta nuevo, la atiende sin estado; cuando llega un initialize, aplica la semántica antigua.

En TypeScript, serveStdio sirve también la era 2025 salvo que pases legacy: 'reject', y createMcpHandler hace lo mismo con su opción por defecto, legacy: 'stateless'. Lo vimos con el servidor del paso 4, arrancado en el puerto 3111. Conectamos un cliente en modo auto y después otro en modo legacy al mismo endpoint, y la fábrica registró una línea por petición:

MCP en http://127.0.0.1:3111/mcp
[servidor] petición de la era modern
[servidor] petición de la era modern
[servidor] petición de la era modern
[servidor] petición de la era modern
[servidor] petición de la era modern
[servidor] petición de la era legacy
[servidor] petición de la era legacy
[servidor] petición de la era legacy
[servidor] petición de la era legacy
[servidor] petición de la era legacy
[servidor] petición de la era legacy

El modo sin estado tiene un límite con los clientes de 2025. Por HTTP sin estado, un cliente de 2025 no puede recibir peticiones del servidor, así que la elicitation empujada falla. La documentación del SDK de TypeScript dice que el shim responde con un rechazo de capability (no lo hemos reproducido). Cloudflare documenta lo mismo para su handler, que además devuelve 405 a GET y DELETE.

Si necesitas mantener un servidor con sesiones para los clientes antiguos, la guía de migración de Cloudflare publica este patrón para Workers, que enruta cada petición a un handler u otro:

import { isLegacyRequest } from "@modelcontextprotocol/server";
import { createMcpHandler } from "agents/mcp/server";

const stateless = createMcpHandler(createStatelessServer, {
	route: "/mcp",
	legacy: "reject",
});

const legacy = MyMcpAgent.serve("/mcp");

export default {
	async fetch(request: Request, env: Env, ctx: ExecutionContext) {
		if (await isLegacyRequest(request)) {
			return legacy.fetch(request, env, ctx);
		}
		return stateless(request, env, ctx);
	},
} satisfies ExportedHandler<Env>;

Mantén legacy: "reject" en el handler sin estado. Si no, su carril de compatibilidad se queda con las peticiones antiguas antes de que lleguen a la ruta con sesiones.

Importante: si tienes un proxy o un gateway delante, comprueba que conserva las cabeceras MCP-Protocol-Version, Mcp-Method y Mcp-Name. En Streamable HTTP, MCP-Protocol-Version es obligatoria en todo POST, Mcp-Method en todas las peticiones y Mcp-Name en tools/call, resources/read y prompts/get.

La lección Estado actual y evolución trata cómo mantener un servidor al día atendiendo dos versiones.

Cómo comprobar que negocias 2026-07-28

Que las llamadas salgan bien no demuestra nada, porque un servidor que cae a 2025-11-25 también responde. Hay que mirar la versión negociada, y lo más rápido es una sonda server/discover con curl (aquí, contra el servidor del paso 4 en el puerto 3000):

# Sonda de la era 2026-07-28
curl -s -X POST http://127.0.0.1:3000/mcp \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: server/discover' \
  -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"curl","version":"0"},"io.modelcontextprotocol/clientCapabilities":{}}}}'

Si el servidor está migrado, la respuesta incluye supportedVersions y lleva serverInfo dentro de _meta:

{"result":{"supportedVersions":["2026-07-28"],"capabilities":{"tools":{"listChanged":true},"resources":{"listChanged":true},"prompts":{"listChanged":true}},"resultType":"complete","ttlMs":0,"cacheScope":"private","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"curso-demo","version":"1.0.0"}}},"jsonrpc":"2.0","id":1}

Si pides una versión que el servidor no soporta, recibes un -32022 con las versiones que sí acepta. Así respondió nuestro servidor Python de prueba a una petición con 2027-01-01:

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

Con el Inspector, pasa siempre --protocol-era modern. En ejecuciones sueltas su valor por defecto es legacy, y con esa era un servidor sin migrar también responde. Nosotros lo lanzamos por stdio con un archivo de configuración:

npx @modelcontextprotocol/inspector@2.9.0 --cli --config .inspector/config.json --server demo \
  --protocol-era modern --format json --method tools/list

Con --server apuntando al servidor del paso 3, que solo habla 2025, el mismo comando falla con el Version negotiation failed que ya vimos y código de salida 1. En un CI eso es justo lo que quieres.

En los tests, ejecuta las mismas aserciones contra las dos eras. En TypeScript, InMemoryTransport.createLinkedPair() solo conecta la era 2025. Para probar 2026-07-28 sin sockets, el SDK propone pasar handler.fetch como fetch del StreamableHTTPClientTransport, y nuestros tests comprueban después client.getProtocolEra(). En Python, Client(mcp) en memoria ya negocia 2026-07-28, y puedes parametrizar el modo. Este es uno de nuestros tests de pytest (sin los imports ni la fixture anyio_backend):

@pytest.mark.anyio
@pytest.mark.parametrize("mode", ["auto", "legacy"])
async def test_elicitation_en_ambas_eras(mode: str):
    async def responder(ctx: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
        return ElicitResult(action="accept", content={"plazas": 3, "confirmar": True})

    async with Client(mcp_reservas, mode=mode, elicitation_callback=responder) as c:
        r = await c.call_tool("reservar", {"destino": "Lisboa"})
        assert "Reservadas 3 plazas a Lisboa" in r.content[0].text

Un test que dependa de ctx.elicit() fallará con Client(mcp), porque negocia 2026-07-28 por defecto, salvo que le pases mode="legacy". Si te pasa, te queda una tool por migrar.

Si algo no cuadra, en Cómo depurar un servidor MCP tienes los síntomas habituales ordenados por capa. La lección Testing y depuración monta los tests de protocolo en las dos versiones y su integración en CI, y Cómo viaja una petición explica qué lleva cada petición y cómo se negocia la versión.

Aprende esto en el curso

La lección 1 la puedes leer ya, sin registro. Las otras 13 del Básico son gratis con una cuenta.

Empieza gratis

Fuentes

  1. Changelog de la especificación 2026-07-28
  2. Especificación 2026-07-28: versionado y compatibilidad
  3. Especificación 2026-07-28: Multi Round-Trip Requests
  4. Especificación 2026-07-28: features deprecadas
  5. Extensión Tasks
  6. Guía de migración del SDK de Python v2
  7. Migrate to MCP SDK v2 (Cloudflare)

Artículos relacionados