Crear un servidor MCP en Python (2026): tutorial sin estado

Crea un servidor MCP en Python con el SDK oficial mcp 2.x y MCPServer: tools, resources y prompt por stdio y HTTP, y pruébalo con el Inspector y Claude Code.

Por

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

En este tutorial vas a crear un servidor MCP en Python con el SDK oficial (mcp 2.x y su clase MCPServer), escrito para la especificación 2026-07-28, la versión sin estado del protocolo. El servidor se llama nortia-almacen y es de Nortia Logística, la empresa ficticia que usamos en el curso. Consulta las existencias de sus almacenes y las alertas de rotura de stock con dos tools, dos resources y un prompt.

El código es el de la lección 10 del curso, copiado tal cual y ejecutado con Python 3.13 y mcp 2.2.0. Después lo pruebas con el MCP Inspector y con Claude Code, o le pides a Claude Code que lo construya mientras ves cómo lo hace.

Qué cambia con la especificación 2026-07-28

Si vienes de tutoriales de 2025, el cambio principal es que en la versión actual del protocolo no hay handshake initialize ni sesión. Cada petición lleva en _meta la versión del protocolo y las capacidades del cliente, y el cliente descubre el servidor con server/discover. Lo tienes explicado en MCP 2026-07-28: el protocolo pasa a ser sin estado. Si MCP es nuevo para ti, empieza por qué es MCP.

Si lo escribes en Python, lo tienes fácil: un servidor hecho con mcp 2.x atiende las dos formas por el mismo endpoint y sin configuración. Un cliente de la versión actual se queda en 2026-07-28, y uno antiguo hace su initialize y negocia 2025-11-25 o anterior.

Requisitos

  • Python 3.10 o superior es el mínimo que declara el SDK. El proyecto de ejemplo pide Python 3.13 (uv init y su pyproject.toml), y el código de este artículo solo se ha ejecutado con esa versión.
  • mcp 2.2.0, publicado el 7 de septiembre de 2026. El 2 de octubre salió la 2.3.0, pero el ejemplo fija la 2.2.0 y no lo hemos vuelto a ejecutar con la nueva.
  • uv como gestor de proyecto (el ejemplo se probó con uv 0.11). Si no tienes Python 3.13, uv lo descarga.
  • Para el Inspector, Node 22.19.0 o posterior.

Conviene aclarar los nombres, porque de ahí vienen casi todas las confusiones. La clase FastMCP que venía dentro del SDK oficial 1.x ya no existe, y en 2.x se llama MCPServer. El paquete independiente fastmcp es otra cosa y sigue vivo: su versión 4 está construida sobre el SDK oficial 2.x. Este tutorial usa el SDK oficial.

Crea el proyecto e instala el SDK:

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

Fuente: lección 10 del curso, «Crear el proyecto». Con pip: pip install "mcp[cli]==2.2.0" "pytest==9.1.1", como en el LEEME del repositorio de ejemplos. Fija la versión: desde el 2 de octubre de 2026, sin ella se instala la 2.3.0.

Paso 1: el servidor, los datos y los modelos

El archivo empieza por lo que no es MCP: los datos, los modelos de Pydantic y una función de acceso. El inventario está en memoria para que el ejemplo se ejecute sin base de datos.

"""nortia-almacen: existencias y alertas de rotura de stock (SDK oficial mcp 2.x)."""
import logging
import os
from typing import Annotated, Literal

from pydantic import BaseModel, Field

from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ResourceNotFoundError, ToolError
from mcp.types import ToolAnnotations

# logging escribe en stderr; en stdio, stdout queda reservado para los mensajes MCP
logging.basicConfig(level=logging.INFO, format="%(name)s %(message)s")
auditoria = logging.getLogger("nortia.auditoria")

