Dev.to AI 🤖 Ai 👁 0 📖 4 min read

Crear un servidor MCP en Python con el SDK oficial 2.x (adiós, FastMCP)

Si has seguido un tutorial de MCP en Python de 2025, probablemente empezaba con from mcp.server.fastmcp import FastMCP. Con el SDK oficial 2.x esa línea da ModuleNotFoundError: la clase ahora se llama MCPServer. Y el paq

Si has seguido un tutorial de MCP en Python de 2025, probablemente empezaba con from mcp.server.fastmcp import FastMCP. Con el SDK oficial 2.x esa línea da ModuleNotFoundError: la clase ahora se llama MCPServer. Y el paquete independiente fastmcp es otra cosa, que sigue viva (su versión 4 está construida sobre el SDK oficial 2.x).

Este artículo es un extracto de una guía más larga. Montamos nortia-almacen, un servidor que consulta las existencias de los almacenes de Nortia Logística (la empresa ficticia del curso de DesarrolloMCP), con el SDK oficial mcp 2.2.0 y Python 3.13, escrito para la especificación 2026-07-28, la versión sin estado del protocolo.

Lo bueno de Python: las dos versiones del protocolo por el mismo endpoint

En 2026-07-28 no hay handshake initialize ni sesión: cada petición lleva en _meta la versión y las capacidades del cliente. Un servidor hecho con mcp 2.x atiende las dos formas sin configuración: un cliente actual se queda en 2026-07-28 y uno antiguo hace su initialize y negocia 2025-11-25 o anterior.

El proyecto

uv init --bare --name nortia-almacen --python 3.13
uv add "mcp[cli]==2.2.0"
uv add --dev pytest
uv run mcp version
# MCP version 2.2.0

Fija la versión: desde el 2 de octubre de 2026, sin ella se instala la 2.3.0, con la que no hemos vuelto a ejecutar el ejemplo.

Tools sin escribir esquemas

CodigoAlmacen = Annotated[
    str, Field(pattern=r"^[A-Z]{3}-\d{2}$", description="Código de almacén, por ejemplo MAD-01")
]
SOLO_LECTURA = ToolAnnotations(read_only_hint=True, destructive_hint=False, idempotent_hint=True, open_world_hint=False)


@mcp.tool(title="Consultar existencias", annotations=SOLO_LECTURA)
def consultar_existencias(
    almacen: CodigoAlmacen,
    sku: Annotated[str | None, Field(description="Referencia concreta; si se omite, todas")] = None,
) -> Existencias:
    """Existencias actuales de un almacén: unidades, reservadas, disponibles y estado por referencia."""
    auditoria.info("tool=consultar_existencias inquilino=%s almacen=%s sku=%s", INQUILINO, almacen, sku)
    try:
        lineas = lineas_de(almacen)
    except LookupError as e:
        raise ToolError(str(e)) from e
    if sku is not None:
        lineas = [linea for linea in lineas if linea.sku == sku]
        if not lineas:
            raise ToolError(f"{almacen} no tiene la referencia {sku}. Llama sin sku para ver todas.")
    return Existencias(almacen=almacen, inquilino=INQUILINO, lineas=lineas)


@mcp.tool(title="Alertas de rotura de stock", annotations=SOLO_LECTURA)
def alertas_rotura_stock(almacen: CodigoAlmacen) -> list[Existencia]:
    """Referencias de un almacén con disponibles por debajo del punto de pedido."""
    auditoria.info("tool=alertas_rotura_stock inquilino=%s almacen=%s", INQUILINO, almacen)
    try:
        return [linea for linea in lineas_de(almacen) if linea.estado != "ok"]
    except LookupError as e:
        raise ToolError(str(e)) from e

El SDK lo deduce todo de la firma:

  • Los parámetros con tipos producen el inputSchema. El patrón y las descripciones salen de los Field: si el modelo envía almacen: "madrid", la función ni se ejecuta.
  • El docstring es la description de la tool.
  • El tipo de retorno produce el outputSchema, y el valor viaja en structuredContent.
  • Las listas y los escalares se envuelven en un objeto con la clave result. Si quieres decidir tú la forma, devuelve un modelo, como consultar_existencias.

Los errores previstos (un almacén que no existe) se convierten en ToolError, con un texto escrito para el modelo que le dice qué valores sí son válidos. Lo imprevisto no se captura: desde la 2.1.0, el SDK no envía al cliente el texto de las excepciones inesperadas.

Servirlo por Streamable HTTP sin estado

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)

Dos opciones que se malinterpretan a menudo:

  • stateless_http=True solo cambia cómo atiende a los clientes de versiones con handshake: deja de asignarles sesión. El tráfico 2026-07-28 nunca abre sesión, con o sin ella.
  • json_response=True responde a cada POST con un único JSON en vez de un stream SSE. Va bien para tools rápidas, pero las notificaciones de progreso se pierden.

Tres errores que verás seguro

  • TypeError por @mcp.tool sin paréntesis. En el SDK oficial son obligatorios: @mcp.tool().
  • TypeError al pasar port al constructor. Las opciones de transporte van en mcp.run(...), no en MCPServer(...).
  • El Inspector pasa en verde, pero no has probado 2026-07-28. Sin "protocolEra": "modern" dentro de la entrada del servidor, el Inspector usa las versiones con handshake.

La guía completa, con el servidor entero (datos, modelos, resources y prompt), los tests con pytest en memoria, la prueba con el Inspector y con Claude Code, y cómo pedirle a Claude Code que lo construya, está en DesarrolloMCP: crear un servidor MCP en Python.

Si estás aprendiendo MCP en español, el curso de DesarrolloMCP tiene el nivel Básico gratis (14 lecciones, sin tarjeta), actualizado a la especificación 2026-07-28 y con todo el código probado.

📰 Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.