Cómo crear un servidor MCP desde cero en Python, paso a paso

Un servidor MCP en Python que funciona, desde una carpeta vacía hasta Claude Desktop. Escribe herramientas, recursos y prompts con el SDK oficial 2.x, pruébalos en el Inspector, añade una herramienta de imágenes real con llamadas asíncronas a la API y despliégalo mediante Streamable HTTP.

Cómo crear un servidor MCP desde cero en Python, paso a paso
Cristian Da Conceicao
Fundador de Picasso IA

La mayoría de los tutoriales de MCP se quedan en una herramienta meteorológica que devuelve una cadena fija. Este construye un servidor que de verdad puedes mantener. Lo escribirás desde cero con el SDK oficial de Python, lo probarás sin ningún cliente de IA, lo conectarás a Claude Desktop y Claude Code, y terminarás con una herramienta real que llama a una API de imágenes y espera el resultado. Todo lo que sigue necesita Python 3.10 o más reciente y corresponde al SDK de MCP para Python 2.x (2.3.0 en PyPI en el momento de escribir esto, octubre de 2026).

Si copiaste código de un tutorial de 2025 y te topas con ModuleNotFoundError: No module named 'mcp.server.fastmcp', estás en el lugar correcto. La clase principal cambió de nombre, y la sección de configuración muestra la corrección en una sola línea.

Qué hace realmente un servidor MCP

El Model Context Protocol (MCP) es una forma estándar de que una aplicación de IA llame a tu código. Hay tres roles que importan. El host es la aplicación con la que habla la persona, como Claude Desktop o un IDE. El cliente vive dentro del host y habla el protocolo. El servidor es la parte que tú construyes. Tu servidor nunca habla directamente con el modelo, solo responde a las solicitudes de un cliente.

Tres primitivas, tres responsables

Un servidor expone exactamente tres tipos de capacidades, y lo que las diferencia es quién decide usarlas:

PrimitivaQuién la activaQué esEjemplo
HerramientaEl modeloUna función que realiza una acciónGenerar una imagen, escribir una fila en una base de datos
RecursoLa aplicaciónDatos que se cargan en el contexto del modeloUn archivo, una configuración, un catálogo
PromptEl usuarioUna plantilla de mensaje reutilizableUn comando de barra

Si has creado una API web, la equivalencia es rápida. Un recurso se comporta como un GET, una herramienta se comporta como un POST, y un prompt es una consulta guardada que el usuario ejecuta por su nombre.

Boceto a mano en una libreta de tres bloques conectados sobre un escritorio de roble

💡 Regla práctica: si el modelo debe decidir cuándo ejecutarlo, conviértelo en una herramienta. Si la aplicación debe adjuntarlo, hazlo un recurso. Si una persona debe elegirlo de un menú, hazlo un prompt.

Elige el transporte desde el principio

El transporte es la forma en que los bytes viajan entre cliente y servidor. Lo eliges con un único argumento de mcp.run().

TransporteCómo funcionaÚsalo para
stdioEl host lanza tu archivo como subproceso y se comunica por su stdin y stdoutServidores locales, y es el predeterminado
streamable-httpUn servidor HTTP real en un puerto, con el endpoint en /mcpCualquier cosa que despliegues
sseEl transporte HTTP anteriorNada nuevo, quedó reemplazado en la revisión del protocolo 2025-03-26

Cables de red conectados a un switch en un pequeño armario de servidores

Empieza con stdio. Cambiarás a Streamable HTTP hacia el final, y el código de las herramientas seguirá exactamente igual.

Configura Python en cinco minutos

Manos de un desarrollador escribiendo en un editor de código en un equipo portátil plateado

Instala uv y el SDK

Necesitas Python 3.10 o más reciente y uv. Crea un proyecto y añade el SDK:

uv init mcp-image-studio
cd mcp-image-studio
uv add "mcp[cli]" httpx

El extra cli instala el comando mcp con mcp dev, mcp run y mcp install. pip install "mcp[cli]" también funciona. El Inspector es una aplicación de Node.js, así que npx debe estar en tu PATH.

Un cambio de nombre que rompe el código antiguo

