Cómo migrar tu servidor MCP a FastMCP 4 y al SDK de Python 2
Índice de contenidos
- Puntos clave
- Qué cambia con FastMCP 4 y el SDK de Python 2
- El servidor de partida con el SDK 1.30
- Cómo montar el banco de pruebas en Docker
- Qué falla al actualizar a mcp 2.2.0 y cómo se arregla
- ModuleNotFoundError: No module named 'mcp.server.fastmcp'
- ImportError: cannot import name 'McpError'
- TypeError: unexpected keyword argument 'host'
- Los tres cambios que no dan error
- ctx.elicit() sin canal de vuelta en 2026-07-28
- El cliente 1.x con errores de protocolo
- Cómo migrar a FastMCP 4 en lugar de a MCPServer
- Qué rompe FastMCP 4 si vienes de FastMCP 3
- Cómo probar el servidor migrado con un cliente real
- Qué pasa detrás de un balanceador con dos réplicas
- Preguntas frecuentes
- ¿Tengo que migrar ya si mi servidor funciona con mcp 1.x?
- ¿MCPServer o FastMCP 4?
- ¿Los clientes antiguos siguen funcionando contra el servidor migrado?
- Conclusión
- Fuentes
Si tu servidor importa mcp.server.fastmcp, actualizar el paquete mcp a la versión 2 lo rompe: FastMCP pasó a llamarse MCPServer. Tienes dos destinos, MCPServer o FastMCP 4, y en los dos hay que mover host y port a run(), construir MCPError con código y mensaje y sustituir ctx.elicit, que falla en conexiones 2026-07-28.
Si tu servidor del Model Context Protocol (MCP) empieza con from mcp.server.fastmcp import FastMCP, el próximo pip install -U mcp lo tumba en la primera línea. El kit de desarrollo de software (SDK) oficial de Python publicó su versión 2.0.0 el 28 de julio de 2026 y renombró FastMCP a MCPServer, y FastMCP 4.0.0, el proyecto independiente, salió el 31 de agosto sobre esa misma base. Aquí migro un servidor pequeño pero real a los dos destinos, con cada error tal como apareció, su arreglo y la prueba con clientes reales por stdio, por Streamable HTTP y detrás de un balanceador. Si partes de cero, empieza por construir un servidor MCP propio. Tienes esta guía también en inglés.
Puntos clave
- Con
mcp2.2.0, el servidor antiguo falla en la importación conModuleNotFoundError: No module named 'mcp.server.fastmcp'. Hay dos salidas:MCPServerdel propio SDK ofrom fastmcp import FastMCPcon FastMCP 4.0.10. - En los dos destinos,
hostyportpasan arun(), yMcpError(ErrorData(...))provocaTypeErrorporque el error se construye con código y mensaje. - Tres cambios no dan error: con
MCPServer, las instrucciones pasadas por posición acaban entitle, el texto de las excepciones deja de llegar al cliente y la versión del servidor sale vacía. ctx.elicit()solo funciona en conexiones 2025-11-25. En 2026-07-28,MCPServerlo resuelve conResolve(Elicit(...))y FastMCP 4 obliga a devolver unInputRequiredResult.- Detrás de nginx con dos réplicas por turnos, el cliente 2026-07-28 pasó 5 de 5 pruebas; el mismo cliente en modo sesión, 1 de 5, y el cliente 1.x, ninguna.
Qué cambia con FastMCP 4 y el SDK de Python 2
El SDK 2 reescribe la capa de protocolo para la revisión 2026-07-28 de MCP, en la que cada petición va sin sesión. Aun así, sigue sirviendo a los clientes antiguos desde el mismo servidor.
Las notas de la versión 2.0.0 del SDK[1] lo resumen en tres hechos. pip install mcp instala ya la 2.x, "FastMCP is now MCPServer" y la rama 1.x "will only receive security fixes". Qué rompe esa revisión en el protocolo lo explica el artículo sobre la especificación MCP 2026-07-28.
FastMCP, el proyecto de PrefectHQ del que salió la clase original, se construye en su versión 4 sobre el SDK 2. Las notas de FastMCP 4.0.0[2] hablan de cinco betas en cinco semanas, 23 colaboradores y más de 80 pull requests, y describen así el cambio de fondo: "modern requests are sessionless and self-contained, so any replica behind an ordinary load balancer can answer them".
Estas son las versiones que usé el 27 de septiembre de 2026, según los índices de PyPI de mcp[3] y fastmcp[4]:
| Paquete | Versión | Publicada | Depende de |
|---|---|---|---|
mcp (rama 1.x) |
1.30.0 | 2026-09-07 | nada del SDK 2 |
mcp |
2.2.0 | 2026-09-07 | mcp-types 2.2.0, httpx2 |
fastmcp (rama 3.x) |
3.4.7 | 2026-08-10 | mcp>=1.24.0,<2.0 |
fastmcp |
4.0.10 | 2026-09-25 | mcp>=2.0.0,<3.0.0 |
FastMCP publica casi a diario (diez parches entre el 31 de agosto y el 25 de septiembre), así que fija la versión exacta en tu requirements.txt. El entorno virtual de FastMCP 4 ocupó 115 MB frente a los 71 MB del SDK 2 con su extra cli.
El servidor de partida con el SDK 1.30
El servidor de prueba guarda notas en SQLite y tiene las piezas de un servidor MCP real. Son cuatro herramientas, un recurso con plantilla notas://{nota_id}, un prompt, un error de validación con McpError y una confirmación con ctx.elicit() antes de borrar. Esta es la cabecera y la primera herramienta, tal como funcionaban con mcp 1.30.0 (la función db() abre la base y crea la tabla):
import os
import sqlite3
import sys
from pydantic import BaseModel
from mcp.server.fastmcp import Context, FastMCP
from mcp.shared.exceptions import McpError
from mcp.types import INVALID_PARAMS, ErrorData
DB = os.environ.get("NOTAS_DB", "/tmp/notas.db")
mcp = FastMCP("notas", "Guarda y busca notas cortas del equipo.",
host="0.0.0.0", port=8000)
@mcp.tool()
def crear_nota(titulo: str, cuerpo: str) -> int:
"""Guarda una nota y devuelve su id."""
if not titulo.strip():
raise McpError(ErrorData(code=INVALID_PARAMS,
message="El título está vacío"))
with db() as con:
cur = con.execute("insert into notas(titulo, cuerpo) values (?, ?)",
(titulo, cuerpo))
return cur.lastrowid
El segundo argumento posicional del constructor son las instrucciones que el cliente pasa al modelo, y host y port van también en el constructor. Las dos cosas cambian en la versión 2. Las otras dos herramientas que importan para la migración son estas:
@mcp.tool()
def leer_nota(nota_id: int) -> str:
"""Devuelve el cuerpo de una nota."""
with db() as con:
fila = con.execute("select cuerpo from notas where id = ?",
(nota_id,)).fetchone()
if fila is None:
raise ValueError(f"No existe la nota {nota_id}")
return fila[0]
class Confirmacion(BaseModel):
borrar: bool
@mcp.tool()
async def borrar_nota(nota_id: int, ctx: Context) -> str:
"""Borra una nota después de pedir confirmación al usuario."""
r = await ctx.elicit(f"¿Borrar la nota {nota_id}?", Confirmacion)
if r.action != "accept" or not r.data.borrar:
return "Cancelado"
with db() as con:
con.execute("delete from notas where id = ?", (nota_id,))
await ctx.info(f"nota {nota_id} borrada")
return f"Nota {nota_id} borrada"
leer_nota lanza un ValueError genérico y borrar_nota pide confirmación con ctx.elicit(), que en MCP se llama elicitación: el servidor pregunta al usuario a mitad de una llamada. El archivo termina con mcp.run(transport=sys.argv[1] if len(sys.argv) > 1 else "stdio").
Cómo montar el banco de pruebas en Docker
Cada versión vive en su propio entorno virtual dentro de un contenedor python:3.14.7-slim-trixie, así las cuatro conviven en el mismo directorio sin tocar el Python del sistema. Lo hice en un equipo aarch64 con Docker; con --user 1000:1000 los archivos que crea el contenedor quedan a tu nombre:
mkdir notas && cd notas
docker run --rm --user 1000:1000 -e HOME=/work -v "$PWD":/work -w /work \
python:3.14.7-slim-trixie sh -c '
python -m venv venv-sdk1 && venv-sdk1/bin/pip install -q "mcp[cli]==1.30.0"
python -m venv venv-sdk2 && venv-sdk2/bin/pip install -q "mcp[cli]==2.2.0"
python -m venv venv-fm4 && venv-fm4/bin/pip install -q "fastmcp==4.0.10"'
El servidor va en v1/server.py. A su lado, v1/cliente.py usa el cliente de la rama 1.x: ClientSession con stdio_client o streamablehttp_client. Crea una nota, la busca, lee una que no existe, envía un título vacío, lee el recurso y borra la nota aceptando la confirmación. La línea base pasa entera:
docker run --rm --user 1000:1000 -e HOME=/work -v "$PWD":/work -w /work/v1 \
python:3.14.7-slim-trixie /work/venv-sdk1/bin/python cliente.py stdio
La salida muestra el identificador del servidor, la versión de protocolo negociada y el resultado de cada llamada:
server: notas 1.30.0 proto: 2025-11-25
instructions: Guarda y busca notas cortas del equipo.
tools: ['crear_nota', 'buscar', 'leer_nota', 'borrar_nota']
crear_nota -> 1
buscar -> {'result': [{'id': 1, 'titulo': 'Backups'}]}
leer_nota(999) -> True Error executing tool leer_nota: No existe la nota 999
crear_nota(vacío) -> True Error executing tool crear_nota: El título está vacío
recurso -> restic cada noche
elicit: ¿Borrar la nota 1?
borrar_nota -> Nota 1 borrada
Fíjate en la versión: 1.30.0 es la del SDK, no la de tu servidor, porque el constructor no la recibía. Ese detalle cambia en la versión 2.
Qué falla al actualizar a mcp 2.2.0 y cómo se arregla
Al ejecutar el mismo archivo con venv-sdk2, el servidor cae en la importación y después va encadenando un error por arranque hasta que funciona. Este es el orden en que aparecieron, con el arreglo que indica la guía de migración del SDK de v1 a v2[5]. He partido las líneas largas de los mensajes para que quepan.
ModuleNotFoundError: No module named ‘mcp.server.fastmcp’
La primera ejecución no llega a crear el servidor:
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.
El mensaje con el enlace a la guía llegó en la 2.1.1 y en la 2.0.1. Si todavía no puedes migrar, fija mcp>=1.28,<2 en tus dependencias. Para seguir, cambia la importación a from mcp.server.mcpserver import Context, MCPServer y la clase a MCPServer("notas", ...). Los decoradores @mcp.tool(), @mcp.resource() y @mcp.prompt() no cambian.
ImportError: cannot import name ‘McpError’
La excepción de protocolo también cambió de nombre:
ImportError: cannot import name 'McpError' from 'mcp.shared.exceptions'
(...). Did you mean: 'MCPError'?
Renombrar no basta, porque el constructor también cambió. Con solo el nombre nuevo, MCPError(ErrorData(...)) lanza TypeError: MCPError.__init__() missing 1 required positional argument: 'message' en cuanto alguien envía un título vacío. La forma nueva es raise MCPError(INVALID_PARAMS, "El título está vacío"), importando MCPError directamente desde mcp.
TypeError: unexpected keyword argument ‘host’
Los parámetros de transporte salieron del constructor:
TypeError: MCPServer.__init__() got an unexpected keyword argument 'host'
Pasan a run(). Lo mismo ocurre con port, json_response, stateless_http, streamable_http_path, event_store y transport_security:
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "streamable-http":
mcp.run(transport="streamable-http", host="0.0.0.0", port=8000)
else:
mcp.run(transport="stdio")
Los tres cambios que no dan error
Con esos tres arreglos el servidor arranca y el cliente 1.x lista las herramientas, pero la salida ya no es la misma:
server: notas proto: 2025-11-25
instructions: None
tools: ['crear_nota', 'buscar', 'leer_nota', 'borrar_nota']
crear_nota -> 1
leer_nota(999) -> True Error executing tool leer_nota
crear_nota(vacío) -> True Error executing tool crear_nota
Ninguna de estas tres regresiones deja rastro en el cliente:
- Instrucciones perdidas: el constructor de
MCPServerintercalatitleydescriptionantes deinstructions, así que tu texto se envía como título y el modelo deja de recibir instrucciones. Pasainstructions=por nombre - Versión vacía: un servidor sin
version=anuncia una cadena vacía en lugar de la versión del SDK. Pasaversion="2.0.0" - Errores mudos: desde la 2.1.0, una excepción inesperada en una herramienta llega al cliente como
Error executing tool leer_nota, sin el texto. La traza completa queda en el registro del servidor. Si el mensaje es para el modelo, lanzaToolErrordesdemcp.server.mcpserver.exceptions
Hay un cuarto cambio que sí avisa, en el registro del servidor: ctx.info() emite MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577).. Funciona en las dos eras del protocolo, así que puedes dejarlo o sustituirlo por el módulo logging de Python.
ctx.elicit() sin canal de vuelta en 2026-07-28
El cliente del SDK 2 negocia por defecto la revisión 2026-07-28 (mode="auto"), en la que el servidor ya no puede enviar peticiones al cliente. La confirmación de borrar_nota falla en el lado del cliente:
mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this
transport context has no back-channel for server-initiated requests.
La documentación de elicitación del SDK[6] recomienda un resolvedor: un parámetro anotado con Resolve(fn) que el SDK rellena antes de ejecutar la herramienta. Si fn devuelve Elicit(...), el SDK hace la pregunta por el canal que tenga la conexión y el modelo nunca ve ese parámetro:
async def pedir_ok(nota_id: int) -> Elicit[Confirmacion]:
return Elicit(f"¿Borrar la nota {nota_id}?", Confirmacion)
@mcp.tool()
async def borrar_nota(
nota_id: int,
ok: Annotated[ElicitationResult[Confirmacion], Resolve(pedir_ok)],
) -> str:
"""Borra una nota después de pedir confirmación al usuario."""
match ok:
case AcceptedElicitation(data=Confirmacion(borrar=True)):
with db() as con:
con.execute("delete from notas where id = ?", (nota_id,))
return f"Nota {nota_id} borrada"
return "Cancelado"
Elicit, ElicitationResult, AcceptedElicitation y Resolve se importan desde mcp.server.mcpserver. Con este cambio, el servidor pasó la batería completa con el cliente del SDK 2 en mode="auto" (2026-07-28) y en mode="legacy" (2025-11-25), tanto por stdio como por HTTP.
El cliente 1.x con errores de protocolo
Un MCPError lanzado dentro de una herramienta ya no se convierte en un resultado con isError. Ahora llega como error JSON-RPC, con su código. El cliente del SDK 2 lo recibe como excepción (MCPError -32602 El título está vacío), y un cliente 1.x que no lo esperaba se cae con mcp.shared.exceptions.McpError: El título está vacío. Si tienes clientes antiguos o quieres que el modelo lea el error y corrija el título, usa ToolError en su lugar.
Cómo migrar a FastMCP 4 en lugar de a MCPServer
La guía de FastMCP para quien viene del SDK v1[7] promete que "for most servers, it’s a single import change": from fastmcp import Context, FastMCP. En mi servidor hicieron falta tres cambios más, y los errores fueron más explícitos:
McpError: el mismoImportErrordesdemcp.shared.exceptions. FastMCP mantiene el nombre antiguo como alias enfastmcp.exceptions, con el constructor nuevo:McpError(code=INVALID_PARAMS, message="...")host:TypeError: FastMCP() no longer acceptshost. Passhosttorun_http_async(), or set FASTMCP_HOST.El arreglo esmcp.run(transport="http", host="0.0.0.0", port=8000). FastMCP llamahttpal transporte Streamable HTTPctx.elicit(): con un cliente 2026-07-28, la herramienta devuelveelicitation via server-initiated requests is unavailable on 2026-07-28 connections.
FastMCP 4 no tiene el Resolve del SDK, así que la herramienta tiene que distinguir la era de la conexión. En 2026-07-28 devuelve un InputRequiredResult con la pregunta, y el cliente repite la llamada con la respuesta en ctx.input_responses, tal como explica la documentación de elicitación de FastMCP[8]:
def pedir_ok(msg: str) -> InputRequiredResult:
esquema = Confirmacion.model_json_schema()
params = ElicitRequestFormParams(message=msg, requested_schema=esquema)
return InputRequiredResult(
result_type="input_required",
input_requests={"ok": ElicitRequest(method="elicitation/create",
params=params)})
@mcp.tool()
async def borrar_nota(nota_id: int,
ctx: Context) -> str | InputRequiredResult:
"""Borra una nota después de pedir confirmación al usuario."""
msg = f"¿Borrar la nota {nota_id}?"
if ctx.request_context.protocol_version < "2026-07-28":
r = await ctx.elicit(msg, Confirmacion)
ok = r.action == "accept" and r.data.borrar
elif ctx.input_responses is None:
return pedir_ok(msg)
else:
r = ctx.input_responses["ok"]
ok = r.action == "accept" and r.content["borrar"]
if not ok:
return "Cancelado"
Las clases ElicitRequest, ElicitRequestFormParams e InputRequiredResult vienen de mcp.types, y la comparación de cadenas funciona porque las revisiones son fechas ISO. Tras la línea return "Cancelado", la función borra la nota igual que antes.
Con el mismo servidor y el mismo cliente, los dos destinos no se comportan igual:
| Comportamiento observado | MCPServer (mcp 2.2.0) |
FastMCP 4.0.10 |
|---|---|---|
| Instrucciones como segundo argumento posicional | Acaban en title |
Siguen siendo instrucciones |
Versión si no pasas version= |
Cadena vacía | La de FastMCP (4.0.10) |
Texto de un ValueError en una herramienta |
Oculto al cliente | Llega al cliente |
McpError lanzado en una herramienta |
Error JSON-RPC | Resultado con is_error |
| Elicitación en las dos eras | Resolve(Elicit(...)) sin ramas |
Rama por versión de protocolo |
| Tamaño del entorno virtual | 71 MB | 115 MB |
Si tu servidor es pequeño y no necesitas middleware, proxies ni proveedores de autenticación, MCPServer te deja con una dependencia menos y una elicitación más limpia. Si ya usas algo de eso, o quieres el cambio mínimo, FastMCP 4 es el destino natural.
Qué rompe FastMCP 4 si vienes de FastMCP 3
FastMCP 3.4.7 fija mcp<2.0, así que un servidor en FastMCP 3 no se rompe al actualizar mcp: se rompe al actualizar fastmcp. Porté el mismo servidor a FastMCP 3.4.7, con una herramienta más que resume una nota con ctx.sample() usando el modelo del cliente, y lo ejecuté sin cambios con 4.0.10. La guía de FastMCP para quien viene de la 3[9] enumera doce puntos; en este servidor aparecieron cuatro:
McpErrordesdemcp.shared.exceptionsda el mismoImportError. Importa el alias defastmcp.exceptionsy constrúyelo concode=ymessage=ctx.sample()ya no existe:ToolError: Error calling tool 'resumir_nota': 'Context' object has no attribute 'sample'. La guía propone llamar a un modelo desde tu servidor con tu propia clave, pedir la generación con el patrónInputRequiredResulto quedarte en 3.x si usar el modelo de quien llama es la razón de ser del servidor. Yo eliminé la herramienta, porque el promptresumirya cubre ese casoctx.elicit()falla con el mismo error de era de antes. El parche temporal esClient(servidor, mode="legacy")en tus clientes, que lo hizo funcionar a la primera. El arreglo definitivo es la rama por versión de la sección anteriorClient("server.py")avisa conFastMCPDeprecationWarning: Inferring a stdio transport from the string 'server.py' is deprecated and will be removed in FastMCP 5. PasaPath("server.py")
La guía avisa además de otras dos roturas que no provocan error en tiempo de ejecución. FastMCP usa ahora httpx2, así que un except httpx.ConnectError alrededor de una llamada queda como código muerto. Y el código de error de recurso no encontrado pasa de -32002 a -32602.
Cómo probar el servidor migrado con un cliente real
El cliente del SDK 2 sustituye la pila ClientSession más transporte más initialize() por un único objeto Client, que recibe una URL o un StdioServerParameters y negocia la versión de protocolo solo. Este es el núcleo del cliente de prueba migrado:
async def main(destino: str) -> None:
if destino == "stdio":
destino = StdioServerParameters(
command=os.environ.get("SERVER_PY", sys.executable),
args=[os.environ.get("SERVER_FILE", "server.py")])
modo = os.environ.get("MODO", "auto")
async with Client(destino, mode=modo,
elicitation_callback=confirmar) as c:
info = c.server_info
print("server:", info.name, repr(info.version),
"proto:", c.protocol_version)
r = await c.call_tool("leer_nota", {"nota_id": 999})
print("leer_nota(999) ->", r.is_error, r.content[0].text)
try:
await c.call_tool("crear_nota", {"titulo": " ", "cuerpo": "x"})
except MCPError as e:
print("crear_nota(vacío) -> MCPError", e.code, e.message)
Los campos pasan a snake_case (is_error, structured_content, server_info) y la excepción se importa con from mcp import Client, MCPError, StdioServerParameters. confirmar es el mismo callback de elicitación de antes, que devuelve ElicitResult(action="accept", content={"borrar": True}). Para Streamable HTTP, levanta el servidor en una red de Docker y apunta el cliente a su URL:
docker network create mcp-net
docker run -d --name mcp-srv --network mcp-net --user 1000:1000 \
-e HOME=/work -v "$PWD":/work -w /work/sdk2 python:3.14.7-slim-trixie \
/work/venv-sdk2/bin/python server.py streamable-http
docker run --rm --network mcp-net --user 1000:1000 -e HOME=/work \
-v "$PWD":/work -w /work/sdk2 python:3.14.7-slim-trixie \
/work/venv-sdk2/bin/python cliente.py http://mcp-srv:8000/mcp
El servidor migrado a MCPServer respondió así, ya con la versión, las instrucciones y los errores en su sitio:
server: notas '2.0.0' proto: 2026-07-28
instructions: Guarda y busca notas cortas del equipo.
tools: ['crear_nota', 'buscar', 'leer_nota', 'borrar_nota']
crear_nota -> 1
leer_nota(999) -> True Error executing tool leer_nota: No existe la nota 999
crear_nota(vacío) -> MCPError -32602 El título está vacío
recurso -> restic cada noche
elicit: ¿Borrar la nota 1?
borrar_nota -> False Nota 1 borrada
Con MODO=legacy la salida es idéntica salvo proto: 2025-11-25. El cliente 1.x original también funciona contra el servidor migrado hasta el título vacío, donde se cae por el cambio de MCPError descrito antes. La versión FastMCP 4 del servidor pasó la misma batería por stdio y por HTTP con el cliente del SDK 2 y con fastmcp.Client, en los dos modos. El cliente 1.x la completó entera, porque FastMCP convierte el McpError en un resultado con is_error.
Como tercer cliente usé MCP Inspector 2.8.0, la versión publicada en npm el 23 de septiembre, en su modo CLI y en su interfaz web. Su versión 2.8.0 todavía negocia 2025-11-25, así que prueba la rama de ctx.elicit() del servidor FastMCP 4. La interfaz mostró el formulario de confirmación a partir del esquema de Confirmacion, y al enviarlo la herramienta devolvió el resultado:

Qué pasa detrás de un balanceador con dos réplicas
La revisión 2026-07-28 promete que cualquier réplica puede contestar cualquier petición. Lo probé con dos contenedores del servidor detrás de nginx 1.30.1 en reparto por turnos, compartiendo el archivo SQLite. Esta es la configuración del balanceador:
upstream mcp {
server mcp-a:8000;
server mcp-b:8000;
}
server {
listen 8080;
location /mcp {
proxy_pass http://mcp;
proxy_http_version 1.1;
proxy_buffering off;
proxy_set_header Host $host;
}
}
Lancé cada cliente cinco veces contra http://mcp-lb:8080/mcp. Las réplicas son los contenedores mcp-a y mcp-b. En una ejecución del cliente 2026-07-28 conté 8 peticiones POST, repartidas 4 y 4 entre las dos:
| Servidor detrás de nginx | Cliente | Ejecuciones correctas | Error |
|---|---|---|---|
| FastMCP 4.0.10 | SDK 2, mode="auto" (2026-07-28) |
5 de 5 | ninguno |
| FastMCP 4.0.10 | SDK 2, mode="legacy" (2025-11-25) |
1 de 5 | Session not found |
| FastMCP 4.0.10 | SDK 1.30.0 | 0 de 5 | Session terminated |
MCPServer sin clave compartida |
SDK 2, mode="auto" |
0 de 5 | Invalid or expired requestState |
MCPServer con clave compartida |
SDK 2, mode="auto" |
5 de 5 | ninguno |
La fila de MCPServer sin clave es la trampa que no aparece en ninguna traza local. El resolvedor de borrar_nota guarda su estado en un requestState sellado con una clave aleatoria por proceso. La guía de despliegue del SDK[10] lo explica: si el reintento cae en otra réplica, la llamada falla con -32602. El arreglo es dar a todas las réplicas la misma clave y el mismo nombre de servidor:
clave = os.environ.get("NOTAS_CLAVE")
sello = RequestStateSecurity(keys=[clave]) if clave else None
mcp = MCPServer("notas", version="2.0.0",
instructions="Guarda y busca notas cortas del equipo.",
request_state_security=sello)
Genera la clave con python -c "import secrets; print(secrets.token_hex(32))" y pásala a cada réplica como variable de entorno. FastMCP 4 tiene el mismo parámetro request_state_security, y lo necesitas en cuanto tu herramienta guarde algo en request_state.
Los clientes con sesión siguen necesitando afinidad: MCP Inspector 2.8.0 falló por el balanceador por turnos con un 404 Session not found y funcionó con ip_hash; en el bloque upstream. Desde mcp 2.2.0, además, una sesión antigua sin actividad caduca a los 30 minutos. Un servidor admite como mucho 10 000 sesiones, según las notas de la versión 2.2.0[11].
Preguntas frecuentes
¿Tengo que migrar ya si mi servidor funciona con mcp 1.x?
No de inmediato, pero fija mcp<2 hoy mismo: un pip install -U, un lockfile nuevo o una imagen reconstruida instalan ya la 2.x. La rama 1.x recibe solo correcciones de seguridad, y la 1.30.0 del 7 de septiembre ya cambió comportamientos por defecto, como la caducidad de sesiones inactivas.
¿MCPServer o FastMCP 4?
Con un servidor que solo usa decoradores, los dos exigen un trabajo parecido. MCPServer resuelve la elicitación en las dos eras sin ramas y pesa 44 MB menos. FastMCP 4 respeta más código antiguo (instrucciones posicionales, alias McpError, texto de los errores) y añade middleware, proxies y autenticación.
¿Los clientes antiguos siguen funcionando contra el servidor migrado?
Sí. Tanto MCPServer como FastMCP 4 sirvieron a un cliente 1.30.0 con la revisión 2025-11-25, por stdio y por HTTP. Las excepciones son los MCPError de MCPServer, que ahora llegan como error JSON-RPC, y los despliegues con dos o más réplicas sin afinidad de sesión.
Conclusión
Migrar un servidor MCP pequeño a FastMCP 4 o al SDK 2 lleva una tarde, y los errores que se ven se arreglan siguiendo el propio mensaje. Lo que merece tu tiempo es lo que no se ve: instrucciones que acaban en el título, errores sin texto, un MCPError que tumba clientes antiguos y un requestState que falla en cuanto hay dos réplicas.
Fija mcp==2.2.0 o fastmcp==4.0.10, prueba con un cliente en mode="auto" y otro en mode="legacy", y ejecuta la batería detrás de un balanceador antes de desplegar. Si tu servidor se conecta a un editor, repasa cómo instalar un servidor MCP local para tu editor con la configuración nueva.
Fuentes: [1] Índice de PyPI del paquete mcp[3], [2] Índice de PyPI del paquete fastmcp[4], [3] Versiones del SDK de Python de MCP (feed Atom)[1], [4] Notas de la versión 2.2.0 del SDK de Python de MCP[11], [5] Guía de migración del SDK de Python de MCP de v1 a v2[5], [6] Elicitación en el SDK de Python de MCP[6], [7] Despliegue y escalado en el SDK de Python de MCP[10], [8] Notas de la versión 4.0.0 de FastMCP[2], [9] FastMCP: actualizar desde FastMCP 3[9], [10] FastMCP: actualizar desde el SDK v1 de MCP[7], [11] FastMCP: elicitación[8].
Fuentes
Código fuente
Accede a todo el código fuente de este artículo en GitHub.
Ver en GitHub