Módulo 3: Tres Primitivas — Resources, Tools, Prompts

Tools: Funciones que el Modelo Puede Invocar

Tools: Funciones que el Modelo Puede Invocar

Descripción de la cápsula

Si Resources son los ojos del modelo (le permiten ver datos), Tools son las manos — le permiten hacer cosas. Un tool es una función que el modelo puede invocar a través del MCP server para ejecutar acciones con efectos reales: crear archivos, insertar registros en una base de datos, enviar mensajes, llamar APIs externas, ejecutar cálculos.

La diferencia fundamental con Resources es que los tools tienen side effects. Cuando un tool se ejecuta, algo cambia en el mundo. Por eso los tools requieren un nivel adicional de diseño: necesitan schemas que definan sus inputs, validación de parámetros, manejo de errores, y un modelo de permisos donde el usuario aprueba la ejecución.

Tools es la primitiva más usada en MCP servers reales. Si miras cualquier MCP server popular — Filesystem, GitHub, Slack — la mayoría de sus capabilities son tools. Entender cómo diseñar e implementar tools es la habilidad más valiosa de este módulo.


¿Qué es un Tool?

Definición formal

Un Tool en MCP es una función con un nombre, una descripción, un schema de input (JSON Schema), y un handler que ejecuta la lógica. El flujo es:

  1. El server registra los tools disponibles con sus schemas
  2. El host descubre los tools y se los presenta al modelo
  3. El modelo decide invocar un tool basado en la conversación
  4. El host solicita aprobación al usuario
  5. El server ejecuta el tool y retorna el resultado

Definición práctica

Un tool responde a: "¿Qué acciones puede ejecutar el modelo a través de este server?"

Ejemplos de tools:
├── create_file        → Crea un archivo nuevo
├── insert_user        → Inserta un usuario en la database
├── send_slack_message → Envía un mensaje a Slack
├── run_query          → Ejecuta una query SQL
├── deploy_app         → Despliega la aplicación
└── resize_image       → Redimensiona una imagen

Características clave

CaracterísticaDetalle
NombreIdentificador único del tool (snake_case por convención)
DescripciónTexto que el modelo usa para decidir cuándo invocar el tool
Input SchemaJSON Schema que define los parámetros requeridos y opcionales
HandlerFunción que ejecuta la lógica del tool
Side effectsLos tools pueden y suelen modificar estado
AprobaciónEl usuario debe aprobar la ejecución (en la mayoría de hosts)

Anatomía de un Tool

Estructura de registro

Cuando registras un tool, defines:

interface ToolDefinition {
  name: string;               // "create_file"
  description: string;        // "Crea un archivo nuevo con el contenido especificado"
  inputSchema: {              // JSON Schema para los inputs
    type: "object";
    properties: {
      path: { type: "string"; description: "Ruta del archivo" };
      content: { type: "string"; description: "Contenido del archivo" };
    };
    required: ["path", "content"];
  };
}

Estructura de respuesta

Cuando un tool se ejecuta, retorna:

interface ToolResult {
  content: Array<{
    type: "text" | "image" | "resource";
    text?: string;
    data?: string;     // base64 para imágenes
    mimeType?: string;
  }>;
  isError?: boolean;   // true si la ejecución falló
}

El ciclo de vida completo

1. Registro
   Server: "Tengo un tool 'create_file' que acepta path y content"

2. Descubrimiento
   Host al modelo: "Tienes disponible create_file(path, content)"

3. Decisión del modelo
   Usuario: "Crea un archivo hello.txt con 'Hola Mundo'"
   Modelo: "Voy a usar create_file con path='hello.txt' y content='Hola Mundo'"

4. Aprobación
   Host al usuario: "Claude quiere ejecutar create_file. ¿Aprobar?"
   Usuario: "Sí"

5. Ejecución
   Host al Server: tools/call { name: "create_file", arguments: { path: "hello.txt", content: "Hola Mundo" } }
   Server: *ejecuta la función* → retorna resultado

6. Respuesta
   Server al Host: { content: [{ type: "text", text: "Archivo hello.txt creado exitosamente" }] }
   Modelo al usuario: "He creado el archivo hello.txt con el contenido 'Hola Mundo'"

La importancia del Input Schema

El input schema no es un detalle técnico — es lo que le dice al modelo qué parámetros necesita y cómo usarlos. Un schema bien diseñado resulta en mejor uso del tool.

Schema pobre vs Schema rico

// ❌ Schema pobre — el modelo no sabe qué poner
{
  properties: {
    data: { type: "string" }
  }
}