En el SDK 1.x la clase de alto nivel se llamaba FastMCP. En la 2.x se llama MCPServer, y está en un módulo distinto:

# SDK 1.x, seen in older tutorials
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("Demo")

# SDK 2.x, used in this article
from mcp.server import MCPServer
mcp = MCPServer("Demo")

Otro cambio que confunde a la gente: los ajustes del transporte, como port, pasaron del constructor a run(). Pasar port= a MCPServer(...) lanza un TypeError.

Escribe tu primer servidor

Crea server.py. Un archivo, tres decoradores, y todas las primitivas quedan registradas:

from mcp.server import MCPServer

mcp = MCPServer("Demo")


@mcp.tool()
def add(a: int, b: int) -> int:
    """Add two numbers."""
    return a + b


@mcp.resource("greeting://{name}")
def greeting(name: str) -> str:
    """Greet someone by name."""
    return f"Hello, {name}!"


@mcp.prompt()
def summarize(text: str) -> str:
    """Summarize a piece of text in one sentence."""
    return f"Summarize the following text in one sentence:\n\n{text}"


if __name__ == "__main__":
    mcp.run()

Eso es un servidor que funciona. La guarda if __name__ importa: mcp dev, mcp run, mcp install y tus pruebas importan este archivo, y un run() sin guarda arrancaría un servidor en cuanto alguien lo cargara.

Añade una herramienta

El SDK lee tres cosas de tu función. El nombre se convierte en el nombre de la herramienta, la docstring se convierte en la descripción que ve el modelo, y los type hints se convierten en el esquema de los argumentos. No hay que escribir ningún JSON Schema, porque a: int, b: int es el esquema. Si un cliente envía una cadena donde declaraste un entero, el SDK rechaza la llamada antes de que se ejecute tu función.

Si das a un parámetro un valor por defecto, se vuelve opcional. Para límites más estrictos, envuelve el tipo en Annotated con un Field de Pydantic:

from typing import Annotated, Literal
from pydantic import Field


@mcp.tool()
def search_books(
    query: str,
    limit: Annotated[int, Field(ge=1, le=50, description="Maximum results")] = 10,
    genre: Literal["fiction", "non-fiction", "poetry"] = "fiction",
) -> str:
    """Search the catalog by title or author."""
    return f"Found 3 books matching {query!r} (up to {limit})."

Los límites aparecen en el esquema como minimum y maximum, y el Literal se convierte en una enumeración de la que el modelo debe elegir.

Añade un recurso y un prompt

Un {param} en la URI de un recurso lo convierte en una plantilla de recurso, así que greeting://{name} no tiene una entrada única que listar hasta que alguien proporcione un nombre. Un prompt es aún más simple: la cadena que devuelve se convierte en un mensaje de usuario. Ambos leen sus descripciones de la docstring, igual que las herramientas.

Lanza errores que el modelo pueda leer

Cuando una herramienta falla, lanza ToolError. Nunca devuelvas una cadena de error, porque una cadena devuelta tiene is_error=False y parece una respuesta correcta.

from mcp.server.mcpserver.exceptions import ToolError

CATALOG = {"Dune": "Frank Herbert", "Neuromancer": "William Gibson"}


@mcp.tool()
def get_author(title: str) -> str:
    """Look up the author of a book in the catalog."""
    if title not in CATALOG:
        raise ToolError(f"No book titled {title!r} in the catalog.")
    return CATALOG[title]

El modelo lee ese mensaje, se da cuenta de que adivinó mal el título y vuelve a llamar con uno mejor. Una raise te da un agente que se corrige solo. Cualquier otra excepción cuenta como un fallo: el modelo solo ve que la llamada falló, y tu registro recibe el traceback.

💡 Declara una herramienta async def siempre que haga E/S, como una llamada a una API, una lectura de archivo o una consulta a una base de datos. Usa def simple para todo lo demás.

Pruébalo y conéctalo

Ejecuta el Inspector de MCP

Antes de que cualquier cliente de IA toque tu servidor, ejecútalo con el Inspector:

uv run mcp dev server.py

