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.
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:
Primitiva
Quién la activa
Qué es
Ejemplo
Herramienta
El modelo
Una función que realiza una acción
Generar una imagen, escribir una fila en una base de datos
Recurso
La aplicación
Datos que se cargan en el contexto del modelo
Un archivo, una configuración, un catálogo
Prompt
El usuario
Una plantilla de mensaje reutilizable
Un 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.
💡 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().
Transporte
Cómo funciona
Úsalo para
stdio
El host lanza tu archivo como subproceso y se comunica por su stdin y stdout
Servidores locales, y es el predeterminado
streamable-http
Un servidor HTTP real en un puerto, con el endpoint en /mcp
Cualquier cosa que despliegues
sse
El transporte HTTP anterior
Nada nuevo, quedó reemplazado en la revisión del protocolo 2025-03-26
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
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: intes 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, lanzaToolError. 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.
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.
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íntoma
Causa
Solución
El servidor nunca arranca
Una ruta relativa, porque el host lanza desde su propio directorio de trabajo
Usa rutas absolutas, incluida la de uv (where uv en Windows, which uv en otros sistemas)
Los cambios no tienen efecto
Los hosts leen su configuración al arrancar
Cierra el host por completo y vuelve a abrirlo
La conexión se cae de inmediato
Algo escribió en stdout, que es el canal del protocolo
Registra 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:
5 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.
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()
Tres detalles hacen que sea seguro dejarlo funcionando:
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.
Limita tu propia concurrencia. El Semaphore(4) evita que un modelo muy activo acapare las 5 plazas de la cuenta.
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.
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.
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.
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.
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.
Deja max tokens en 8192 para archivos completos de servidor, o bájalo para un fragmento rápido.
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.
Genera, copia y prueba. Pega el resultado en server.py y ejecútalo con mcp dev antes de fiarte de él.
Parámetro
Obligatorio
Predeterminado
Qué hace
prompt
Sí
ninguno
Tu solicitud
system_prompt
No
vacío
Fija el rol y las restricciones de la sesión
effort
No
low
Profundidad de razonamiento, de la más rápida a la más profunda
max_tokens
No
8192
Límite de longitud de la salida
image
No
ninguno
Una 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
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
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.