// ✅ Schema rico — el modelo sabe exactamente qué necesita
{
  properties: {
    filePath: {
      type: "string",
      description: "Ruta completa del archivo a crear (e.g., 'src/utils/helpers.ts')"
    },
    content: {
      type: "string",
      description: "Contenido completo del archivo"
    },
    overwrite: {
      type: "boolean",
      description: "Si true, sobreescribe archivos existentes. Default: false",
      default: false
    }
  },
  required: ["filePath", "content"]
}

Regla: Las descripciones en el schema son instrucciones para el modelo. Sé específico, incluye ejemplos, y marca claramente qué es requerido y qué es opcional.


Implementación en TypeScript

Tool básico con Zod

El SDK de TypeScript usa Zod para definir schemas — es más ergonómico que JSON Schema y provee validación automática:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

const server = new McpServer({
  name: "my-server",
  version: "1.0.0",
});

server.tool(
  "create_file",
  "Crea un archivo nuevo con el contenido especificado",
  {
    filePath: z.string().describe("Ruta del archivo a crear"),
    content: z.string().describe("Contenido del archivo"),
    overwrite: z.boolean().default(false).describe("Sobreescribir si existe"),
  },
  async ({ filePath, content, overwrite }) => {
    const fs = await import("fs/promises");
    const path = await import("path");

    if (!overwrite) {
      try {
        await fs.access(filePath);
        return {
          content: [{ type: "text", text: `Error: el archivo ${filePath} ya existe. Usa overwrite: true para sobreescribir.` }],
          isError: true,
        };
      } catch {
        // El archivo no existe, podemos continuar
      }
    }

    const dir = path.dirname(filePath);
    await fs.mkdir(dir, { recursive: true });
    await fs.writeFile(filePath, content, "utf-8");

    return {
      content: [{
        type: "text",
        text: `Archivo creado exitosamente: ${filePath} (${content.length} caracteres)`,
      }],
    };
  }
);

Tool con operaciones de base de datos

server.tool(
  "insert_user",
  "Inserta un nuevo usuario en la base de datos",
  {
    name: z.string().min(1).describe("Nombre del usuario"),
    email: z.string().email().describe("Email del usuario"),
    role: z.enum(["admin", "user", "viewer"]).default("user").describe("Rol del usuario"),
  },
  async ({ name, email, role }) => {
    try {
      const result = await db.query(
        "INSERT INTO users (name, email, role) VALUES ($1, $2, $3) RETURNING id",
        [name, email, role]
      );

      return {
        content: [{
          type: "text",
          text: JSON.stringify({
            success: true,
            userId: result.rows[0].id,
            message: `Usuario '${name}' creado con rol '${role}'`,
          }, null, 2),
        }],
      };
    } catch (error) {
      return {
        content: [{
          type: "text",
          text: `Error al crear usuario: ${error instanceof Error ? error.message : "Error desconocido"}`,
        }],
        isError: true,
      };
    }
  }
);

Tool con llamada a API externa

server.tool(
  "send_notification",
  "Envía una notificación a un canal de Slack",
  {
    channel: z.string().describe("Nombre del canal (e.g., '#general')"),
    message: z.string().describe("Mensaje a enviar"),
    urgent: z.boolean().default(false).describe("Marcar como urgente"),
  },
  async ({ channel, message, urgent }) => {
    const prefix = urgent ? "🚨 URGENTE: " : "";

    const response = await fetch("https://slack.com/api/chat.postMessage", {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${process.env.SLACK_TOKEN}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        channel,
        text: `${prefix}${message}`,
      }),
    });

    const result = await response.json();

    if (!result.ok) {
      return {
        content: [{ type: "text", text: `Error al enviar: ${result.error}` }],
        isError: true,
      };
    }

    return {
      content: [{
        type: "text",
        text: `Mensaje enviado a ${channel}: "${message}"${urgent ? " (urgente)" : ""}`,
      }],
    };
  }
);

Implementación en Python

Tool básico con FastMCP

from mcp.server.fastmcp import FastMCP

server = FastMCP("my-server")

@server.tool()
async def create_file(file_path: str, content: str, overwrite: bool = False) -> str:
    """Crea un archivo nuevo con el contenido especificado.

    Args:
        file_path: Ruta del archivo a crear
        content: Contenido del archivo
        overwrite: Si True, sobreescribe archivos existentes
    """
    import os

    if not overwrite and os.path.exists(file_path):
        raise ValueError(f"El archivo {file_path} ya existe. Usa overwrite=True para sobreescribir.")

    os.makedirs(os.path.dirname(file_path), exist_ok=True)
    with open(file_path, "w") as f:
        f.write(content)

    return f"Archivo creado exitosamente: {file_path} ({len(content)} caracteres)"