Abre la URL que muestra. El Inspector lanza server.py como subproceso por stdio, exactamente como lo haría un host real. Recorre las pestañas en orden:

  • Tools: add aparece con un formulario construido a partir de tus type hints. Llámala con a=1 y b=2 y obtendrás 3.
  • Resources: la lista está vacía, y greeting aparece en Resource Templates. Dale World y leerás Hello, World!.
  • Prompts: summarize tiene un argumento text obligatorio y devuelve un único mensaje de usuario.

Dos ingenieros revisando código en un equipo portátil en un escritorio de pie

Escribe una prueba en memoria

La clase Client del SDK también conecta en memoria: le pasas el objeto del servidor y no hay subproceso ni puerto. Añade pytest con uv add --dev pytest, y luego crea test_server.py:

import pytest
from mcp import Client

from server import mcp


@pytest.fixture
def anyio_backend():
    return "asyncio"


@pytest.mark.anyio
async def test_add():
    async with Client(mcp, raise_exceptions=True) as client:
        result = await client.call_tool("add", {"a": 1, "b": 2})
        assert result.structured_content == {"result": 3}

Mantén raise_exceptions=True solo en las pruebas. Muestra el mensaje de error real en lugar del Internal server error saneado que vería un llamador remoto.

Conecta Claude Desktop y Claude Code

Todo host necesita lo mismo: el comando que arranca tu servidor. Este funciona desde cualquier directorio, sin entorno virtual que activar:

uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py

Claude Desktop es el único host que el SDK puede configurar por ti:

uv run mcp install server.py

Eso escribe una entrada en claude_desktop_config.json, que se encuentra en ~/Library/Application Support/Claude/ en macOS y en %APPDATA%\Claude\ en Windows. Cierra Claude Desktop por completo, no solo la ventana, y luego vuelve a abrirlo. La aplicación inicia tu servidor con su propio entorno, así que pasa los secretos con -v NAME=value o -f .env.

Claude Code no necesita ningún archivo. Registra el servidor con la CLI y luego ejecuta /mcp dentro de una sesión para confirmar que está conectado:

claude mcp add image-studio -- uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py

Cursor lee .cursor/mcp.json bajo el campo mcpServers, y VS Code lee .vscode/mcp.json bajo servers con "type": "stdio". El comando interno es idéntico.

Equipo portátil mostrando una ventana de chat junto a una terminal sobre una mesa de madera

Arregla un servidor que no aparece

Primero, ejecuta tú mismo el comando de arranque. Un servidor stdio sano no imprime nada y espera a que un host hable primero. Un traceback o una salida inmediata es tu error real. Si espera en silencio, revisa estas tres causas:

SíntomaCausaSolución
El servidor nunca arrancaUna ruta relativa, porque el host lanza desde su propio directorio de trabajoUsa rutas absolutas, incluida la de uv (where uv en Windows, which uv en otros sistemas)
Los cambios no tienen efectoLos hosts leen su configuración al arrancarCierra el host por completo y vuelve a abrirlo
La conexión se cae de inmediatoAlgo escribió en stdout, que es el canal del protocoloRegistra con el módulo logging, que escribe en stderr, y nunca dependas de print()

Claude Desktop guarda un registro por servidor, llamado mcp-server-<NAME>.log, en ~/Library/Logs/Claude en macOS y en %APPDATA%\Claude\logs en Windows. Ese archivo es el stderr de tu servidor.

Construye una herramienta de imágenes real

Un servidor se gana su lugar cuando una herramienta hace un trabajo que el modelo no puede hacer solo. Esta toma un prompt, pide a la API de PicassoIA una imagen y devuelve la URL.

Diseña el contrato de la herramienta

La API sigue el estilo de Replicate: creas una predicción, la consultas y luego lees la salida. Estos son los datos de los que depende la herramienta:

DetalleValor
URL basehttps://api.picassoia.com/v1
AutenticaciónAuthorization: Bearer pia_sk_..., creado en la página de la API
Crear un trabajoPOST /v1/models/{owner}/{name}/predictions con {"input": {"prompt": "..."}}
Modelo usado aquíPicassoIA Image, slug picassoia/picassoia-image
Longitud del promptDe 1 a 4000 caracteres
Valores de estadostarting, processing, succeeded, failed, canceled
Concurrencia5 predicciones por cuenta, compartidas entre todos los tokens y conexiones MCP

