Si ayer escribimos sobre crear un cliente MCP en TypeScript, hoy toca el otro lado: el servidor. Y vamos a hacerlo en Python con FastMCP, que es el camino corto para tener algo funcionando en producción en menos de una hora.
FastMCP 1.0 se incorporó al SDK oficial de Python en 2024, y a día de hoy alguna versión de FastMCP mueve el 70% de los servidores MCP que circulan por ahí, en cualquier lenguaje. La 3.0 salió en enero de 2026 y es la que usamos en este tutorial.
Qué vamos a construir
Un servidor MCP que expone:
Tools: funciones que un LLM puede invocar (ej:
get_weather,search_repo).Resources: datos que el LLM puede leer (ej: archivos, registros de BD, URLs internas).
Prompts: plantillas reusables que el cliente puede inyectar.
Lo levantaremos primero por stdio (modo local, lo que se conecta a Claude Desktop), y luego lo desplegaremos por Streamable HTTP para uso remoto con OAuth.
Instalación
Necesitas Python 3.11+ y el SDK oficial:
pip install "mcp[cli]"
# o si usas uv (recomendado):
uv add "mcp[cli]"Nota: el paquete
mcpya incluye FastMCP. Si ves tutoriales que dicenpip install fastmcp, te están instalando el repo histórico de Prefect que ahora se mantiene aparte. Para servidores nuevos, usamcp[cli].
Crea el proyecto:
mkdir mi-mcp-server && cd mi-mcp-server
uv init
uv add "mcp[cli]" httpx pydanticEl "hello world" en 30 líneas
server.py:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("mi-servidor-demo")
@mcp.tool()
def saludar(nombre: str) -> str:
"""Devuelve un saludo personalizado."""
return f"Hola, {nombre}. Bienvenido a MCP."
@mcp.tool()
def sumar(a: float, b: float) -> float:
"""Suma dos números."""
return a + b
if __name__ == "__main__":
mcp.run()Y ya está. Para probarlo en local:
mcp dev server.pyEsto abre el MCP Inspector en http://localhost:6274 y te deja invocar las tools desde el navegador. Es el debugger oficial.
Cómo lo conecta Claude Desktop
Edita claude_desktop_config.json:
macOS:
~/Library/Application Support/Claude/claude_desktop_config.jsonWindows:
%APPDATA%\Claude\claude_desktop_config.json
Añade:
{
"mcpServers": {
"mi-demo": {
"command": "uv",
"args": ["run", "--directory", "/ruta/a/mi-mcp-server", "server.py"]
}
}
}Reinicia Claude Desktop. En el icono de herramientas verás tus tools disponibles. (Si te perdiste, aquí está cómo configurar MCP en Claude Desktop.)
Tools con tipos complejos
FastMCP usa los type hints y los docstrings para generar automáticamente la JSON Schema que el LLM verá. Cuanto mejor escritos, mejor decide qué tool llamar.
from typing import Annotated
from pydantic import Field
@mcp.tool()
def buscar_productos(
query: Annotated[str, Field(description="Términos de búsqueda")],
max_resultados: Annotated[int, Field(ge=1, le=50)] = 10,
incluir_agotados: bool = False,
) -> list[dict]:
"""Busca productos en el catálogo. Devuelve hasta `max_resultados` items."""
# tu lógica aquí
return [{"id": 1, "nombre": "..."}]El LLM verá: parámetro query con descripción explícita, max_resultados con rango 1-50 y default 10, y incluir_agotados booleano opcional. Lo mejor que puedes hacer por la calidad de las llamadas: llenar los Field(description=...).
Resources (datos que el LLM puede leer)
@mcp.resource("config://app")
def get_config() -> str:
"""Configuración actual de la aplicación."""
return open("/etc/app/config.yaml").read()
@mcp.resource("user://{user_id}/profile")
def get_user_profile(user_id: str) -> dict:
"""Perfil del usuario indicado."""
return {"id": user_id, "name": "...", "plan": "pro"}La diferencia clave con tools: los resources se identifican por URI y el cliente decide cuándo "abrirlos" para meter su contenido en el contexto. Tools son acciones; resources son datos.
Prompts (plantillas reusables)
@mcp.prompt()
def revisar_pr(repo: str, pr_id: int) -> list[dict]:
"""Plantilla para revisar un PR de un repo dado."""
return [
{
"role": "user",
"content": f"Revisa el PR #{pr_id} de {repo}. "
"Busca: bugs, code smells, falta de tests, problemas de performance.",
}
]En Claude Desktop esto aparece en el menú "+" como slash commands que el usuario puede invocar.
Casos reales: tool con HTTP externo
Caso típico: un MCP server que consulta una API.
import httpx
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("github-mini")
API = "https://api.github.com"
@mcp.tool()
async def listar_issues(owner: str, repo: str, state: str = "open") -> list[dict]:
"""Lista los issues del repo. state: open | closed | all."""
async with httpx.AsyncClient() as client:
r = await client.get(
f"{API}/repos/{owner}/{repo}/issues",
params={"state": state, "per_page": 30},
headers={"Accept": "application/vnd.github+json"},
timeout=10,
)
r.raise_for_status()
return [
{"number": i["number"], "title": i["title"], "state": i["state"]}
for i in r.json()
]Funciona en local sin más. Para producción, mete autenticación (token GitHub vía variable de entorno) y rate-limiting. El SDK no opina sobre esto: tu código, tus reglas.
Pasar de stdio a Streamable HTTP
Stdio sirve para uso local (Claude Desktop, Cursor, IDEs). Para acceso remoto necesitas Streamable HTTP (el reemplazo de SSE desde la spec de marzo 2025).
Cambia el bloque final del archivo:
if __name__ == "__main__":
mcp.run(transport="streamable-http", host="0.0.0.0", port=8000)Arranca:
uv run server.pyEl servidor expone el endpoint /mcp (POST + GET para el stream). Desde un cliente:
const transport = new StreamableHTTPClientTransport(
new URL("http://tu-host:8000/mcp")
);
await client.connect(transport);OAuth para MCP remoto
Si el servidor expone datos sensibles, mete OAuth. La spec MCP define el flujo y FastMCP tiene helpers:
from mcp.server.fastmcp import FastMCP
from mcp.server.auth.provider import OAuthAuthorizationServerProvider
class MyAuth(OAuthAuthorizationServerProvider):
# implementa: authorize, token, register_client, revoke
...
mcp = FastMCP(
"mi-server-protegido",
auth_server_provider=MyAuth(),
auth_settings=...,
)Para empezar rápido, usa OAuth dinámico (el cliente se registra solo, sin app preconfigurada). Para entornos enterprise, conecta tu IdP (Auth0, Okta, Clerk) detrás del provider.
Logging y observabilidad
FastMCP integra logging estándar y permite hooks before_tool_call / after_tool_call:
import logging
logging.basicConfig(level=logging.INFO)
@mcp.before_tool_call
async def log_input(name, arguments):
logging.info(f"→ {name} con args: {arguments}")
@mcp.after_tool_call
async def log_output(name, result):
logging.info(f"← {name} devolvió: {result!r}")Si vas a producción, combinarlo con OpenTelemetry o un AI Gateway con observabilidad (tipo Portkey o Levante Platform) es lo razonable.
Deploy
Opción 1: Docker
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN pip install uv && uv sync --frozen
COPY . .
CMD ["uv", "run", "server.py"]Opción 2: Cloud Run / Fly.io
Funciona out-of-the-box con Streamable HTTP. El único detalle: el endpoint /mcp mantiene conexiones largas, así que configura timeouts altos (--timeout=600 en Cloud Run).
Opción 3: Vercel / Netlify (edge)
Dado que MCP usa HTTP estándar, va. Pero el handler tiene que respetar Content-Type: text/event-stream y la duración del request — algunas plataformas tienen límites bajos (10-30s). Para servidores grandes, mejor un host long-lived.
Patrones que funcionan en producción
Un server, un dominio: si tu producto tiene zonas (CRM, billing, support), haz un server por zona, no un megaserver con 200 tools. Los LLMs eligen mejor con catálogos pequeños.
Tool descriptions = tu API: el
docstringy losField(description=...)son lo que el LLM lee. Trátalo como el README del módulo, no como ruido.Errors útiles: si una tool falla, devuelve un error claro (
raise ValueError("repo no existe")) en vez de un stacktrace. El LLM puede recuperarse de un error legible.No expongas todo: los tools del MCP deberían ser escaneables y reversibles. No metas un
eliminar_cuentasin doble confirmación humana.Versiona: si rompes una tool, súbele el nombre (
buscar_productos_v2). Los clientes en cache te lo agradecerán.
Probar tu servidor
Tres herramientas:
MCP Inspector (
mcp dev server.py): debugger visual oficial.CLI MCP:
mcp call server.py listar_issues --owner=anthropic --repo=claude-code.Tests con pytest:
import pytest
from server import mcp
@pytest.mark.asyncio
async def test_saludar():
result = await mcp.call_tool("saludar", {"nombre": "Pepe"})
assert "Pepe" in result.content[0].textCuándo NO usar FastMCP
Si tu lenguaje no es Python: usa el SDK de TypeScript, Go, Rust o Kotlin. La spec es la misma.
Si necesitas streaming de bajo nivel: el SDK base te da más control que FastMCP.
Si vas a integrar dentro de un framework existente (FastAPI, Django): puedes montar el handler MCP como un sub-router. FastMCP es opinionated, montar el server crudo sobre tu framework te da más libertad.
Próximos pasos
Lee la spec MCP completa: 30 minutos de inversión, evita 90% de las dudas.
Mira los servers oficiales: github.com/modelcontextprotocol/servers. Hay implementaciones de Filesystem, GitHub, Slack, Postgres, etc., todas en Python o TypeScript.
Publica el tuyo: registry.modelcontextprotocol.io es el registry público. O súbelo al MCP Store de Levante para que tu equipo lo instale en un clic.
Recursos
Docs oficiales MCP — tutorial weather server paso a paso.
Python SDK en GitHub — código fuente y ejemplos.
FastMCP docs — el repo histórico (sigue activo, complementario al SDK oficial).
Si quieres ir más allá: el cliente MCP en TypeScript que escribimos ayer, el pillar sobre qué es un cliente MCP y los mejores servidores MCP de 2026.