Tool con validación

@server.tool()
async def insert_user(name: str, email: str, role: str = "user") -> str:
    """Inserta un nuevo usuario en la base de datos.

    Args:
        name: Nombre del usuario (mínimo 1 carácter)
        email: Email válido del usuario
        role: Rol del usuario (admin, user, viewer). Default: user
    """
    if role not in ("admin", "user", "viewer"):
        raise ValueError(f"Rol inválido: {role}. Opciones: admin, user, viewer")

    if "@" not in email:
        raise ValueError(f"Email inválido: {email}")

    import json

    user_id = await db.execute(
        "INSERT INTO users (name, email, role) VALUES ($1, $2, $3) RETURNING id",
        name, email, role
    )

    return json.dumps({
        "success": True,
        "userId": user_id,
        "message": f"Usuario '{name}' creado con rol '{role}'",
    }, indent=2)

Para tools que llaman APIs externas en Python, usa httpx (async HTTP client). El patrón es el mismo: validar inputs, ejecutar la llamada, manejar errores, retornar resultado formateado.


Patrones de diseño para Tools

Patrón 1: CRUD completo

Si expones operaciones CRUD, crea un tool por operación:

// Crear
server.tool("create_task", "Crea una nueva tarea", { ... }, async (args) => { ... });

// Leer (podría ser Resource, pero como tool permite queries complejas)
server.tool("search_tasks", "Busca tareas con filtros", { ... }, async (args) => { ... });

// Actualizar
server.tool("update_task", "Actualiza una tarea existente", { ... }, async (args) => { ... });

// Eliminar
server.tool("delete_task", "Elimina una tarea por ID", { ... }, async (args) => { ... });

Patrón 2: Tool con confirmación

Para operaciones destructivas, retorna un preview antes de ejecutar:

server.tool(
  "delete_files",
  "Elimina archivos que coincidan con un patrón",
  {
    pattern: z.string().describe("Glob pattern de archivos a eliminar"),
    dryRun: z.boolean().default(true).describe("Si true, solo muestra qué se eliminaría"),
  },
  async ({ pattern, dryRun }) => {
    const files = await glob(pattern);

    if (dryRun) {
      return {
        content: [{
          type: "text",
          text: `Se eliminarían ${files.length} archivos:\n${files.join("\n")}\n\nUsa dryRun: false para ejecutar.`,
        }],
      };
    }

    for (const file of files) {
      await fs.unlink(file);
    }

    return {
      content: [{ type: "text", text: `${files.length} archivos eliminados.` }],
    };
  }
);

Error Handling en Tools

El manejo de errores no es un afterthought — es parte del diseño. Un tool que falla sin explicación es inútil.

Errores esperados vs inesperados

server.tool(
  "get_user_data",
  "Obtiene datos completos de un usuario",
  { userId: z.string().describe("ID del usuario") },
  async ({ userId }) => {
    try {
      const user = await db.findUser(userId);

      if (!user) {
        // Error esperado — el usuario no existe
        return {
          content: [{ type: "text", text: `Usuario '${userId}' no encontrado.` }],
          isError: true,
        };
      }

      return {
        content: [{ type: "text", text: JSON.stringify(user, null, 2) }],
      };
    } catch (error) {
      // Error inesperado — fallo de DB, red, etc.
      return {
        content: [{
          type: "text",
          text: `Error interno al buscar usuario: ${error instanceof Error ? error.message : "Error desconocido"}`,
        }],
        isError: true,
      };
    }
  }
);

El campo isError

Cuando un tool retorna isError: true, el modelo sabe que algo falló y puede:

  • Informar al usuario del error
  • Intentar con parámetros diferentes
  • Sugerir acciones alternativas

Siempre usa isError: true cuando la operación no fue exitosa.


Troubleshooting

"El modelo no invoca el tool"

Causa: La descripción del tool no es clara o no coincide con lo que el usuario está pidiendo.

Solución: Mejora la descripción del tool:

// ❌ Descripción vaga
"Procesa datos"

// ✅ Descripción específica
"Inserta un nuevo registro de venta en la base de datos con cliente, producto y monto"

"Los parámetros llegan incorrectos"

Causa: El schema no tiene descripciones claras o le faltan constraints.

Solución:

// ❌ Sin descripción — el modelo adivina
{ path: z.string() }

// ✅ Con descripción y ejemplo
{ path: z.string().describe("Ruta absoluta del archivo (e.g., '/home/user/data.json')") }

"El tool falla con 'invalid arguments'"