# El inquilino lo fija quien despliega el proceso, nunca un argumento que rellene el modelo
INQUILINO = os.environ.get("NORTIA_INQUILINO", "es")
ALMACENES = {"MAD-01": "es", "BCN-02": "es", "MEX-01": "mx"}
# Sustituye a la proyección de eventos de inventario: (unidades, reservadas, punto de pedido)
INVENTARIO: dict[tuple[str, str], tuple[int, int, int]] = {
    ("MAD-01", "PAL-EUR-120"): (340, 60, 150),
    ("MAD-01", "CAJ-CART-40"): (95, 20, 200),
    ("BCN-02", "PAL-EUR-120"): (12, 12, 80),
    ("MEX-01", "PAL-EUR-120"): (510, 0, 150),
}

mcp = MCPServer(
    "nortia-almacen",
    version="1.0.0",
    instructions="Existencias y alertas de rotura de stock de los almacenes de Nortia. Solo lectura.",
)


class Existencia(BaseModel):
    sku: str
    unidades: int = Field(ge=0, description="Unidades físicas en el almacén")
    reservadas: int = Field(ge=0, description="Unidades asignadas a pedidos sin expedir")
    disponibles: int = Field(description="unidades menos reservadas")
    punto_pedido: int = Field(description="Umbral de disponibles por debajo del cual se repone")
    estado: Literal["ok", "bajo", "rotura"]


class Existencias(BaseModel):
    almacen: str
    inquilino: Literal["es", "mx"]
    lineas: list[Existencia]


def almacenes_visibles() -> list[str]:
    return sorted(a for a, inquilino in ALMACENES.items() if inquilino == INQUILINO)


def lineas_de(almacen: str) -> list[Existencia]:
    if ALMACENES.get(almacen) != INQUILINO:
        # Misma respuesta si el almacén no existe o si es de otro inquilino: no se revela cuál
        raise LookupError(f"El almacén {almacen} no existe. Almacenes válidos: {almacenes_visibles()}")
    lineas = []
    for (alm, sku), (unidades, reservadas, punto) in sorted(INVENTARIO.items()):
        if alm == almacen:
            disponibles = unidades - reservadas
            estado = "rotura" if disponibles <= 0 else "bajo" if disponibles < punto else "ok"
            lineas.append(Existencia(sku=sku, unidades=unidades, reservadas=reservadas,
                                     disponibles=disponibles, punto_pedido=punto, estado=estado))
    return lineas

Fuente: basico/10-fastmcp/servidor.py, líneas 1 a 64, del repositorio público github.com/noelserdna/desarrollomcp-ejemplos (idéntico a la lección 10).

Fíjate en dos decisiones. La primera es que el inquilino (España o México) sale de una variable de entorno que fija quien despliega el proceso, no de un argumento. Los argumentos de una tool los escribe el modelo, y el aislamiento entre inquilinos no puede depender de eso.

La segunda es que lineas_de lanza LookupError y no una excepción del SDK, porque la usan tanto las tools como los resources, y cada primitiva traduce ese fallo a su propio tipo de error.

Paso 2: tools con salida estructurada

No escribes ningún esquema a mano para las tools. El SDK lo deduce de la firma de cada función.

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

Fuente: basico/10-fastmcp/servidor.py, líneas 67 a 98, del mismo repositorio.

Esto es lo que genera el SDK a partir de cada función:

  • Los parámetros con tipos producen el inputSchema que el servidor anuncia en tools/list. El patrón y las descripciones salen de los Field. Si el modelo envía almacen: "madrid", no cumple el patrón y la función ni se ejecuta.
  • El docstring produce la description de la tool.
  • El tipo de retorno produce el outputSchema. El valor devuelto viaja en structuredContent y también, como el mismo JSON, en un bloque de texto de content.
  • Las listas y los escalares se envuelven en un objeto con la clave result. Por eso alertas_rotura_stock devuelve {"result": [...]}. Si quieres decidir tú la forma, devuelve un modelo, como hace consultar_existencias.
  • Las anotaciones van en snake_case en Python (read_only_hint) y en camelCase en el mensaje (readOnlyHint).

Los errores siguen una regla simple. Lo previsto, como un almacén que no existe o una referencia que el almacén no tiene, se convierte en ToolError. Su texto está escrito para el modelo y le dice qué valores sí son válidos. Llega como Error executing tool alertas_rotura_stock: El almacén MEX-01 no existe… con isError: true, y así el modelo puede corregirse.