La documentación de la API indica que se requiere un plan Infinite, y que una solicitud sin él devuelve 403 plan_required. Las predicciones se describen como gratuitas y no usan créditos. Revisa tu plan antes de depurar cualquier otra cosa.

Mujer dibujando un flujo de API en una pizarra blanca en un loft con luz de sol

Mantén el contrato pequeño: una herramienta, dos parámetros, una URL de respuesta. Cada ruta de fallo lanza ToolError, así que el modelo siempre recibe un mensaje legible.

Gestiona los trabajos asíncronos sin bloquear

Como el trabajo se ejecuta en una GPU remota, la herramienta debe esperar sin congelar el servidor. Para eso sirven async def, httpx.AsyncClient y asyncio.sleep:

import asyncio
import os
from typing import Literal

import httpx
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError

API = "https://api.picassoia.com/v1"
MODEL = "picassoia/picassoia-image"
DONE = ("succeeded", "failed", "canceled")

mcp = MCPServer("Image Studio")
slots = asyncio.Semaphore(4)


@mcp.tool()
async def generate_image(
    prompt: str,
    aspect_ratio: Literal["1:1", "16:9", "9:16", "4:3"] = "16:9",
) -> str:
    """Generate an image from a text prompt and return its URL."""
    token = os.environ.get("PICASSOIA_API_TOKEN")
    if not token:
        raise ToolError("PICASSOIA_API_TOKEN is not set for this server.")

    headers = {"Authorization": f"Bearer {token}"}
    body = {"input": {"prompt": prompt, "aspect_ratio": aspect_ratio}}

    async with slots, httpx.AsyncClient(headers=headers, timeout=30) as http:
        response = await http.post(f"{API}/models/{MODEL}/predictions", json=body)
        prediction = response.json()
        if not response.is_success:
            raise ToolError(f"{prediction.get('code')}: {prediction.get('detail')}")

        while prediction["status"] not in DONE:
            eta = prediction.get("eta") or {}
            await asyncio.sleep(eta.get("next_poll_in_seconds", 2))
            prediction = (await http.get(prediction["urls"]["get"])).json()

    if prediction["status"] != "succeeded":
        raise ToolError(prediction.get("error") or prediction["status"])

    output = prediction["output"]
    return output[0] if isinstance(output, list) else output


if __name__ == "__main__":
    mcp.run()

Bolígrafo señalando notas de respuesta de una API junto a un equipo portátil

Tres detalles hacen que sea seguro dejarlo funcionando:

  1. Consulta según el calendario del servidor. La respuesta trae eta.next_poll_in_seconds, así que duermes exactamente el tiempo que pide la API.
  2. Limita tu propia concurrencia. El Semaphore(4) evita que un modelo muy activo acapare las 5 plazas de la cuenta.
  3. Lee el token del entorno. Regístralo con mcp install server.py -v PICASSOIA_API_TOKEN=pia_sk_..., y nunca lo pegues en el archivo.

💡 El campo output puede ser una lista de URL, una sola URL o null. Las dos últimas líneas gestionan las dos primeras, y la comprobación succeeded que está encima descarta null en la práctica.

Cómo usar Sonnet 5 en PicassoIA