Causa: Los argumentos no pasan la validación de Zod.

Solución:

# Usa MCP Inspector para ver exactamente qué envía el modelo
npx @modelcontextprotocol/inspector

# Verifica que el schema coincide con lo que esperas
# Campos required vs optional, tipos, defaults

"Timeout al ejecutar un tool"

Causa: La operación dentro del tool tarda demasiado.

Solución:

// Agrega timeout a operaciones externas
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);

try {
  const response = await fetch(url, { signal: controller.signal });
  // ...
} finally {
  clearTimeout(timeoutId);
}

"El tool se ejecuta pero el resultado no se muestra"

Causa: El formato del resultado no es correcto.

Solución:

// Asegúrate de retornar la estructura correcta
return {
  content: [
    {
      type: "text",     // ← type es requerido
      text: "resultado" // ← text es requerido para type: "text"
    }
  ],
};

Ejercicios

Ejercicio 1: Diseñar schemas de tools (Fácil)

Diseña el input schema (nombre, descripción, parámetros) para estos 3 tools:

  1. Un tool que convierte temperaturas entre Celsius y Fahrenheit
  2. Un tool que cuenta palabras en un texto
  3. Un tool que genera un password seguro
Ver solución
// 1. Convertir temperatura
server.tool(
  "convert_temperature",
  "Convierte temperatura entre Celsius y Fahrenheit",
  {
    value: z.number().describe("Valor de temperatura a convertir"),
    from: z.enum(["celsius", "fahrenheit"]).describe("Unidad de origen"),
  },
  async ({ value, from }) => {
    const result = from === "celsius"
      ? { fahrenheit: (value * 9/5) + 32 }
      : { celsius: (value - 32) * 5/9 };
    return { content: [{ type: "text", text: JSON.stringify(result) }] };
  }
);

// 2. Contar palabras
server.tool(
  "count_words",
  "Cuenta el número de palabras, caracteres y líneas en un texto",
  {
    text: z.string().describe("Texto a analizar"),
  },
  async ({ text }) => {
    const result = {
      words: text.split(/\s+/).filter(Boolean).length,
      characters: text.length,
      lines: text.split("\n").length,
    };
    return { content: [{ type: "text", text: JSON.stringify(result) }] };
  }
);

// 3. Generar password
server.tool(
  "generate_password",
  "Genera un password seguro con los criterios especificados",
  {
    length: z.number().min(8).max(128).default(16).describe("Longitud del password"),
    includeSymbols: z.boolean().default(true).describe("Incluir símbolos (!@#$...)"),
    includeNumbers: z.boolean().default(true).describe("Incluir números"),
  },
  async ({ length, includeSymbols, includeNumbers }) => {
    let chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
    if (includeNumbers) chars += "0123456789";
    if (includeSymbols) chars += "!@#$%^&*()_+-=[]{}";
    const password = Array.from({ length }, () => chars[Math.floor(Math.random() * chars.length)]).join("");
    return { content: [{ type: "text", text: `Password generado: ${password}` }] };
  }
);

Ejercicio 2: Implementar un Tool con validación (Medio)

Implementa un tool create_directory en TypeScript que:

  • Reciba la ruta del directorio a crear
  • Valide que la ruta no contenga ".." (prevenir path traversal)
  • Cree la estructura de directorios recursivamente
  • Retorne confirmación con la ruta creada
Ver solución
import * as fs from "fs/promises";
import * as path from "path";

server.tool(
  "create_directory",
  "Crea un directorio y sus padres si no existen",
  {
    dirPath: z.string().describe("Ruta del directorio a crear (e.g., 'src/components/ui')"),
  },
  async ({ dirPath }) => {
    if (dirPath.includes("..")) {
      return {
        content: [{ type: "text", text: "Error: la ruta no puede contener '..' por seguridad" }],
        isError: true,
      };
    }

    const absolutePath = path.resolve(dirPath);

    try {
      await fs.mkdir(absolutePath, { recursive: true });
      const stats = await fs.stat(absolutePath);

      return {
        content: [{
          type: "text",
          text: JSON.stringify({
            success: true,
            path: absolutePath,
            created: stats.birthtime.toISOString(),
          }, null, 2),
        }],
      };
    } catch (error) {
      return {
        content: [{
          type: "text",
          text: `Error al crear directorio: ${error instanceof Error ? error.message : "desconocido"}`,
        }],
        isError: true,
      };
    }
  }
);

Ejercicio 3: Tool CRUD en Python (Medio)

Implementa un tool manage_todo en Python que soporte crear y completar tareas en una lista in-memory:

