Módulo 5: MCP Server en Python
Tools y Resources en Python: Decoradores, Pydantic, y Diferencias con TypeScript
Tools y Resources en Python: Decoradores, Pydantic, y Diferencias con TypeScript
Descripción de la cápsula
En la cápsula anterior creaste tu primer MCP server Python y viste la magia de los decoradores: @mcp.tool() extrae nombre, descripción, y schema de tu función automáticamente. Ahora es momento de ir más profundo.
Esta cápsula cubre la implementación completa de tools y resources en Python: validación avanzada con Pydantic, manejo de tipos complejos, patrones de diseño para tools robustos, resources dinámicos con templates de URI, y las diferencias idiomáticas que hacen que el SDK de Python se sienta diferente al de TypeScript.
El objetivo es que al terminar esta cápsula, puedas implementar cualquier tool o resource que necesites para tu MCP server Python, con validación sólida y error handling adecuado.
Tools avanzados con @mcp.tool()
Más allá del "Hello World"
Los tools del setup eran simples: una función, un string de retorno. Los tools reales necesitan:
- Validación de inputs complejos
- Múltiples parámetros con tipos variados
- Manejo de errores robusto
- Retorno de contenido estructurado
- Interacción con servicios externos
Tool con múltiples tipos de parámetros
from mcp.server.fastmcp import FastMCP
import json
mcp = FastMCP("advanced-tools")
@mcp.tool()
async def search_items(
query: str,
category: str = "all",
max_results: int = 10,
include_metadata: bool = False,
tags: list[str] | None = None,
) -> str:
"""Busca items en el catálogo.
Permite filtrar por categoría, limitar resultados,
incluir metadata, y filtrar por tags.
"""
results = []
for i in range(min(max_results, 5)):
item = {
"id": i + 1,
"name": f"Item que coincide con '{query}' #{i + 1}",
"category": category,
}
if include_metadata:
item["metadata"] = {"relevance": 0.95 - (i * 0.1), "source": "catalog"}
if tags:
item["matched_tags"] = tags
results.append(item)
return json.dumps(results, indent=2, ensure_ascii=False)
El schema JSON generado automáticamente:
{
"type": "object",
"properties": {
"query": { "type": "string" },
"category": { "type": "string", "default": "all" },
"max_results": { "type": "integer", "default": 10 },
"include_metadata": { "type": "boolean", "default": false },
"tags": {
"anyOf": [
{ "type": "array", "items": { "type": "string" } },
{ "type": "null" }
],
"default": null
}
},
"required": ["query"]
}
El SDK convierte los type hints de Python a JSON Schema automáticamente. list[str] | None se convierte en un schema con anyOf.
Tool con validación Pydantic
Para inputs complejos, usa Pydantic models como parámetro:
from pydantic import BaseModel, Field
class CreateTaskInput(BaseModel):
title: str = Field(min_length=1, max_length=200, description="Título de la tarea")
description: str = Field(default="", max_length=2000, description="Descripción detallada")
priority: int = Field(default=3, ge=1, le=5, description="Prioridad del 1 (máxima) al 5 (mínima)")
assignee: str | None = Field(default=None, description="Persona asignada")
tags: list[str] = Field(default_factory=list, description="Etiquetas para organizar")
tasks: list[dict] = []
@mcp.tool()
async def create_task(input: CreateTaskInput) -> str:
"""Crea una nueva tarea con validación completa."""
task = {
"id": len(tasks) + 1,
"title": input.title,
"description": input.description,
"priority": input.priority,
"assignee": input.assignee,
"tags": input.tags,
"status": "pending",
}
tasks.append(task)
return json.dumps({"message": "Tarea creada", "task": task}, indent=2, ensure_ascii=False)
¿Por qué Pydantic en lugar de type hints simples?
Los type hints simples validan tipos pero no valores. Pydantic agrega:
| Validación | Type hints | Pydantic |
|---|---|---|
| Tipo correcto | ✅ | ✅ |
| Longitud mínima/máxima | ❌ | ✅ Field(min_length=1) |
| Rango numérico | ❌ | ✅ Field(ge=1, le=5) |
| Regex patterns | ❌ | ✅ Field(pattern=r'...') |
| Valores default complejos | Limitado | ✅ Field(default_factory=list) |
| Descripciones por campo | ❌ | ✅ Field(description="...") |
Comparación: Pydantic vs Zod
Zod (TypeScript):
const CreateTaskSchema = z.object({
title: z.string().min(1).max(200).describe("Título de la tarea"),
description: z.string().max(2000).default("").describe("Descripción detallada"),
priority: z.number().int().min(1).max(5).default(3).describe("Prioridad 1-5"),
assignee: z.string().optional().describe("Persona asignada"),
tags: z.array(z.string()).default([]).describe("Etiquetas"),
});
server.tool("create_task", "Crea una nueva tarea", CreateTaskSchema.shape, async (input) => {
// ...
});
Pydantic (Python):
class CreateTaskInput(BaseModel):
title: str = Field(min_length=1, max_length=200, description="Título de la tarea")
description: str = Field(default="", max_length=2000, description="Descripción detallada")
priority: int = Field(default=3, ge=1, le=5, description="Prioridad 1-5")
assignee: str | None = Field(default=None, description="Persona asignada")
tags: list[str] = Field(default_factory=list, description="Etiquetas")
@mcp.tool()
async def create_task(input: CreateTaskInput) -> str:
"""Crea una nueva tarea."""
# ...
La diferencia clave: en Zod defines el schema separado de la función. En Pydantic, defines un modelo que es a la vez el schema y la estructura de datos. El modelo Pydantic es una clase Python que puedes reutilizar en toda tu aplicación — no solo para MCP.
Tool con error handling
Los tools fallan. APIs caen, archivos no existen, inputs son inválidos. Tu tool debe manejar eso:
import httpx
@mcp.tool()
async def fetch_url(url: str, timeout: int = 10) -> str:
"""Obtiene el contenido de una URL.
Retorna el contenido de texto de la respuesta HTTP.
"""
try:
async with httpx.AsyncClient() as client:
response = await client.get(url, timeout=timeout)
response.raise_for_status()
content_type = response.headers.get("content-type", "")
if "text" in content_type or "json" in content_type:
return response.text[:5000]
return f"Respuesta recibida ({response.status_code}), pero el contenido no es texto: {content_type}"
except httpx.TimeoutException:
return f"Error: timeout después de {timeout} segundos conectando a {url}"
except httpx.HTTPStatusError as e:
return f"Error HTTP {e.response.status_code}: {e.response.text[:500]}"
except httpx.RequestError as e:
return f"Error de conexión: {e}"
except Exception as e:
return f"Error inesperado: {type(e).__name__}: {e}"
Patrón recomendado: No lances excepciones desde tools. Retorna mensajes de error como texto. El modelo puede leer el error e intentar corregir su approach. Si lanzas una excepción, el SDK la captura pero el mensaje puede ser menos útil.
Tool con patrón dry-run
import os
@mcp.tool()
async def delete_files(
directory: str,
pattern: str,
dry_run: bool = True,
) -> str:
"""Elimina archivos que coincidan con un patrón.
Por defecto ejecuta en modo dry_run (solo muestra qué se eliminaría).
Usa dry_run=false para ejecutar la eliminación real.
"""
import fnmatch
if not os.path.isdir(directory):
return f"Error: '{directory}' no es un directorio válido."
matches = []
for filename in os.listdir(directory):
if fnmatch.fnmatch(filename, pattern):
matches.append(filename)
if not matches:
return f"No se encontraron archivos que coincidan con '{pattern}' en {directory}."
if dry_run:
file_list = "\n".join(f" - {f}" for f in matches)
return f"[DRY RUN] Se eliminarían {len(matches)} archivos:\n{file_list}\n\nUsa dry_run=false para ejecutar."
deleted = []
errors = []
for filename in matches:
try:
filepath = os.path.join(directory, filename)
os.remove(filepath)
deleted.append(filename)
except OSError as e:
errors.append(f"{filename}: {e}")
result = f"Eliminados: {len(deleted)} archivos."
if errors:
result += f"\nErrores: {len(errors)}\n" + "\n".join(f" - {e}" for e in errors)
return result
El patrón dry-run es fundamental para tools destructivos. El modelo primero ejecuta con dry_run=True, el usuario ve el preview, y luego puede confirmar con dry_run=False.
Resources avanzados con @mcp.resource()
Resources estáticos vs dinámicos
Resource estático — el URI es fijo:
@mcp.resource("config://app/version")
async def app_version() -> str:
"""Versión actual de la aplicación."""
return "2.5.1"
Resource dinámico con template — el URI tiene parámetros:
@mcp.resource("users://{user_id}/profile")
async def user_profile(user_id: str) -> str:
"""Perfil de un usuario específico."""
users_db = {
"1": {"name": "Ann", "role": "admin", "email": "ann@example.com"},
"2": {"name": "Carl", "role": "developer", "email": "carl@example.com"},
}
user = users_db.get(user_id)
if not user:
return json.dumps({"error": f"Usuario {user_id} no encontrado"})
return json.dumps(user, indent=2)
El {user_id} en el URI es un template. Cuando un cliente pide users://42/profile, el SDK extrae user_id="42" y lo pasa a tu función.
Resource que retorna JSON estructurado
@mcp.resource("metrics://server/health")
async def server_health() -> str:
"""Métricas de salud del servidor."""
import psutil
health = {
"status": "healthy",
"cpu_percent": psutil.cpu_percent(),
"memory": {
"total_gb": round(psutil.virtual_memory().total / (1024**3), 2),
"used_percent": psutil.virtual_memory().percent,
},
"disk": {
"total_gb": round(psutil.disk_usage("/").total / (1024**3), 2),
"used_percent": psutil.disk_usage("/").percent,
},
}
return json.dumps(health, indent=2)
Comparación: Resources en Python vs TypeScript
TypeScript:
server.resource(
"user-profile",
"users://{user_id}/profile",
{ description: "Perfil de un usuario", mimeType: "application/json" },
async (uri) => {
const userId = uri.pathname.split("/")[1];
const user = await getUserById(userId);
return {
contents: [{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(user),
}],
};
}
);
Python:
@mcp.resource("users://{user_id}/profile")
async def user_profile(user_id: str) -> str:
"""Perfil de un usuario."""
user = await get_user_by_id(user_id)
return json.dumps(user, indent=2)
Diferencias clave:
- Python extrae los parámetros del URI template automáticamente como argumentos de la función
- No necesitas parsear el URI manualmente
- El retorno es un string simple — el SDK lo envuelve en el formato MCP
- La descripción viene del docstring
Patrones de diseño para tools
Patrón 1: CRUD completo
from pydantic import BaseModel, Field
from datetime import datetime
mcp = FastMCP("todo-crud")
todos: list[dict] = []
next_id = 1
class TodoCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
description: str = Field(default="")
priority: int = Field(default=3, ge=1, le=5)
class TodoUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=200)
description: str | None = None
priority: int | None = Field(default=None, ge=1, le=5)
completed: bool | None = None
@mcp.tool()
async def create_todo(input: TodoCreate) -> str:
"""Crea un nuevo TODO."""
global next_id
todo = {
"id": next_id,
"title": input.title,
"description": input.description,
"priority": input.priority,
"completed": False,
"created_at": datetime.now().isoformat(),
}
next_id += 1
todos.append(todo)
return json.dumps({"message": "TODO creado", "todo": todo}, indent=2)
@mcp.tool()
async def get_todo(todo_id: int) -> str:
"""Obtiene un TODO por su ID."""
for todo in todos:
if todo["id"] == todo_id:
return json.dumps(todo, indent=2)
return json.dumps({"error": f"TODO con ID {todo_id} no encontrado"})
@mcp.tool()
async def update_todo(todo_id: int, updates: TodoUpdate) -> str:
"""Actualiza un TODO existente."""
for todo in todos:
if todo["id"] == todo_id:
update_data = updates.model_dump(exclude_none=True)
todo.update(update_data)
todo["updated_at"] = datetime.now().isoformat()
return json.dumps({"message": "TODO actualizado", "todo": todo}, indent=2)
return json.dumps({"error": f"TODO con ID {todo_id} no encontrado"})
@mcp.tool()
async def delete_todo(todo_id: int) -> str:
"""Elimina un TODO por su ID."""
global todos
original_len = len(todos)
todos = [t for t in todos if t["id"] != todo_id]
if len(todos) < original_len:
return f"TODO con ID {todo_id} eliminado."
return f"Error: TODO con ID {todo_id} no encontrado."
@mcp.tool()
async def list_todos(
status: str = "all",
sort_by: str = "created_at",
) -> str:
"""Lista TODOs con filtros opcionales.
status: 'all', 'completed', o 'pending'.
sort_by: 'created_at', 'priority', o 'title'.
"""
filtered = todos
if status == "completed":
filtered = [t for t in todos if t["completed"]]
elif status == "pending":
filtered = [t for t in todos if not t["completed"]]
if sort_by in ("priority", "title", "created_at"):
filtered = sorted(filtered, key=lambda t: t.get(sort_by, ""))
return json.dumps({"total": len(filtered), "todos": filtered}, indent=2)
Patrón 2: Tool que envuelve una API externa
import httpx
@mcp.tool()
async def get_weather(city: str, units: str = "metric") -> str:
"""Obtiene el clima actual de una ciudad.
units: 'metric' (Celsius) o 'imperial' (Fahrenheit).
"""
api_key = os.environ.get("OPENWEATHER_API_KEY")
if not api_key:
return "Error: OPENWEATHER_API_KEY no configurada. Establece la variable de entorno."
try:
async with httpx.AsyncClient() as client:
response = await client.get(
"https://api.openweathermap.org/data/2.5/weather",
params={"q": city, "appid": api_key, "units": units, "lang": "es"},
timeout=10,
)
response.raise_for_status()
data = response.json()
temp = data["main"]["temp"]
feels_like = data["main"]["feels_like"]
description = data["weather"][0]["description"]
humidity = data["main"]["humidity"]
unit_symbol = "°C" if units == "metric" else "°F"
return (
f"Clima en {city}:\n"
f" Temperatura: {temp}{unit_symbol} (sensación: {feels_like}{unit_symbol})\n"
f" Condición: {description}\n"
f" Humedad: {humidity}%"
)
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
return f"Error: ciudad '{city}' no encontrada."
return f"Error de API: {e.response.status_code}"
except httpx.RequestError as e:
return f"Error de conexión: {e}"
Patrón 3: Tool + Resource complementarios
import json
project_files: dict[str, str] = {}
@mcp.resource("project://files/list")
async def list_project_files() -> str:
"""Lista de archivos en el proyecto."""
return json.dumps(list(project_files.keys()), indent=2)
@mcp.resource("project://files/{filename}")
async def read_project_file(filename: str) -> str:
"""Contenido de un archivo del proyecto."""
content = project_files.get(filename)
if content is None:
return f"Error: archivo '{filename}' no encontrado."
return content
@mcp.tool()
async def write_project_file(filename: str, content: str) -> str:
"""Escribe un archivo en el proyecto."""
project_files[filename] = content
return f"Archivo '{filename}' escrito ({len(content)} caracteres)."
@mcp.tool()
async def analyze_project() -> str:
"""Analiza todos los archivos del proyecto.
Retorna estadísticas de cada archivo.
"""
if not project_files:
return "No hay archivos en el proyecto."
stats = []
for name, content in project_files.items():
lines = content.count("\n") + 1
words = len(content.split())
stats.append({"file": name, "lines": lines, "words": words, "chars": len(content)})
return json.dumps({"total_files": len(stats), "files": stats}, indent=2)
El resource project://files/list permite al modelo ver qué archivos hay. El resource project://files/{filename} permite leer un archivo específico. El tool write_project_file permite crear archivos. Y analyze_project actúa sobre los datos. Resource para leer, tool para actuar.
Decoradores vs Clases: la diferencia fundamental
El approach de TypeScript: explícito y verboso
server.tool(
"analyze_text", // nombre: explícito
"Analiza un texto y retorna estadísticas", // descripción: explícita
{ // schema: explícito con Zod
text: z.string().min(1),
include_sentiment: z.boolean().default(false),
},
async ({ text, include_sentiment }) => { // handler: separado
const words = text.split(/\s+/).length;
const sentences = text.split(/[.!?]+/).filter(Boolean).length;
let result = `Palabras: ${words}, Oraciones: ${sentences}`;
if (include_sentiment) {
result += `, Sentimiento: neutral`;
}
return { content: [{ type: "text", text: result }] };
}
);
Cuatro piezas separadas: nombre, descripción, schema, handler.
El approach de Python: inferido y conciso
@mcp.tool()
async def analyze_text(text: str, include_sentiment: bool = False) -> str:
"""Analiza un texto y retorna estadísticas."""
words = len(text.split())
sentences = len([s for s in text.split(".") if s.strip()])
result = f"Palabras: {words}, Oraciones: {sentences}"
if include_sentiment:
result += ", Sentimiento: neutral"
return result
Una sola pieza: la función. Todo lo demás se infiere.
| Elemento | TypeScript | Python |
|---|---|---|
| Nombre | "analyze_text" (string literal) | analyze_text (nombre de función) |
| Descripción | String separado | Docstring |
| Schema | Zod object | Type hints |
| Handler | Función separada | La misma función |
| Return | { content: [{ type: "text", text }] } | String directo |
Ninguno es "mejor" — son filosofías diferentes. TypeScript es explícito: ves exactamente qué se registra. Python es convencional: si sigues las convenciones (docstrings, type hints), el framework hace el trabajo.
Ejercicios
Ejercicio 1: Tool con Pydantic model anidado (Medio)
Crea un tool create_event que reciba un modelo Pydantic con:
title(str, requerido, 1-100 chars)date(str, requerido, formato ISO)locationconname(str) yaddress(str, opcional)attendees(lista de strings, máximo 50)
Ver solución
from pydantic import BaseModel, Field
from mcp.server.fastmcp import FastMCP
import json
mcp = FastMCP("event-manager")
events: list[dict] = []
class Location(BaseModel):
name: str = Field(description="Nombre del lugar")
address: str | None = Field(default=None, description="Dirección completa")
class CreateEventInput(BaseModel):
title: str = Field(min_length=1, max_length=100, description="Título del evento")
date: str = Field(description="Fecha en formato ISO (YYYY-MM-DD)")
location: Location = Field(description="Ubicación del evento")
attendees: list[str] = Field(
default_factory=list, max_length=50, description="Lista de asistentes"
)
@mcp.tool()
async def create_event(input: CreateEventInput) -> str:
"""Crea un nuevo evento con ubicación y asistentes."""
event = {
"id": len(events) + 1,
"title": input.title,
"date": input.date,
"location": input.location.model_dump(),
"attendees": input.attendees,
"attendee_count": len(input.attendees),
}
events.append(event)
return json.dumps({"message": "Evento creado", "event": event}, indent=2, ensure_ascii=False)
if __name__ == "__main__":
mcp.run()
Ejercicio 2: Resource con template URI dinámico (Medio)
Crea un MCP server con:
- Resource
inventory://products/{category}que retorne productos filtrados por categoría - Resource
inventory://products/{category}/{product_id}que retorne un producto específico - Tool
add_productpara agregar productos al inventario
Ver solución
from mcp.server.fastmcp import FastMCP
import json
mcp = FastMCP("inventory")
products: list[dict] = [
{"id": 1, "name": "Laptop Pro", "category": "electronics", "price": 1299.99},
{"id": 2, "name": "Mechanical Keyboard", "category": "electronics", "price": 89.99},
{"id": 3, "name": "Python Cookbook", "category": "books", "price": 45.00},
{"id": 4, "name": "Standing Desk", "category": "furniture", "price": 599.99},
]
@mcp.resource("inventory://products/{category}")
async def products_by_category(category: str) -> str:
"""Productos filtrados por categoría."""
filtered = [p for p in products if p["category"] == category]
return json.dumps(
{"category": category, "count": len(filtered), "products": filtered},
indent=2,
)
@mcp.resource("inventory://products/{category}/{product_id}")
async def product_detail(category: str, product_id: str) -> str:
"""Detalle de un producto específico."""
for p in products:
if p["category"] == category and str(p["id"]) == product_id:
return json.dumps(p, indent=2)
return json.dumps({"error": f"Producto {product_id} no encontrado en '{category}'"})
@mcp.tool()
async def add_product(name: str, category: str, price: float) -> str:
"""Agrega un producto al inventario."""
product = {
"id": max((p["id"] for p in products), default=0) + 1,
"name": name,
"category": category,
"price": price,
}
products.append(product)
return json.dumps({"message": "Producto agregado", "product": product}, indent=2)
if __name__ == "__main__":
mcp.run()
Ejercicio 3: Convertir tools de TypeScript a Python (Medio)
Convierte el siguiente MCP server de TypeScript a Python idiomático:
const server = new McpServer({ name: "file-stats", version: "1.0.0" });
server.tool("count_lines", "Cuenta líneas de un archivo", {
filepath: z.string().describe("Ruta al archivo"),
skip_empty: z.boolean().default(false).describe("Ignorar líneas vacías"),
}, async ({ filepath, skip_empty }) => {
const content = await fs.readFile(filepath, "utf-8");
let lines = content.split("\n");
if (skip_empty) {
lines = lines.filter(line => line.trim().length > 0);
}
return {
content: [{
type: "text",
text: JSON.stringify({ filepath, total_lines: lines.length, skip_empty }),
}],
};
});
server.tool("file_info", "Información de un archivo", {
filepath: z.string(),
}, async ({ filepath }) => {
const stats = await fs.stat(filepath);
return {
content: [{
type: "text",
text: JSON.stringify({
size_bytes: stats.size,
created: stats.birthtime.toISOString(),
modified: stats.mtime.toISOString(),
is_directory: stats.isDirectory(),
}),
}],
};
});
Ver solución
import os
import json
from datetime import datetime
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("file-stats")
@mcp.tool()
async def count_lines(filepath: str, skip_empty: bool = False) -> str:
"""Cuenta líneas de un archivo.
Opcionalmente ignora líneas vacías.
"""
try:
with open(filepath, "r", encoding="utf-8") as f:
lines = f.readlines()
if skip_empty:
lines = [line for line in lines if line.strip()]
return json.dumps(
{"filepath": filepath, "total_lines": len(lines), "skip_empty": skip_empty},
indent=2,
)
except FileNotFoundError:
return json.dumps({"error": f"Archivo no encontrado: {filepath}"})
except PermissionError:
return json.dumps({"error": f"Sin permisos para leer: {filepath}"})
@mcp.tool()
async def file_info(filepath: str) -> str:
"""Información de un archivo: tamaño, fechas, tipo."""
try:
stats = os.stat(filepath)
return json.dumps(
{
"size_bytes": stats.st_size,
"created": datetime.fromtimestamp(stats.st_ctime).isoformat(),
"modified": datetime.fromtimestamp(stats.st_mtime).isoformat(),
"is_directory": os.path.isdir(filepath),
},
indent=2,
)
except FileNotFoundError:
return json.dumps({"error": f"Archivo no encontrado: {filepath}"})
if __name__ == "__main__":
mcp.run()
Notas sobre la conversión:
z.string().describe(...)→ type hintstr+ parámetro en docstringz.boolean().default(false)→bool = Falsefs.readFile(async en Node) →open()(sync en Python, pero dentro deasync def)- El retorno simplificado: string en lugar de
{ content: [{ type: "text", text }] } - Error handling agregado (Python idiomático)
Ejercicio 4: Tool con enum y validación estricta (Difícil)
Crea un tool process_data que:
- Reciba un
DataProcessInputcon un campoformatque sea un Enum (csv,json,xml) - Reciba
datacomo string - Reciba
optionscomo un modelo Pydantic conskip_header(bool),delimiter(str), yencoding(str) - Valide que
delimitersolo se use cuandoformatescsv
Ver solución
from enum import Enum
from pydantic import BaseModel, Field, model_validator
from mcp.server.fastmcp import FastMCP
import json
mcp = FastMCP("data-processor")
class DataFormat(str, Enum):
CSV = "csv"
JSON = "json"
XML = "xml"
class ProcessOptions(BaseModel):
skip_header: bool = Field(default=False, description="Saltar primera línea (solo CSV)")
delimiter: str = Field(default=",", description="Delimitador (solo CSV)")
encoding: str = Field(default="utf-8", description="Codificación del texto")
class DataProcessInput(BaseModel):
format: DataFormat = Field(description="Formato de los datos")
data: str = Field(min_length=1, description="Datos a procesar")
options: ProcessOptions = Field(default_factory=ProcessOptions)
@model_validator(mode="after")
def validate_options(self):
if self.format != DataFormat.CSV and self.options.delimiter != ",":
raise ValueError("El delimiter solo aplica para formato CSV")
if self.format != DataFormat.CSV and self.options.skip_header:
raise ValueError("skip_header solo aplica para formato CSV")
return self
@mcp.tool()
async def process_data(input: DataProcessInput) -> str:
"""Procesa datos en formato CSV, JSON o XML."""
if input.format == DataFormat.CSV:
lines = input.data.strip().split("\n")
if input.options.skip_header and len(lines) > 1:
lines = lines[1:]
rows = [line.split(input.options.delimiter) for line in lines]
return json.dumps(
{"format": "csv", "rows": len(rows), "columns": len(rows[0]) if rows else 0, "data": rows},
indent=2,
)
elif input.format == DataFormat.JSON:
try:
parsed = json.loads(input.data)
item_count = len(parsed) if isinstance(parsed, list) else 1
return json.dumps({"format": "json", "items": item_count, "valid": True}, indent=2)
except json.JSONDecodeError as e:
return json.dumps({"format": "json", "valid": False, "error": str(e)})
elif input.format == DataFormat.XML:
tag_count = input.data.count("<") // 2
return json.dumps({"format": "xml", "estimated_tags": tag_count}, indent=2)
return json.dumps({"error": "Formato no soportado"})
if __name__ == "__main__":
mcp.run()
Ejercicio 5: Server completo con las 3 primitivas (Difícil)
Crea un MCP server "bookmark-manager" con:
- Resources:
bookmarks://all,bookmarks://category/{cat} - Tools:
add_bookmark,delete_bookmark,search_bookmarks - Prompt:
organize_bookmarksque pida al modelo organizar los bookmarks por categoría
Ver solución
from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field
from datetime import datetime
import json
mcp = FastMCP("bookmark-manager")
bookmarks: list[dict] = []
next_id = 1
class BookmarkInput(BaseModel):
url: str = Field(description="URL del bookmark")
title: str = Field(min_length=1, max_length=200, description="Título descriptivo")
category: str = Field(default="uncategorized", description="Categoría")
tags: list[str] = Field(default_factory=list, description="Tags para búsqueda")
@mcp.resource("bookmarks://all")
async def all_bookmarks() -> str:
"""Todos los bookmarks almacenados."""
return json.dumps(
{"total": len(bookmarks), "bookmarks": bookmarks}, indent=2, ensure_ascii=False
)
@mcp.resource("bookmarks://category/{category}")
async def bookmarks_by_category(category: str) -> str:
"""Bookmarks filtrados por categoría."""
filtered = [b for b in bookmarks if b["category"] == category]
return json.dumps(
{"category": category, "count": len(filtered), "bookmarks": filtered},
indent=2,
ensure_ascii=False,
)
@mcp.tool()
async def add_bookmark(input: BookmarkInput) -> str:
"""Agrega un nuevo bookmark."""
global next_id
bookmark = {
"id": next_id,
"url": input.url,
"title": input.title,
"category": input.category,
"tags": input.tags,
"created_at": datetime.now().isoformat(),
}
next_id += 1
bookmarks.append(bookmark)
return json.dumps({"message": "Bookmark agregado", "bookmark": bookmark}, indent=2, ensure_ascii=False)
@mcp.tool()
async def delete_bookmark(bookmark_id: int) -> str:
"""Elimina un bookmark por ID."""
global bookmarks
before = len(bookmarks)
bookmarks = [b for b in bookmarks if b["id"] != bookmark_id]
if len(bookmarks) < before:
return f"Bookmark {bookmark_id} eliminado."
return f"Error: bookmark {bookmark_id} no encontrado."
@mcp.tool()
async def search_bookmarks(query: str) -> str:
"""Busca bookmarks por título, URL o tags."""
query_lower = query.lower()
results = [
b
for b in bookmarks
if query_lower in b["title"].lower()
or query_lower in b["url"].lower()
or any(query_lower in tag.lower() for tag in b["tags"])
]
return json.dumps({"query": query, "results": len(results), "bookmarks": results}, indent=2, ensure_ascii=False)
@mcp.prompt()
async def organize_bookmarks() -> str:
"""Pide al modelo organizar los bookmarks por categoría."""
data = json.dumps(bookmarks, indent=2, ensure_ascii=False) if bookmarks else "No hay bookmarks."
return f"""Analiza los siguientes bookmarks y sugiere una reorganización por categorías:
{data}
Por favor:
1. Agrupa los bookmarks en categorías lógicas
2. Sugiere nuevas categorías si las actuales no son descriptivas
3. Identifica bookmarks duplicados o similares
4. Recomienda tags útiles para cada bookmark
5. Usa el tool delete_bookmark y add_bookmark para implementar los cambios"""
if __name__ == "__main__":
mcp.run()
Troubleshooting
"El tool no recibe los parámetros correctos"
Causa: El modelo envía parámetros que no coinciden con los type hints de tu función.
Solución:
@mcp.tool()
async def my_tool(
name: str, # ✅ Tipo explícito
count: int = 5, # ✅ Default explícito
tags: list[str] | None = None, # ✅ Optional con default
) -> str:
"""Descripción que le dice al modelo exactamente qué parámetros usar."""
...
"ValidationError de Pydantic al invocar el tool"
Causa: Los datos enviados no pasan la validación de Pydantic.
Solución:
class MyInput(BaseModel):
value: int = Field(ge=0, le=100)
@mcp.tool()
async def my_tool(input: MyInput) -> str:
"""El rango válido es 0-100. Valores fuera de rango serán rechazados."""
return str(input.value)
"El resource template no extrae los parámetros del URI"
Causa: Los nombres de los parámetros en el URI template no coinciden con los de la función.
Solución:
@mcp.resource("data://{item_type}/{item_id}")
async def get_item(item_type: str, item_id: str) -> str:
...
"TypeError: object str can't be used in 'await' expression"
Causa: Estás usando await en una función que no es async, o estás llamando una función sync con await.
Solución:
@mcp.tool()
async def my_tool(path: str) -> str:
content = open(path).read() # sync, sin await
return content
Resumen
En esta cápsula aprendiste:
- Tools avanzados con múltiples parámetros, tipos complejos, y Pydantic models
- Pydantic vs Zod: ambos validan, pero Pydantic se integra con type hints de Python
- Resources dinámicos con templates de URI que extraen parámetros automáticamente
- Patrones de diseño: CRUD completo, API wrapper, dry-run, tool + resource complementarios
- Decoradores vs Clases: Python infiere nombre, descripción, y schema; TypeScript los declara explícitamente
- Error handling como texto de retorno (no excepciones) para que el modelo pueda interpretar errores
Próxima cápsula: Patrones async en MCP Python — asyncio, context managers async, generators, y por qué async importa cuando tu MCP server conecta con APIs y databases.
Recursos adicionales
- Pydantic Field Validators — Validación avanzada con Pydantic
- MCP Python SDK — Tools — Implementación oficial de tools
- Python Type Hints Cheat Sheet — Referencia rápida de type hints
- Zod Documentation — Para comparar con Pydantic
- MCP Specification — Resources — Spec oficial de resources
- MCP Specification — Tools — Spec oficial de tools
Siguiente cápsula: Patrones Async en MCP — asyncio en el contexto de MCP servers, conexiones a APIs, y error handling async.