De tu primera herramienta expuesta con FastMCP, hasta un servidor de producción stateless, con autenticación OAuth y balanceo de carga entre réplicas.
0 / 10 completado
Nivel 1 · Fundamentos
Tu primer servidor MCP
Objetivo: entender qué resuelve MCP e instalar tu primer servidor con FastMCP.
Model Context Protocol (MCP) estandariza cómo un modelo de IA se conecta a herramientas y fuentes de datos externas. Antes de MCP, conectar un LLM a "tus" herramientas significaba una integración custom por cada combinación de modelo y herramienta — N modelos × M herramientas, todas con su propio formato. MCP define un protocolo único: construís tu servidor una vez, y cualquier cliente compatible (Claude Desktop, Claude Code, otros agentes) lo puede usar sin integración adicional.
Instala FastMCP
TERMINAL
pip install fastmcp
Tu primer servidor
servidor.py
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("mi-primer-servidor")
@mcp.tool()
def saludar(nombre: str) -> str:
"""Saluda a una persona por su nombre."""
return f"¡Hola, {nombre}! Este es tu primer servidor MCP."
if __name__ == "__main__":
mcp.run(transport="stdio")
Corrés python servidor.py y ya tenés un servidor MCP funcionando sobre stdio — el transporte más simple, pensado para que un cliente local (como Claude Desktop) lo lance como subproceso.
Reto
Agregá una segunda tool despedir(nombre: str) al mismo servidor y confirmá que ambas aparecen cuando lo inspecciones (nivel 9 te muestra cómo).
Nivel 2 · Tools
Herramientas con validación real
Objetivo: construir tools con parámetros tipados, validación y manejo de errores.
El decorador @mcp.tool() convierte los type hints de tu función en un JSON Schema automáticamente — el modelo ve ese schema para saber qué parámetros mandar y de qué tipo. El docstring es lo que el modelo lee para decidir cuándo usar la herramienta.
servidor.py
from pydantic import Field
from typing import Annotated
@mcp.tool()
def consultar_clima(
ciudad: Annotated[str, Field(description="Nombre de la ciudad, ej. 'Ciudad de Guatemala'")],
unidad: Annotated[str, Field(description="'celsius' o 'fahrenheit'")] = "celsius",
) -> dict:
"""Consulta el clima actual de una ciudad. Usar cuando el usuario pregunte por clima o temperatura."""
datos = {"Ciudad de Guatemala": 22}
if ciudad not in datos:
raise ValueError(f"No tengo datos de clima para '{ciudad}'")
temp = datos[ciudad]
if unidad == "fahrenheit":
temp = temp * 9 / 5 + 32
return {"ciudad": ciudad, "temperatura": temp, "unidad": unidad}
Cuando tu tool lanza una excepción (como el ValueError de arriba), FastMCP la convierte en un error MCP estructurado que el modelo puede leer e interpretar — mejor que devolver un string genérico de "algo salió mal".
Reto
Agregá una tool que reciba una lista de ciudades y devuelva el clima de todas, y probá qué pasa cuando el modelo le manda una ciudad que no existe en tus datos.
Nivel 3 · Resources
Datos direccionables por URI
Objetivo: exponer datos de solo lectura como resources, accesibles por URI sin invocación explícita de una tool.
Un resource es distinto de una tool: no es una acción que el modelo "decide ejecutar", es un dato direccionable que el cliente puede leer en cualquier momento — como un archivo o un endpoint de solo lectura, identificado por una URI.
servidor.py
@mcp.resource("config://empresa")
def configuracion_empresa() -> str:
"""Configuración general de la empresa, disponible para el modelo como contexto."""
return """
Nombre: Guatemalia AI
Horario de soporte: 8am - 6pm GMT-6
Idiomas soportados: español, inglés
"""
@mcp.resource("documentos://{doc_id}")
def obtener_documento(doc_id: str) -> str:
"""Devuelve el contenido de un documento por su ID — resource con parámetro en la URI."""
documentos = {"manual-01": "Contenido del manual de usuario..."}
return documentos.get(doc_id, "Documento no encontrado")
El segundo ejemplo usa una plantilla de URI (documentos://{doc_id}) — el cliente puede pedir documentos://manual-01 y FastMCP resuelve el parámetro automáticamente.
Reto
Convertí uno de tus artículos del blog en un resource direccionable por slug, ej. articulo://que-es-orquestador.
Nivel 4 · Prompts
Plantillas reutilizables
Objetivo: definir prompts parametrizados que el cliente puede exponer como comandos rápidos.
Un prompt en MCP es una plantilla de mensaje parametrizada — pensada para que el host (el cliente MCP) la muestre como algo similar a un comando de barra ("/"), sin que el usuario tenga que escribir el prompt completo cada vez.
servidor.py
@mcp.prompt()
def revisar_codigo(lenguaje: str, codigo: str) -> str:
"""Genera un prompt de revisión de código estructurada."""
return f"""Revisá el siguiente código en {lenguaje} y reportá:
1. Bugs potenciales
2. Problemas de seguridad
3. Sugerencias de legibilidad
```{lenguaje}
{codigo}
```"""
La diferencia práctica con una tool: un prompt no ejecuta nada por sí solo, solo devuelve el texto ya armado que el modelo va a procesar — es una forma de estandarizar y reutilizar prompts bien diseñados en vez de que cada usuario los reescriba desde cero.
Reto
Creá un prompt resumir_ticket(descripcion: str, prioridad: str) pensado para tu propio caso de soporte al cliente.
Nivel 5 · Conectar un cliente
Probar tu servidor con Claude
Objetivo: conectar tu servidor MCP a un cliente real y probarlo end-to-end.
Para probar tu servidor con Claude Desktop o Claude Code, lo registrás en la configuración del cliente — el cliente se encarga de lanzarlo como subproceso vía stdio cuando lo necesita.
Reiniciás el cliente, y las tools, resources y prompts de tu servidor ya aparecen disponibles en la conversación. Este es el ciclo de desarrollo más rápido: editás el servidor, reiniciás el cliente, probás en una conversación real.
Reto
Conectá tu servidor del Nivel 3 y pedile a Claude directamente "¿cuál es el horario de soporte?" — confirmá que lee el resource sin que se lo tengas que pegar vos en el mensaje.
Nivel 6 · HTTP stateless
De stdio a un servidor HTTP que escala
Objetivo: migrar tu servidor a transporte HTTP stateless, el estándar actual para producción.
stdio funciona para desarrollo local, pero un servidor de producción necesita vivir en la red, accesible por múltiples clientes. La especificación 2026-07-28 de MCP hizo un cambio central acá: el protocolo pasó de tener estado de sesión (que obligaba a sticky sessions para escalar) a un núcleo completamente stateless.
servidor_http.py
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("servidor-produccion")
@mcp.tool()
def consultar_clima(ciudad: str) -> dict:
"""Consulta el clima actual de una ciudad."""
return {"ciudad": ciudad, "temperatura": 22}
if __name__ == "__main__":
mcp.run(
transport="streamable-http",
stateless_http=True, # sin sesión de protocolo: cualquier instancia atiende cualquier request
json_response=True,
)
# uvicorn ya no hace falta manejarlo aparte: FastMCP expone el server ASGI directamente
Con stateless_http=True, cada request HTTP es autocontenido — no hace falta que el mismo cliente vuelva siempre a la misma instancia del servidor, que es exactamente lo que te permite ponerlo detrás de un load balancer estándar (nivel 10).
Reto
Corré tu servidor con transporte HTTP y hacé un curl directo al endpoint para ver la respuesta cruda antes de conectarlo a un cliente.
Nivel 7 · Autenticación
OAuth y Resource Indicators
Objetivo: entender por qué un servidor MCP expuesto en la red necesita validar tokens con audiencia específica.
Un servidor MCP en producción actúa como resource server de OAuth 2.1: debe validar que cada token de acceso fue efectivamente emitido para él, no reutilizado de otro servicio. Acá es donde entra RFC 8707 (Resource Indicators), que la especificación de MCP exige de forma explícita.
auth.py
from mcp.server.auth import TokenVerifier
class VerificadorToken(TokenVerifier):
async def verify_token(self, token: str) -> dict:
claims = decodificar_y_validar_jwt(token) # tu lógica de validación de firma/expiración
# Punto crítico: el token debe haber sido emitido específicamente
# para ESTE servidor MCP, no reutilizado de otro
audiencia_esperada = "https://mcp.guatemalia.com"
if audiencia_esperada not in claims.get("aud", []):
raise ValueError("Token no válido para este servidor MCP")
return claims
mcp = FastMCP("servidor-produccion", token_verifier=VerificadorToken())
Sin esta validación de audiencia, un token robado de un servidor MCP legítimo podría reutilizarse contra otro servidor distinto que confíe en el mismo proveedor de identidad — exactamente el escenario que Resource Indicators está diseñado para bloquear.
Reto
Simulá un token con la audiencia incorrecta y confirmá que tu servidor lo rechaza antes de ejecutar cualquier tool.
Nivel 8 · Guardrails
Límites explícitos por herramienta
Objetivo: agregar controles de permisos y límites de uso a nivel de tool, no solo a nivel de servidor.
No todas las tools de un servidor deberían tener el mismo nivel de riesgo permitido. Una tool de solo lectura ("consultar estado de un ticket") no necesita las mismas protecciones que una tool que modifica datos ("cancelar una suscripción").
guardrails.py
from functools import wraps
import time
contador_llamadas = {}
def limitar_uso(max_por_minuto: int):
def decorador(func):
@wraps(func)
def wrapper(*args, **kwargs):
ahora = time.time()
historial = contador_llamadas.setdefault(func.__name__, [])
historial[:] = [t for t in historial if ahora - t < 60]
if len(historial) >= max_por_minuto:
raise ValueError(f"Límite de {max_por_minuto} llamadas/minuto excedido para {func.__name__}")
historial.append(ahora)
return func(*args, **kwargs)
return wrapper
return decorador
@mcp.tool()
@limitar_uso(max_por_minuto=5)
def cancelar_suscripcion(usuario_id: str) -> dict:
"""Cancela la suscripción de un usuario. Acción irreversible — usar con cuidado."""
# En producción: esto además debería requerir confirmación explícita del humano
return {"status": "cancelada", "usuario_id": usuario_id}
Para acciones verdaderamente irreversibles (cancelar algo, borrar datos, mover dinero), la mejor práctica no es solo un rate limit — es requerir una confirmación explícita del lado humano antes de ejecutar, en vez de confiar en que el modelo "decidió bien".
Reto
Agregá una tool que requiera un segundo parámetro confirmar: bool = False y que falle explícitamente si no viene en True, forzando una segunda llamada explícita.
Nivel 9 · Testing
Depurar con MCP Inspector
Objetivo: probar tools, resources y prompts de forma aislada, sin depender de un cliente de chat completo.
TERMINAL
npx @modelcontextprotocol/inspector python servidor.py
# Abre una interfaz web local donde podés:
# - Ver todas las tools, resources y prompts registrados
# - Invocar cada tool manualmente con distintos parámetros
# - Ver el JSON Schema generado automáticamente por FastMCP
# - Inspeccionar errores sin necesidad de un cliente de chat
Tests automatizados
test_servidor.py
import pytest
from servidor import consultar_clima
def test_consultar_clima_ciudad_valida():
resultado = consultar_clima("Ciudad de Guatemala")
assert resultado["temperatura"] == 22
def test_consultar_clima_ciudad_invalida():
with pytest.raises(ValueError):
consultar_clima("Ciudad Inexistente")
Como las tools son funciones Python normales por debajo del decorador, se pueden testear directamente con pytest sin levantar el servidor MCP completo — reservá el MCP Inspector para probar la integración end-to-end, no la lógica de cada tool individualmente.
Reto
Escribí tests para las tools de los niveles 2 y 8, incluyendo el caso del rate limit excedido.
Nivel 10 · Arquitectura final
Servidor MCP en producción, balanceado
Objetivo: desplegar múltiples réplicas de tu servidor detrás de un load balancer, aprovechando que ahora es stateless.
Porque tu servidor ya corre en modo stateless_http=True (Nivel 6), cualquier réplica puede atender cualquier request — sin sticky sessions, sin almacén de estado compartido. Esto convierte a un servidor MCP en un servicio HTTP normal a efectos de infraestructura.
1. Empaquetar el servidor
Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["python", "servidor_http.py"]
2. Load balancer entre réplicas
nginx.conf
upstream servidor_mcp {
least_conn;
server mcp-1:8000;
server mcp-2:8000;
server mcp-3:8000;
}
server {
listen 443 ssl;
server_name mcp.guatemalia.com;
location / {
proxy_pass http://servidor_mcp;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
}
Con esto tenés el camino completo: de una tool suelta corriendo por stdio en el Nivel 1, a un servidor MCP de producción, autenticado, con guardrails por herramienta, y escalado horizontalmente sin fricción gracias a la nueva especificación stateless.
Reto final
Desplegá 2 réplicas de tu servidor del Nivel 8 en contenedores separados, ponelas detrás de nginx con least_conn, y confirmá que un mismo cliente puede recibir respuestas de cualquiera de las dos réplicas sin notar diferencia.