Ver solución
import json
from datetime import datetime

todos: list[dict] = []

@server.tool()
async def manage_todo(action: str, title: str = "", todo_id: int = -1) -> str:
    """Gestiona una lista de tareas (crear, completar, listar).

    Args:
        action: Acción a realizar (create, complete, list)
        title: Título de la tarea (requerido para create)
        todo_id: ID de la tarea (requerido para complete)
    """
    if action == "create":
        if not title:
            raise ValueError("El título es requerido para crear una tarea")
        todo = {
            "id": len(todos) + 1,
            "title": title,
            "completed": False,
            "created_at": datetime.now().isoformat(),
        }
        todos.append(todo)
        return json.dumps({"message": f"Tarea creada: {title}", "todo": todo}, indent=2)

    elif action == "complete":
        if todo_id < 0:
            raise ValueError("El todo_id es requerido para completar una tarea")
        for todo in todos:
            if todo["id"] == todo_id:
                todo["completed"] = True
                return json.dumps({"message": f"Tarea completada: {todo['title']}", "todo": todo}, indent=2)
        raise ValueError(f"Tarea con ID {todo_id} no encontrada")

    elif action == "list":
        return json.dumps({"total": len(todos), "todos": todos}, indent=2)

    else:
        raise ValueError(f"Acción inválida: {action}. Opciones: create, complete, list")

Ejercicio 4: Tool con dryRun (Difícil)

Implementa un tool rename_files en TypeScript que:

  • Reciba un directorio, un patrón de búsqueda y un patrón de reemplazo
  • En modo dryRun: true, muestre qué archivos cambiarían de nombre
  • En modo dryRun: false, ejecute el renombrado
Ver solución
import * as fs from "fs/promises";
import * as path from "path";

server.tool(
  "rename_files",
  "Renombra archivos en un directorio reemplazando un patrón en el nombre",
  {
    directory: z.string().describe("Ruta del directorio"),
    searchPattern: z.string().describe("Texto a buscar en los nombres de archivo"),
    replaceWith: z.string().describe("Texto de reemplazo"),
    dryRun: z.boolean().default(true).describe("Si true, solo muestra preview sin ejecutar"),
  },
  async ({ directory, searchPattern, replaceWith, dryRun }) => {
    const files = await fs.readdir(directory);
    const changes: Array<{ original: string; newName: string }> = [];

    for (const file of files) {
      if (file.includes(searchPattern)) {
        changes.push({
          original: file,
          newName: file.replace(searchPattern, replaceWith),
        });
      }
    }

    if (changes.length === 0) {
      return {
        content: [{
          type: "text",
          text: `No se encontraron archivos con '${searchPattern}' en ${directory}`,
        }],
      };
    }

    if (dryRun) {
      return {
        content: [{
          type: "text",
          text: `Preview de renombrado (dryRun):\n\n${changes.map(c => `  ${c.original} → ${c.newName}`).join("\n")}\n\nTotal: ${changes.length} archivos. Usa dryRun: false para ejecutar.`,
        }],
      };
    }

    for (const change of changes) {
      await fs.rename(
        path.join(directory, change.original),
        path.join(directory, change.newName)
      );
    }

    return {
      content: [{
        type: "text",
        text: `${changes.length} archivos renombrados:\n${changes.map(c => `  ${c.original} → ${c.newName}`).join("\n")}`,
      }],
    };
  }
);

Resumen

En esta cápsula aprendiste:

  • Tools son funciones ejecutables que el modelo puede invocar a través del MCP server
  • Tienen side effects — pueden crear archivos, insertar datos, enviar mensajes
  • Requieren un input schema que define parámetros con tipos, descripciones y validación
  • El flujo incluye aprobación del usuario antes de ejecutar
  • Zod (TypeScript) y type hints + docstrings (Python) definen los schemas
  • El error handling es parte del diseño, no un afterthought
  • La descripción del tool es crucial — es lo que le dice al modelo cuándo usarlo
  • Patrones como dryRun, CRUD completo y progress hacen los tools más robustos

Próxima cápsula: Prompts — templates reutilizables con parámetros. La primitiva menos intuitiva para developers pero fundamental para estandarizar interacciones.


Recursos adicionales

  1. MCP Specification — Tools - Especificación oficial de Tools
  2. Zod Documentation - Librería de validación usada en el SDK de TypeScript
  3. JSON Schema - Formato subyacente de los input schemas
  4. MCP TypeScript SDK — Tools - Implementación de Tools en TS
  5. MCP Python SDK — Tools - Implementación de Tools en Python
  6. Building Effective Tools (Anthropic) - Best practices para tool design