Con un modelo de lenguaje, escribir código de herramientas es lo que más tiempo ahorra. Claude Sonnet 5 en PicassoIA maneja tareas de programación de varios pasos y de uso de herramientas, así que puedes pegar la herramienta generate_image que funciona y pedir la siguiente.

  1. Abre la página del modelo de Claude Sonnet 5.
  2. Escribe el prompt. Pega tu servidor y pide: Añade una segunda herramienta que liste mis predicciones recientes con GET /v1/predictions. Reutiliza el mismo manejo de errores. El campo prompt es el único obligatorio.
  3. Define un prompt de sistema para evitar el problema del cambio de nombre desde el principio: Escribes Python para MCP SDK 2.x. Importa MCPServer desde mcp.server y nunca uses FastMCP.
  4. Elige el esfuerzo. El valor predeterminado low omite el pensamiento extendido y es el más rápido. Usa high o max para un error que afecte a varios archivos.
  5. Deja max tokens en 8192 para archivos completos de servidor, o bájalo para un fragmento rápido.
  6. Adjunta una captura de un error del Inspector si tienes una. El modelo lee imágenes, y max_image_resolution (por defecto 0,5 megapíxeles) las reduce de tamaño.
  7. Genera, copia y prueba. Pega el resultado en server.py y ejecútalo con mcp dev antes de fiarte de él.
ParámetroObligatorioPredeterminadoQué hace
promptSíningunoTu solicitud
system_promptNovacíoFija el rol y las restricciones de la sesión
effortNolowProfundidad de razonamiento, de la más rápida a la más profunda
max_tokensNo8192Límite de longitud de la salida
imageNoningunoUna captura o diagrama como contexto

¿Prefieres otro modelo? Kimi K2.6 está en la misma categoría y se describe como útil para crear agentes y escribir código.

Publícalo por HTTP

Técnico caminando por un pasillo de racks de servidores

Cambia el transporte

Cambia una línea al final de server.py:

if __name__ == "__main__":
    mcp.run(transport="streamable-http", port=3001)

Los clientes ahora se conectan a http://127.0.0.1:3001/mcp. También puedes dejar el archivo como está y ejecutar uv run mcp run server.py --transport streamable-http. La llamada run() acepta estas opciones:

  • host y port, con los valores predeterminados 127.0.0.1 y 8000
  • streamable_http_path, con el valor predeterminado /mcp
  • json_response=True para responder a cada POST con un único cuerpo JSON
  • stateless_http=True para un transporte nuevo por solicitud

Registra el servidor remoto en Claude Code con claude mcp add --transport http image-studio https://mcp.example.com/mcp.

Blíndalo

Cuando tu servidor sale de localhost, cambian tres cosas:

  • La lista de hosts permitidos. Por defecto solo acepta 127.0.0.1, localhost y [::1]. Detrás de un nombre de host real, cada solicitud falla con 421 Misdirected Request e Invalid Host header. Corrígelo con transport_security= y enumera tanto "mcp.example.com" como "mcp.example.com:*" en allowed_hosts.
  • Autorización. Tu servidor es un resource server de OAuth 2.1. Implementa TokenVerifier con un único método asíncrono verify_token que devuelva un token de acceso o None, y pasa token_verifier= junto con auth=.
  • TLS detrás de un proxy. Cuando un balanceador de carga termina el TLS, arranca uvicorn con --proxy-headers para que confíe en las cabeceras reenviadas.

💡 Un 421 es una respuesta HTTP normal, no un error del protocolo, así que el cliente solo muestra un fallo genérico de transporte. El nombre de host problemático aparece en el registro del servidor. Un servidor recién desplegado que rechaza todas las conexiones es un problema de lista de hosts permitidos hasta que se demuestre lo contrario.

Crea tus propias imágenes con Picasso IA

Desarrollador recostado en una silla tras terminar su trabajo

Ahora tienes un servidor que registra herramientas, recursos y prompts, supera una prueba en memoria, se ejecuta dentro de Claude y puede desplegarse detrás de un nombre de host real. Lo que merece tu siguiente hora es la herramienta en sí: cambia el slug del modelo, añade una herramienta de edit_image, o conecta junto a ella una herramienta de video.

Prueba el modelo que acaba de llamar tu servidor. PicassoIA Image convierte un prompt en una imagen terminada en segundos, y puedes probar cualquier prompt en el navegador antes de automatizarlo. Cuando una imagen fija no basta, PicassoIA Video y Seedance 2.5 Lite animan un prompt o una foto en clips cortos.

Abre Picasso IA, elige un modelo y ejecuta el mismo prompt que le darías a tu herramienta. Luego conéctalo a tu servidor y deja que Claude se encargue de los clics.

Compartir este artículo

Elige tu idioma