Lo imprevisto no se captura. Desde la versión 2.1.0, el SDK no envía al cliente el texto de las excepciones inesperadas: responde solo Error executing tool <nombre> y deja la traza en el log.

Paso 3: resources y prompt

@mcp.resource("almacen://almacenes", mime_type="application/json")
def almacenes() -> list[str]:
    """Códigos de los almacenes del inquilino (resource fijo)."""
    return almacenes_visibles()


@mcp.resource("almacen://{almacen}/existencias", mime_type="application/json")
def existencias(almacen: str) -> Existencias:
    """Existencias completas de un almacén (resource con plantilla)."""
    try:
        return Existencias(almacen=almacen, inquilino=INQUILINO, lineas=lineas_de(almacen))
    except LookupError as e:
        raise ResourceNotFoundError(str(e)) from e


@mcp.prompt(title="Plan de reposición")
def plan_reposicion(almacen: str) -> str:
    """Pide un plan de reposición para un almacén a partir de sus alertas."""
    return (
        f"Eres responsable del almacén {almacen} de Nortia. Llama a alertas_rotura_stock para {almacen} "
        "y propone, por referencia, cuántas unidades pedir para volver al punto de pedido. "
        "Ordena por urgencia: primero las roturas. No inventes existencias que la tool no haya devuelto."
    )


if __name__ == "__main__":
    mcp.run()  # stdio por defecto

Fuente: basico/10-fastmcp/servidor.py, líneas 101 a 127, del mismo repositorio.

La URI decide el tipo de resource. Sin parámetros, almacen://almacenes aparece en resources/list. Con un parámetro entre llaves, la plantilla aparece en resources/templates/list, y el SDK pasa el valor a la función por nombre.

Los errores aquí funcionan de otra forma que en las tools, porque resources/read no tiene isError. Un resource que no existe es un error de protocolo -32602, y la especificación prohíbe devolver contents vacío en su lugar. Por eso el mismo LookupError se convierte aquí en ResourceNotFoundError.

El prompt es una función que devuelve texto, y el SDK lo entrega como un mensaje con rol user. No consulta el inventario: le dice al modelo qué tool usar. Así el dato pasa por el mismo control de inquilino y el mismo registro de auditoría.

Paso 4: arrancar por stdio y por Streamable HTTP sin estado

mcp.run() sirve por stdio. El host lanza python servidor.py como subproceso, y cada línea de stdin y stdout es un mensaje JSON-RPC. La llamada va bajo if __name__ == "__main__": porque mcp run y los tests importan el archivo. Con stdio, stdout es el cable, y por eso la auditoría usa logging, que escribe en stderr.

Para servirlo por Streamable HTTP solo cambias el transporte que pasas a run(). Sus opciones van ahí, no en el constructor:

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)

Fuente: basico/10-fastmcp/servidor_http.py del mismo repositorio. Se arranca con uv run python servidor_http.py y escucha en http://127.0.0.1:8765/mcp.

Las dos opciones se malinterpretan a menudo:

  • stateless_http=True solo cambia cómo atiende el servidor 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 esa opción.
  • json_response=True hace que el servidor responda a cada POST con un único cuerpo JSON en vez de un stream SSE. Va bien para tools que contestan rápido, pero las notificaciones de progreso de la llamada se pierden.

Probarlo: cliente, tests, Inspector y Claude Code

Con el cliente del repositorio

El ejemplo trae cliente.py. Sin argumentos lanza el servidor por stdio, y si le pasas una URL usa Streamable HTTP. La salida es la misma en los dos casos:

protocolo: 2026-07-28 | servidor: nortia-almacen
tool: consultar_existencias | argumentos: ['almacen', 'sku']
tool: alertas_rotura_stock | argumentos: ['almacen']
alertas MAD-01 -> {'result': [{'sku': 'CAJ-CART-40', 'unidades': 95, 'reservadas': 20, 'disponibles': 75, 'punto_pedido': 200, 'estado': 'bajo'}]}
otro inquilino -> is_error: True | Error executing tool consultar_existencias: El almacén MEX-01 no existe. Almacenes válidos: ['BCN-02', 'MAD-01']
almacen://almacenes -> [  "BCN-02",  "MAD-01"]
prompt -> user | Eres responsable del almacén BCN-02 de Nortia. Llama a alert...

Fuente: basico/10-fastmcp/LEEME.md, «Probar por stdio con el cliente», salida de uv run python cliente.py (y de uv run python cliente.py http://127.0.0.1:8765/mcp).

Mira la primera línea. El Client oficial envía server/discover y, como el servidor lo entiende, se queda en 2026-07-28.

Con pytest, en memoria

El repositorio incluye test_servidor.py con cinco tests. Cada uno pasa el objeto MCPServer a Client y conecta los dos en el mismo proceso, sin subproceso ni red:

$ uv run pytest -q test_servidor.py
.....                                                                    [100%]
5 passed in 0.30s

Fuente: lección 10, «Tests en memoria con pytest», y basico/10-fastmcp/LEEME.md, «Tests».

Con el MCP Inspector

El MCP Inspector (@modelcontextprotocol/inspector, versión 2.9.0 del 30 de septiembre de 2026) reúne tres interfaces en un paquete: web (npx @modelcontextprotocol/inspector), línea de comandos (--cli), pensada para CI y agentes, y terminal (--tui).

Hay un detalle que decide si la prueba sirve de algo: el Inspector usa por defecto las versiones con handshake. Su opción protocolEra vale legacy, y como un servidor mcp 2.x también atiende esas versiones, todo pasa en verde sin haber comprobado la versión actual.

Para exigir 2026-07-28, pon "protocolEra": "modern" en la entrada del servidor del archivo de configuración que pasas con --config. Así el Inspector fija esa versión, sin alternativa, y falla si el servidor no la ofrece. Sabrás que se negoció la versión actual porque las respuestas traen en _meta la clave io.modelcontextprotocol/serverInfo.

Probamos esta forma, con el campo en el archivo, el 7 de octubre de 2026. Usamos el Inspector 2.9.0 contra el nortia-almacen que generó Claude Code en la prueba que contamos más abajo. tools/list y tools/call terminaron con código 0, y la respuesta traía serverInfo en _meta (con legacy, _meta venía vacío). Fíjate en dónde pones el campo. Si va en la raíz del archivo, fuera de la entrada del servidor, el Inspector lo ignora sin avisar y negocia con handshake.

Con Claude Code

Claude Code registra servidores con claude mcp add. En un servidor stdio, todo lo que va detrás de -- es el comando del servidor. En uno HTTP, la forma es claude mcp add --transport http <name> <url>. Para nortia-almacen, con la ruta ajustada a la de tu proyecto:

claude mcp add --env NORTIA_INQUILINO=es --transport stdio --scope project nortia-almacen \
  -- uv run --directory /ruta/absoluta/nortia-almacen python servidor.py
claude mcp list            # ⏸ Pending approval hasta aprobarlo en una sesión interactiva

Fuente: documentación de Claude Code, «Connect Claude Code to tools via MCP» (consultada el 7 de octubre de 2026), y prueba de ese mismo día con Claude Code 2.1.293: este comando registró en .mcp.json el nortia-almacen que había generado Claude Code.

Hay tres detalles de ese comando que importan:

  • El -- es obligatorio en stdio.
  • --env no puede ir justo antes del nombre del servidor. La CLI leería el nombre como otro par y lo rechazaría, así que va --transport stdio en medio. En la prueba, con el nombre justo detrás de --env, falló con «Invalid environment variable format».
  • --scope project guarda la entrada en .mcp.json, en la raíz del proyecto, para que se versione. Con una ruta absoluta solo le sirve a quien la tenga igual, así que para probar tú solo, usa --scope local.

En sesiones interactivas, Claude Code te pedirá aprobación antes de usar los servidores de .mcp.json. Hasta que lo apruebes (al abrir claude o en /mcp), claude mcp list lo muestra como ⏸ Pending approval, no como conectado. Aun así, en la prueba, claude -p lo conectó y usó su tool. Dentro de la sesión, /mcp muestra el estado y el número de tools de cada servidor.

¿Hablará Claude Code 2026-07-28 con tu servidor? Su documentación dice que su runtime de cliente v2 se basa en el SDK de TypeScript 2.0, que añade esa revisión. Según el changelog de la versión 2.1.292 (6 de octubre de 2026), las conexiones stdio negocian 2026-07-28 por defecto. A 7 de octubre, la página de MCP todavía describía un despliegue gradual.

En nuestra prueba, Claude Code conectó con nortia-almacen y llamó a su tool, pero eso no dice qué versión se negoció. La documentación tampoco explica cómo verlo, así que la comprobación fiable es la del Inspector.

Hazlo con Claude Code

También puedes pedirle a Claude Code que construya nortia-almacen por ti y aprender viendo cómo lo hace: qué lee primero, qué plan propone, qué tests escribe y cómo los ejecuta. La guía de buenas prácticas de Claude Code recomienda darle tres cosas:

  • el SDK y la versión exactos
  • una forma de comprobar su trabajo
  • la oportunidad de revisar el plan antes de que escriba código.

Empieza en plan mode

En plan mode, Claude Code explora y propone un plan sin tocar archivos. Entras pulsando Shift+Tab hasta ver ⏸ plan mode on, o arrancando con claude --permission-mode plan. Con Ctrl+G editas el plan antes de aprobarlo. La guía de buenas prácticas de Claude Code recomienda este modo para cambios que no se describen en una frase, y un servidor nuevo es uno de ellos.

Los prompts

Primero, un CLAUDE.md con lo que Claude no puede deducir del código. Este prompt incluye también las reglas de TypeScript, por si el repositorio va a tener servidores en los dos lenguajes. Si solo vas a escribir Python, quita esa línea:

Crea un CLAUDE.md corto para este repositorio, que va a contener servidores MCP del curso de DesarrolloMCP.
Incluye solo lo que no se deduce del código:
- Especificación MCP objetivo: 2026-07-28 (sin estado: sin initialize ni sesión; server/discover y _meta en cada petición).
- Python: SDK oficial `mcp` 2.x (2.2.0) con `from mcp.server import MCPServer`. Prohibido `mcp.server.fastmcp` (no existe en 2.x) y prohibido el paquete independiente `fastmcp`. Gestor: uv. Tests: `uv run pytest -q`.
- TypeScript: SDK v2 en paquetes separados (`@modelcontextprotocol/server`, `@modelcontextprotocol/client`, `/node`, `/express`), Zod 4 importado como `import * as z from 'zod/v4'`. Prohibido `@modelcontextprotocol/sdk` (v1). Para stdio usa `serveStdio(fábrica)` y para HTTP `createMcpHandler(fábrica)`: `server.connect(new StdioServerTransport())` solo habla la era legacy. Comprobar tipos con `npx tsc -p .`.
- Los servidores stdio nunca escriben en stdout: los logs van a stderr.
- Verificación obligatoria al terminar: tests en verde y una llamada con la CLI del MCP Inspector (`npx -y @modelcontextprotocol/inspector@2.9.0 --cli`) con `"protocolEra": "modern"` dentro de la entrada del servidor en el fichero de configuración (o `--protocol-era modern` con un servidor ad hoc).

Después, el servidor:

Quiero construir el servidor MCP `nortia-almacen` en Python dentro de ./nortia-almacen.
Antes de escribir código, lee el README actual de https://github.com/modelcontextprotocol/python-sdk y propón un plan.
Requisitos:
- Proyecto con uv: `uv init --bare --name nortia-almacen --python 3.13`, `uv add "mcp[cli]==2.2.0"`, `uv add --dev pytest`. Comprueba con `uv run mcp version` que es la 2.2.0.
- `servidor.py` con `MCPServer("nortia-almacen", version="1.0.0", instructions=...)`. Si usas la skill mcp-builder o mcp-server-dev, sigue sus fases pero NO su código de FastMCP: aquí va el SDK oficial 2.x.
- Tools de solo lectura (anotación readOnlyHint): `consultar_existencias` y `alertas_rotura_stock(almacen)`, con modelos Pydantic de salida y errores accionables (ToolError) que digan qué almacenes son válidos (BCN-02, MAD-01).
- Recursos `almacen://almacenes` y `almacen://{almacen}/existencias` (JSON) y el prompt `plan_reposicion(almacen)`.
- Inquilino desde la variable de entorno NORTIA_INQUILINO (por defecto "es"). Auditoría con logging hacia stderr, nunca a stdout.
- `mcp.run()` para stdio.
- Tests con pytest que conecten un `Client` en memoria y prueben cada tool, incluido el error de almacén inexistente. Ejecútalos y enséñame la salida.

Fuente de los dos prompts: nuestra investigación sobre Claude Code y servidores MCP (7 de octubre de 2026), corregida con la prueba de ese mismo día. No son texto oficial de Anthropic: aplican sus buenas prácticas generales a este servidor.

Cuando los tests pasen, sigue en el mismo hilo con el tercer prompt. Con él, Claude Code prueba el servidor con el Inspector y te da el comando de conexión sin ejecutarlo, porque ese paso lo das tú.

Ahora conecta y prueba el servidor:
1. Crea `.inspector/config.json` con una entrada "nortia-almacen": `uv run --directory ./nortia-almacen python servidor.py`, con `"env": {"NORTIA_INQUILINO": "es"}` y `"protocolEra": "modern"` dentro de la entrada del servidor (en la raíz del fichero se ignora).
2. Ejecuta `npx -y @modelcontextprotocol/inspector@2.9.0 --cli --config .inspector/config.json --server nortia-almacen --method tools/list` y una llamada `tools/call` real (alertas_rotura_stock con almacen=BCN-02). Comprueba que la respuesta trae `_meta` con `io.modelcontextprotocol/serverInfo` (prueba de que se negoció 2026-07-28) y enséñame los comandos y la salida.
3. Dime el comando `claude mcp add` exacto para registrarlo en este proyecto con `--scope project` (rutas absolutas, `--env NORTIA_INQUILINO=es` seguido de `--transport stdio` antes del nombre, y `--` antes del comando), pero NO lo ejecutes: lo ejecuto yo.

Fuente: misma investigación, §6.a, prompt 4, reducido al servidor de Python.

Qué salió al ejecutarlos

Ejecutamos los prompts el 7 de octubre de 2026 con Claude Code 2.1.293, en una carpeta vacía y en modo no interactivo (claude -p). El de conectar y probar lo lanzamos en el mismo hilo, con --continue. Esto es lo que salió:

  • El servidor. Claude Code instaló mcp 2.2.0 y escribió servidor.py con MCPServer, sin rastro de fastmcp. Sus tests, con un Client en memoria, pasaron con uv run pytest -q (8 de 8). El inquilino no aparece en ningún inputSchema.
  • El Inspector. Con "protocolEra": "modern" dentro de la entrada del servidor, tools/list y tools/call de alertas_rotura_stock con BCN-02 terminaron con código 0 y serverInfo en _meta. Claude Code puso el campo primero en la raíz del archivo, y el Inspector lo ignoró sin avisar y negoció con handshake. Claude Code lo detectó por la falta de _meta y lo movió.
  • Claude Code. Registramos el servidor con el comando de «Con Claude Code» (--scope project). Desde claude -p, la pregunta «¿Qué alertas de rotura de stock hay en BCN-02?» llamó a alertas_rotura_stock y respondió con las dos alertas del almacén.

La prueba no cubre una sesión interactiva. En -p, Claude Code escribió el plan y siguió sin pararse a que lo aprobaras, así que el paso de plan mode no está probado.

Además, la prueba se hizo con una versión anterior de los prompts, que tenía tres diferencias:

  • En lugar del tercer prompt había un párrafo que pedía lo mismo, pero sin decir dónde va protocolEra.
  • Ese párrafo tampoco pedía --scope project, y Claude Code dio el claude mcp add sin ese ámbito.
  • El CLAUDE.md no decía cómo fijar la era. Claude Code avisó en las dos ejecuciones de que no lo sabía.

Los prompts de arriba ya llevan esas tres correcciones, pero no los hemos vuelto a ejecutar.

Las skills que puedes instalar

Una skill es una carpeta con un SKILL.md de instrucciones. Claude Code la carga cuando la petición encaja con su descripción o cuando la invocas con /nombre. Si quieres saber más, lo contamos en qué son las Agent Skills. Para construir servidores MCP hay dos oficiales de Anthropic, las dos con licencia Apache 2.0:

Skill Instalación (dentro de Claude Code) Para qué sirve
Plugin mcp-server-dev /plugin install mcp-server-dev@claude-plugins-official (si no encuentra el marketplace: /plugin marketplace add anthropics/claude-plugins-official y repetir) Es la que recomiendan la documentación de Claude Code y la de modelcontextprotocol.io. Se invoca con /mcp-server-dev:build-mcp-server. Empieza preguntando: a qué se conecta, quién lo usará, cuántas acciones, qué autenticación. Después recomienda el despliegue (HTTP remoto o stdio local), el patrón de tools y el framework, genera el esqueleto y propone probarlo
Skill mcp-builder (dentro del plugin example-skills) /plugin marketplace add anthropics/skills y después /plugin install example-skills@anthropic-agent-skills Una guía en cuatro fases: investigación y plan (diseño de tools, lectura de la especificación y del README del SDK), implementación, revisión y pruebas con el Inspector, y evaluaciones con diez preguntas complejas y verificables. Puede activarse sola por su descripción, pero no siempre (en nuestra prueba, en 1 de 4 peticiones); para asegurarte, invócala con /example-skills:mcp-builder. Su repositorio avisa de que es material de demostración y educativo

Aviso: esas skills son anteriores a la 2026-07-28

Ninguna de las dos está al día con lo que enseña este tutorial. El contenido de mcp-builder no cambia desde el 1 de diciembre de 2025, y el de mcp-server-dev, desde el 28 de abril de 2026. Las dos fechas son anteriores a la especificación 2026-07-28. En Python, esto es lo que encontramos:

  • La guía de Python de mcp-builder importa from mcp.server.fastmcp import FastMCP, y con mcp 2.x ese import falla con ModuleNotFoundError. En nuestra prueba la skill no llegó a activarse en las peticiones de Python, así que no hemos podido ver si el código que genera usa ese import. Lo que sí vimos es que, sin la skill, Claude probó primero mcp.server.fastmcp, recibió ese error y pasó a MCPServer.
  • mcp-server-dev recomienda el paquete independiente fastmcp, no el SDK oficial con MCPServer. En la prueba, sin fijar el SDK, generó un proyecto con fastmcp 4.0.11 (from fastmcp import FastMCP), no con mcp. Pasó el Inspector con la era modern, porque fastmcp 4 habla 2026-07-28, pero no es el SDK que enseña este tutorial.

Las probamos el 7 de octubre de 2026 con Claude Code 2.1.293 en modo no interactivo, cada una en su carpeta y con una petición corta, sin fijar el SDK y fijándolo. Por eso los prompts de arriba fijan el SDK, la versión y el punto de entrada, y piden expresamente seguir las fases de la skill pero no su código.

En la prueba bastó con eso. Le dimos una frase que fijaba mcp 2.2.0 y MCPServer y prohibía mcp.server.fastmcp y fastmcp. Con ella, mcp-server-dev generó un proyecto con mcp[cli]==2.2.0 y MCPServer, y el Inspector con modern terminó con código 0. Úsalas por su método (preguntar, diseñar las tools, probar y evaluar) y revisa siempre el resultado.

Cómo revisar lo que hace Claude Code

  • Lee el plan antes de aprobarlo. Busca from mcp.server import MCPServer y descarta cualquier fastmcp o mcp.server.fastmcp.
  • Exige pruebas. La guía de buenas prácticas de Claude Code pide darle una comprobación que pueda ejecutar y que enseñe la evidencia: la salida de los tests, el comando que ejecutó y lo que devolvió. Para este servidor, uv run pytest -q y la CLI del Inspector con la versión actual, que termina con un código de salida.
  • Comprueba el resultado en Claude Code. Tras registrarlo con --scope project, claude mcp list lo muestra como ⏸ Pending approval hasta que lo apruebes en una sesión interactiva (al abrir claude o en /mcp), aunque claude -p ya lo conecta. Después, /mcp debe mostrarlo conectado y con dos tools. Haz una pregunta que obligue a usar una tool: «¿Qué alertas de rotura de stock hay en BCN-02?».
  • Revisa las decisiones de seguridad. Que el inquilino no aparezca en ningún inputSchema (si fuera un argumento, cualquier conversación podría pedir datos del otro inquilino) y que nada escriba en stdout, porque un print() rompe el servidor por stdio. En seguridad en servidores MCP tienes los avisos reales de los SDK en los últimos meses.

Errores comunes

  • ModuleNotFoundError: No module named 'mcp.server.fastmcp'. Es código para mcp 1.x. En 2.x, from mcp.server import MCPServer y MCPServer("nombre", version="1.0.0").
  • TypeError por @mcp.tool sin paréntesis. En el SDK oficial los paréntesis son obligatorios: @mcp.tool().
  • TypeError al pasar port al constructor. Las opciones de transporte van en mcp.run(...), no en MCPServer(...).
  • AttributeError: 'Tool' object has no attribute 'inputSchema'. En Python los campos van en snake_case: input_schema, is_error, structured_content. En el JSON del protocolo siguen en camelCase.
  • ctx.elicit() falla en conexiones 2026-07-28. El servidor ya no puede iniciar peticiones al cliente; la sustitución es el patrón de varias rondas de la lección 7 (en el SDK, Resolve y Elicit).
  • Las notificaciones de progreso no llegan con json_response=True. Con esa opción se pierden; una tool larga necesita responder con un stream SSE.
  • El Inspector pasa en verde, pero no has probado 2026-07-28. Sin "protocolEra": "modern", o con ese campo en la raíz del archivo en lugar de dentro de la entrada del servidor, usa las versiones con handshake.

Siguientes pasos

  • La lección Servidor en Python del curso es gratuita con registro: además de este código, explica el cliente mínimo, los mensajes que viajan por stdio y por HTTP, los cinco tests y cuándo elegir el paquete fastmcp 4 en lugar del SDK oficial. Termina con un desafío práctico y un quiz.
  • El código completo está en el repositorio público de ejemplos, carpeta basico/10-fastmcp, con versiones fijadas en uv.lock.
  • La lección siguiente, Agentes + MCP, conecta un agente con Claude a varios servidores de Nortia.
  • Si tu equipo trabaja en TypeScript, tienes el tutorial equivalente en crear un servidor MCP en TypeScript, con el SDK oficial v2, y la lección Servidor en TypeScript.

Aprende esto en el curso

  • Básico Gratis Servidor en Python · Construye nortia-almacen con el SDK oficial de Python
  • Básico Gratis Agentes + MCP · Un agente con Claude que orquesta varios servidores
  • Básico Gratis Servidor en TypeScript · Construye nortia-pedidos con el SDK oficial v2
  • Básico Gratis Herramientas · Acciones que el modelo puede invocar, con esquemas y errores

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. Python SDK releases (2026-10-02)
  2. Python SDK v2.1.0 (2026-08-24)
  3. Especificación 2026-07-28: Tools
  4. Especificación 2026-07-28: Transports
  5. Especificación 2026-07-28: server/discover
  6. MCP Inspector releases (2026-09-30)
  7. Claude Code: Connect Claude Code to tools via MCP (2026-10-07)

Artículos relacionados