Módulo 4: MCP Server en TypeScript

Implementar Tools con Zod Schemas

Implementar Tools con Zod Schemas

Descripción de la cápsula

Tools es la primitiva que más vas a implementar. Si miras cualquier MCP server popular — Filesystem, GitHub, Slack, Sentry — la mayoría de sus capabilities son tools. Un resource expone datos, un prompt estandariza interacciones, pero un tool ejecuta acciones. Crear archivos, consultar bases de datos, enviar notificaciones, ejecutar builds — todo esto son tools.

En el módulo 3 implementaste un tool básico con un schema Zod simple. Ahora vas a escalar: schemas con validaciones complejas, enums, arrays, objetos anidados, valores por defecto. Vas a implementar error handling robusto. Vas a ver patrones de diseño que hacen la diferencia entre un tool que "funciona" y un tool que es confiable.

Esta cápsula es ~60% código. Cada concepto se demuestra con implementaciones completas que puedes copiar, compilar, y ejecutar.


Anatomía completa de server.tool()

El método server.tool() recibe 4 argumentos:

server.tool(
  name,          // string — identificador único del tool
  description,   // string — lo que el modelo lee para decidir cuándo usarlo
  schema,        // Record<string, ZodType> — parámetros con validación
  handler        // async (args) => ToolResult — la lógica del tool
);

El nombre

// Convención: snake_case, verbo + sustantivo
"create_file"       // ✅
"search_users"      // ✅
"run_migration"     // ✅

// Evitar:
"file"              // ❌ — ¿leer? ¿crear? ¿borrar?
"doStuff"           // ❌ — camelCase no es convención MCP
"create-file"       // ❌ — kebab-case no es convención MCP

La descripción

La descripción es instrucciones para el modelo. Es lo que Claude lee para decidir si debe invocar tu tool:

// ❌ Descripción inútil — el modelo no sabe cuándo usarla
"Procesa datos"

// ✅ Descripción útil — el modelo sabe exactamente cuándo usarla
"Crea un nuevo archivo en el proyecto con el contenido especificado. Crea directorios intermedios si no existen. Retorna error si el archivo ya existe y overwrite es false."

Regla: Una buena descripción responde tres preguntas:

  1. ¿Qué hace? — "Crea un nuevo archivo"
  2. ¿Qué recibe? — "con el contenido especificado"
  3. ¿Qué comportamiento especial tiene? — "Retorna error si ya existe"

El schema (Zod)

El schema define los parámetros del tool. Cada propiedad es un z.something():

{
  // String básico
  name: z.string().describe("Nombre del usuario"),

  // String con validación
  email: z.string().email().describe("Email válido del usuario"),

  // Número con rango
  age: z.number().int().min(0).max(150).describe("Edad del usuario"),

  // Enum — opciones limitadas
  role: z.enum(["admin", "user", "viewer"]).describe("Rol del usuario"),

  // Boolean con default
  active: z.boolean().default(true).describe("Si el usuario está activo"),

  // Opcional
  nickname: z.string().optional().describe("Apodo del usuario"),
}

El handler

El handler recibe los argumentos validados por Zod y retorna un ToolResult:

async ({ name, email, role }) => {
  // Tu lógica aquí...

  // Retorno exitoso
  return {
    content: [{
      type: "text" as const,
      text: "Resultado del tool",
    }],
  };

  // Retorno con error
  return {
    content: [{
      type: "text" as const,
      text: "Descripción del error",
    }],
    isError: true,
  };
}

Zod schemas: de básico a avanzado

Tipos primitivos

z.string()                       // cualquier string
z.number()                       // cualquier número
z.boolean()                      // true o false
z.null()                         // null
z.undefined()                    // undefined

Validaciones de string

z.string().min(1)                // no vacío
z.string().max(100)              // máximo 100 caracteres
z.string().email()               // formato email
z.string().url()                 // formato URL
z.string().uuid()                // formato UUID
z.string().regex(/^[a-z]+$/)     // match regex
z.string().startsWith("prefix")  // empieza con
z.string().endsWith(".ts")       // termina con

Validaciones de número

z.number().int()                 // entero
z.number().positive()            // positivo
z.number().nonnegative()         // >= 0
z.number().min(1).max(100)       // rango
z.number().multipleOf(5)         // múltiplo de

Enums

z.enum(["small", "medium", "large"])          // opciones fijas
z.enum(["read", "write", "admin"])            // permisos
z.enum(["asc", "desc"]).default("asc")        // con default

Arrays

z.array(z.string())              // array de strings
z.array(z.number()).min(1)       // al menos 1 elemento
z.array(z.string()).max(10)      // máximo 10 elementos

Objetos anidados

z.object({
  name: z.string(),
  address: z.object({
    street: z.string(),
    city: z.string(),
    zipCode: z.string(),
  }),
})

Unions y opcionales

z.string().optional()                          // string | undefined
z.string().nullable()                          // string | null
z.union([z.string(), z.number()])              // string | number
z.string().default("hello")                    // default value

La importancia de .describe()

Cada campo debe tener .describe(). Esta descripción se convierte en la description del JSON Schema que el modelo lee:

// ❌ Sin describe — el modelo tiene que adivinar
{
  q: z.string(),
  n: z.number(),
}

// ✅ Con describe — el modelo sabe exactamente qué enviar
{
  query: z.string().describe("Texto a buscar en los archivos del proyecto"),
  maxResults: z.number().int().positive().default(10)
    .describe("Número máximo de resultados a retornar (default: 10)"),
}

Ejemplo 1: Tool básico — Calculadora

Un tool simple para entender la estructura:

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

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

server.tool(
  "calculate",
  "Ejecuta una operación matemática entre dos números. Soporta suma, resta, multiplicación y división.",
  {
    a: z.number().describe("Primer operando"),
    b: z.number().describe("Segundo operando"),
    operation: z.enum(["add", "subtract", "multiply", "divide"])
      .describe("Operación a realizar"),
  },
  async ({ a, b, operation }) => {
    let result: number;

    switch (operation) {
      case "add":
        result = a + b;
        break;
      case "subtract":
        result = a - b;
        break;
      case "multiply":
        result = a * b;
        break;
      case "divide":
        if (b === 0) {
          return {
            content: [{ type: "text" as const, text: "Error: división por cero" }],
            isError: true,
          };
        }
        result = a / b;
        break;
    }

    return {
      content: [{
        type: "text" as const,
        text: JSON.stringify({
          operation: `${a} ${operation} ${b}`,
          result,
        }, null, 2),
      }],
    };
  }
);

Output esperado con inputs { a: 10, b: 3, operation: "multiply" }:

{
  "operation": "10 multiply 3",
  "result": 30
}

Ejemplo 2: Tool con parámetros complejos — Buscar archivos

import * as fs from "fs/promises";
import * as path from "path";

server.tool(
  "search_files",
  "Busca archivos en un directorio por nombre, extensión o contenido. Retorna las coincidencias con ruta y tamaño.",
  {
    directory: z.string().describe("Directorio donde buscar (ruta absoluta)"),
    pattern: z.string().optional()
      .describe("Patrón de nombre de archivo a buscar (e.g., 'utils', 'test')"),
    extensions: z.array(z.string()).optional()
      .describe("Extensiones de archivo a incluir (e.g., ['.ts', '.js'])"),
    contentSearch: z.string().optional()
      .describe("Texto a buscar dentro de los archivos"),
    maxResults: z.number().int().positive().default(20)
      .describe("Número máximo de resultados (default: 20)"),
    includeHidden: z.boolean().default(false)
      .describe("Incluir archivos ocultos (que empiezan con '.')"),
  },
  async ({ directory, pattern, extensions, contentSearch, maxResults, includeHidden }) => {
    const results: Array<{
      path: string;
      size: string;
      matchType: string;
      lineMatch?: string;
    }> = [];

    async function searchDir(dir: string): Promise<void> {
      if (results.length >= maxResults) return;

      let entries;
      try {
        entries = await fs.readdir(dir, { withFileTypes: true });
      } catch {
        return;
      }

      for (const entry of entries) {
        if (results.length >= maxResults) break;
        if (!includeHidden && entry.name.startsWith(".")) continue;
        if (entry.name === "node_modules") continue;

        const fullPath = path.join(dir, entry.name);

        if (entry.isDirectory()) {
          await searchDir(fullPath);
          continue;
        }

        let matches = false;
        let matchType = "";

        if (pattern && entry.name.toLowerCase().includes(pattern.toLowerCase())) {
          matches = true;
          matchType = "name";
        }

        if (extensions && extensions.some(ext => entry.name.endsWith(ext))) {
          matches = true;
          matchType = matchType ? `${matchType}+extension` : "extension";
        }

        if (!pattern && !extensions && !contentSearch) {
          matches = true;
          matchType = "all";
        }

        let lineMatch: string | undefined;
        if (contentSearch && !entry.name.endsWith(".lock")) {
          try {
            const content = await fs.readFile(fullPath, "utf-8");
            const lines = content.split("\n");
            const matchingLine = lines.findIndex(line =>
              line.toLowerCase().includes(contentSearch.toLowerCase())
            );
            if (matchingLine >= 0) {
              matches = true;
              matchType = matchType ? `${matchType}+content` : "content";
              lineMatch = `L${matchingLine + 1}: ${lines[matchingLine].trim()}`;
            }
          } catch {
            // skip binary files
          }
        }

        if (matches) {
          const stats = await fs.stat(fullPath);
          results.push({
            path: fullPath,
            size: `${(stats.size / 1024).toFixed(1)} KB`,
            matchType,
            ...(lineMatch && { lineMatch }),
          });
        }
      }
    }

    try {
      await fs.access(directory);
    } catch {
      return {
        content: [{ type: "text" as const, text: `Error: directorio '${directory}' no existe o no es accesible` }],
        isError: true,
      };
    }

    await searchDir(directory);

    return {
      content: [{
        type: "text" as const,
        text: JSON.stringify({
          directory,
          totalResults: results.length,
          truncated: results.length >= maxResults,
          results,
        }, null, 2),
      }],
    };
  }
);

Lo que este ejemplo demuestra:

  • Schema con parámetros opcionales (pattern, extensions, contentSearch)
  • Arrays en el schema (extensions: z.array(z.string()))
  • Defaults (maxResults: 20, includeHidden: false)
  • Validación del directorio antes de la búsqueda
  • Límite de resultados para evitar respuestas enormes
  • Manejo de errores (archivos binarios, directorios inaccesibles)

Ejemplo 3: Tool con error handling robusto — Ejecutar comando

import { exec } from "child_process";
import { promisify } from "util";

const execAsync = promisify(exec);

const ALLOWED_COMMANDS = ["ls", "cat", "wc", "head", "tail", "grep", "find", "echo", "date"];

server.tool(
  "run_command",
  "Ejecuta un comando de shell seguro y retorna su output. Solo permite comandos de lectura seguros.",
  {
    command: z.string().describe("Comando a ejecutar (solo comandos de lectura permitidos)"),
    workingDirectory: z.string().optional()
      .describe("Directorio de trabajo para la ejecución"),
    timeoutMs: z.number().int().positive().default(10000)
      .describe("Timeout en milisegundos (default: 10000)"),
  },
  async ({ command, workingDirectory, timeoutMs }) => {
    const baseCommand = command.split(/\s+/)[0];

    if (!ALLOWED_COMMANDS.includes(baseCommand)) {
      return {
        content: [{
          type: "text" as const,
          text: JSON.stringify({
            error: `Comando '${baseCommand}' no permitido`,
            allowedCommands: ALLOWED_COMMANDS,
          }, null, 2),
        }],
        isError: true,
      };
    }

    if (command.includes("&&") || command.includes("||") || command.includes(";") || command.includes("|")) {
      return {
        content: [{
          type: "text" as const,
          text: "Error: no se permiten operadores de encadenamiento (&&, ||, ;, |) por seguridad",
        }],
        isError: true,
      };
    }

    try {
      const { stdout, stderr } = await execAsync(command, {
        cwd: workingDirectory,
        timeout: timeoutMs,
        maxBuffer: 1024 * 1024,
      });

      return {
        content: [{
          type: "text" as const,
          text: JSON.stringify({
            command,
            exitCode: 0,
            stdout: stdout.trim(),
            stderr: stderr.trim() || undefined,
          }, null, 2),
        }],
      };
    } catch (error: unknown) {
      const execError = error as { code?: number; killed?: boolean; stdout?: string; stderr?: string; message?: string };

      if (execError.killed) {
        return {
          content: [{
            type: "text" as const,
            text: `Error: comando excedió el timeout de ${timeoutMs}ms`,
          }],
          isError: true,
        };
      }

      return {
        content: [{
          type: "text" as const,
          text: JSON.stringify({
            error: "Comando falló",
            exitCode: execError.code,
            stderr: execError.stderr?.trim(),
            message: execError.message,
          }, null, 2),
        }],
        isError: true,
      };
    }
  }
);

Lo que este ejemplo demuestra:

  • Whitelist de comandos — solo permite comandos seguros
  • Prevención de inyección — no permite encadenamiento
  • Timeout — mata el proceso si tarda demasiado
  • Manejo de errores tipados — distingue timeout de errores de ejecución
  • Información de diagnóstico — retorna stdout, stderr, exit code

Ejemplo 4: Tool con side effects reales — Gestión de notas

import * as fs from "fs/promises";
import * as path from "path";

const NOTES_DIR = process.env.NOTES_DIR || path.join(process.cwd(), "notes");

server.tool(
  "manage_notes",
  "Gestiona notas de texto en un directorio. Permite crear, leer, listar y eliminar notas.",
  {
    action: z.enum(["create", "read", "list", "delete"])
      .describe("Acción a realizar"),
    title: z.string().optional()
      .describe("Título de la nota (requerido para create, read, delete)"),
    content: z.string().optional()
      .describe("Contenido de la nota (requerido para create)"),
    tags: z.array(z.string()).optional()
      .describe("Tags para categorizar la nota (solo para create)"),
  },
  async ({ action, title, content, tags }) => {
    await fs.mkdir(NOTES_DIR, { recursive: true });

    switch (action) {
      case "create": {
        if (!title || !content) {
          return {
            content: [{ type: "text" as const, text: "Error: 'title' y 'content' son requeridos para crear una nota" }],
            isError: true,
          };
        }

        const filename = `${title.toLowerCase().replace(/\s+/g, "-")}.md`;
        const filePath = path.join(NOTES_DIR, filename);
        const header = `# ${title}\n\n`;
        const tagLine = tags?.length ? `**Tags:** ${tags.join(", ")}\n\n` : "";
        const dateLine = `**Creada:** ${new Date().toISOString()}\n\n---\n\n`;
        const fullContent = header + tagLine + dateLine + content;

        await fs.writeFile(filePath, fullContent, "utf-8");

        return {
          content: [{
            type: "text" as const,
            text: JSON.stringify({
              action: "created",
              title,
              filename,
              path: filePath,
              tags: tags || [],
              size: `${(fullContent.length / 1024).toFixed(1)} KB`,
            }, null, 2),
          }],
        };
      }

      case "read": {
        if (!title) {
          return {
            content: [{ type: "text" as const, text: "Error: 'title' es requerido para leer una nota" }],
            isError: true,
          };
        }

        const filename = `${title.toLowerCase().replace(/\s+/g, "-")}.md`;
        const filePath = path.join(NOTES_DIR, filename);

        try {
          const noteContent = await fs.readFile(filePath, "utf-8");
          return {
            content: [{ type: "text" as const, text: noteContent }],
          };
        } catch {
          return {
            content: [{ type: "text" as const, text: `Error: nota '${title}' no encontrada` }],
            isError: true,
          };
        }
      }

      case "list": {
        const files = await fs.readdir(NOTES_DIR);
        const notes = files.filter(f => f.endsWith(".md"));

        const noteDetails = await Promise.all(
          notes.map(async (file) => {
            const filePath = path.join(NOTES_DIR, file);
            const stats = await fs.stat(filePath);
            return {
              title: file.replace(".md", "").replace(/-/g, " "),
              filename: file,
              size: `${(stats.size / 1024).toFixed(1)} KB`,
              modified: stats.mtime.toISOString(),
            };
          })
        );

        return {
          content: [{
            type: "text" as const,
            text: JSON.stringify({
              directory: NOTES_DIR,
              totalNotes: noteDetails.length,
              notes: noteDetails,
            }, null, 2),
          }],
        };
      }

      case "delete": {
        if (!title) {
          return {
            content: [{ type: "text" as const, text: "Error: 'title' es requerido para eliminar una nota" }],
            isError: true,
          };
        }

        const filename = `${title.toLowerCase().replace(/\s+/g, "-")}.md`;
        const filePath = path.join(NOTES_DIR, filename);

        try {
          await fs.unlink(filePath);
          return {
            content: [{ type: "text" as const, text: `Nota '${title}' eliminada exitosamente` }],
          };
        } catch {
          return {
            content: [{ type: "text" as const, text: `Error: nota '${title}' no encontrada` }],
            isError: true,
          };
        }
      }
    }
  }
);

Lo que este ejemplo demuestra:

  • Tool con múltiples acciones via enum
  • Validación condicional (title requerido para read/delete, content requerido para create)
  • Arrays en inputs (tags)
  • Side effects reales (crea y borra archivos)
  • Formateo de output consistente

Comparación: Zod vs validación manual

Para que entiendas el valor real de Zod, compara ambos enfoques:

// ❌ Validación manual — tedioso, propenso a errores
server.tool("create_user", "Crea un usuario", {}, async (args: any) => {
  if (typeof args.name !== "string" || args.name.length === 0) {
    return { content: [{ type: "text", text: "name es requerido" }], isError: true };
  }
  if (typeof args.email !== "string" || !args.email.includes("@")) {
    return { content: [{ type: "text", text: "email inválido" }], isError: true };
  }
  if (args.role && !["admin", "user"].includes(args.role)) {
    return { content: [{ type: "text", text: "role inválido" }], isError: true };
  }
  const role = args.role || "user";
  // ... lógica del tool
});

// ✅ Validación con Zod — conciso, tipado, automático
server.tool(
  "create_user",
  "Crea un usuario",
  {
    name: z.string().min(1).describe("Nombre del usuario"),
    email: z.string().email().describe("Email válido"),
    role: z.enum(["admin", "user"]).default("user").describe("Rol"),
  },
  async ({ name, email, role }) => {
    // name, email, role ya están validados y tipados
    // Si los inputs no pasan validación, el SDK retorna un error automáticamente
    // ... lógica del tool
  }
);
AspectoManualZod
Líneas de código~15+ para 3 params3 líneas
Tipadoany — sin typesInferido automáticamente
JSON SchemaLo escribes túGenerado automáticamente
Errores de validaciónMensajes manualesMensajes generados por Zod
MantenibilidadFrágilDeclarativo

Patrones de diseño para tools

Patrón 1: dryRun para operaciones destructivas

server.tool(
  "cleanup_temp_files",
  "Elimina archivos temporales de un directorio. Usa dryRun para ver qué se eliminaría sin ejecutar.",
  {
    directory: z.string().describe("Directorio a limpiar"),
    olderThanDays: z.number().positive().default(7)
      .describe("Eliminar archivos más viejos que N días"),
    dryRun: z.boolean().default(true)
      .describe("Si true, solo muestra qué se eliminaría sin ejecutar"),
  },
  async ({ directory, olderThanDays, dryRun }) => {
    const cutoffDate = new Date();
    cutoffDate.setDate(cutoffDate.getDate() - olderThanDays);

    const entries = await fs.readdir(directory, { withFileTypes: true });
    const toDelete: string[] = [];

    for (const entry of entries) {
      if (!entry.isFile()) continue;
      const filePath = path.join(directory, entry.name);
      const stats = await fs.stat(filePath);

      if (stats.mtime < cutoffDate) {
        toDelete.push(entry.name);
      }
    }

    if (dryRun) {
      return {
        content: [{
          type: "text" as const,
          text: JSON.stringify({
            mode: "dryRun",
            wouldDelete: toDelete.length,
            files: toDelete,
            message: "Usa dryRun: false para ejecutar la limpieza",
          }, null, 2),
        }],
      };
    }

    for (const file of toDelete) {
      await fs.unlink(path.join(directory, file));
    }

    return {
      content: [{
        type: "text" as const,
        text: JSON.stringify({
          mode: "executed",
          deleted: toDelete.length,
          files: toDelete,
        }, null, 2),
      }],
    };
  }
);

Regla: Todo tool que elimina o modifica datos de forma irreversible debe tener un modo dryRun con default true.

Patrón 2: Retornar JSON estructurado

// ❌ String plano — difícil de parsear para el modelo
return {
  content: [{ type: "text", text: "3 archivos encontrados en /src" }],
};

// ✅ JSON estructurado — el modelo puede razonar sobre los datos
return {
  content: [{
    type: "text" as const,
    text: JSON.stringify({
      totalFiles: 3,
      directory: "/src",
      files: [
        { name: "index.ts", size: "2.4 KB" },
        { name: "utils.ts", size: "1.1 KB" },
        { name: "types.ts", size: "0.8 KB" },
      ],
    }, null, 2),
  }],
};

Patrón 3: Tool idempotente cuando es posible

server.tool(
  "ensure_directory",
  "Asegura que un directorio existe. Si ya existe, no hace nada. Si no existe, lo crea.",
  {
    dirPath: z.string().describe("Ruta del directorio"),
  },
  async ({ dirPath }) => {
    await fs.mkdir(dirPath, { recursive: true });
    const stats = await fs.stat(dirPath);

    return {
      content: [{
        type: "text" as const,
        text: JSON.stringify({
          path: dirPath,
          exists: true,
          created: stats.birthtimeMs > Date.now() - 1000 ? "just now" : "already existed",
        }, null, 2),
      }],
    };
  }
);

Un tool idempotente produce el mismo resultado sin importar cuántas veces se ejecute.


Error handling: la parte que no puedes ignorar

Estructura de errores

Hay dos tipos de errores en un tool:

// 1. Error esperado — el tool se ejecutó pero el resultado no es exitoso
return {
  content: [{ type: "text" as const, text: "Usuario no encontrado" }],
  isError: true,  // ← señala al modelo que algo no salió bien
};

// 2. Error inesperado — algo se rompió
try {
  await riskyOperation();
} catch (error) {
  return {
    content: [{
      type: "text" as const,
      text: `Error interno: ${error instanceof Error ? error.message : "desconocido"}`,
    }],
    isError: true,
  };
}

Patrón de error handling completo

server.tool(
  "read_json_file",
  "Lee y parsea un archivo JSON. Retorna el contenido parseado o un error descriptivo.",
  {
    filePath: z.string().describe("Ruta del archivo JSON a leer"),
  },
  async ({ filePath }) => {
    try {
      const raw = await fs.readFile(filePath, "utf-8");

      try {
        const parsed = JSON.parse(raw);
        return {
          content: [{
            type: "text" as const,
            text: JSON.stringify(parsed, null, 2),
          }],
        };
      } catch {
        return {
          content: [{
            type: "text" as const,
            text: `Error: el archivo '${filePath}' no contiene JSON válido`,
          }],
          isError: true,
        };
      }
    } catch (error) {
      const nodeError = error as NodeJS.ErrnoException;
      if (nodeError.code === "ENOENT") {
        return {
          content: [{ type: "text" as const, text: `Error: archivo '${filePath}' no encontrado` }],
          isError: true,
        };
      }
      if (nodeError.code === "EACCES") {
        return {
          content: [{ type: "text" as const, text: `Error: sin permisos para leer '${filePath}'` }],
          isError: true,
        };
      }
      return {
        content: [{
          type: "text" as const,
          text: `Error inesperado al leer '${filePath}': ${nodeError.message}`,
        }],
        isError: true,
      };
    }
  }
);

Distinguir ENOENT (no existe) de EACCES (sin permisos) le da al modelo información actionable para sugerir una solución al usuario.


Troubleshooting

"El modelo no invoca el tool"

Causa: La descripción del tool no es suficientemente específica o no conecta con lo que el usuario pide.

Solución:

// ❌ Vaga — el modelo no sabe cuándo usarla
"Procesa archivos"

// ✅ Específica — el modelo sabe exactamente cuándo usarla
"Busca archivos en un directorio por nombre o extensión. Soporta búsqueda recursiva y filtro por contenido."

"Los parámetros llegan como undefined"

Causa: El campo es optional() pero tu handler no verifica si existe.

Solución:

// ❌ Crash si title es undefined
async ({ title }) => {
  const upper = title.toUpperCase(); // TypeError si title es undefined
}

// ✅ Verificación explícita
async ({ title }) => {
  if (!title) {
    return { content: [{ type: "text" as const, text: "title es requerido" }], isError: true };
  }
  const upper = title.toUpperCase();
}

"Zod validation error en runtime"

Causa: El modelo envió un valor que no pasa la validación de Zod. El SDK retorna esto automáticamente.

Solución: El SDK maneja esto automáticamente. Si ves estos errores, revisa que tu schema refleja lo que el modelo debería enviar. Agrega .describe() más claros para guiar al modelo.

"Tool se ejecuta pero retorna vacío"

Causa: Tu handler no retorna nada en algún code path.

Solución:

// ❌ Falta return en un branch
async ({ action }) => {
  if (action === "list") {
    return { content: [{ type: "text" as const, text: "lista" }] };
  }
  // ¿Qué pasa si action no es "list"? → undefined
}

// ✅ Todos los paths retornan
async ({ action }) => {
  if (action === "list") {
    return { content: [{ type: "text" as const, text: "lista" }] };
  }
  return {
    content: [{ type: "text" as const, text: `Acción '${action}' no soportada` }],
    isError: true,
  };
}

"Timeout al ejecutar tool con operación larga"

Causa: El tool ejecuta una operación que tarda más de lo esperado.

Solución:

const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000);

try {
  const response = await fetch(url, { signal: controller.signal });
  return { content: [{ type: "text" as const, text: await response.text() }] };
} catch (error) {
  if (error instanceof Error && error.name === "AbortError") {
    return { content: [{ type: "text" as const, text: "Timeout: la operación tardó más de 15 segundos" }], isError: true };
  }
  throw error;
} finally {
  clearTimeout(timeoutId);
}

Ejercicios

Ejercicio 1: Tool con enum y validación (Fácil)

Implementa un tool format_text que reciba un texto y un formato (uppercase, lowercase, title_case, reverse) y retorne el texto transformado.

Ver solución
server.tool(
  "format_text",
  "Transforma texto al formato especificado: uppercase, lowercase, title_case o reverse",
  {
    text: z.string().min(1).describe("Texto a transformar"),
    format: z.enum(["uppercase", "lowercase", "title_case", "reverse"])
      .describe("Formato de salida"),
  },
  async ({ text, format }) => {
    let result: string;

    switch (format) {
      case "uppercase":
        result = text.toUpperCase();
        break;
      case "lowercase":
        result = text.toLowerCase();
        break;
      case "title_case":
        result = text.replace(/\b\w/g, char => char.toUpperCase());
        break;
      case "reverse":
        result = text.split("").reverse().join("");
        break;
    }

    return {
      content: [{
        type: "text" as const,
        text: JSON.stringify({ original: text, format, result }, null, 2),
      }],
    };
  }
);

Ejercicio 2: Tool con objetos anidados (Medio)

Implementa un tool create_config que reciba un nombre de app, entorno (dev/staging/prod), y un objeto de configuración con port, debug, y database.host/database.name. Escribe la configuración como archivo JSON.

Ver solución
server.tool(
  "create_config",
  "Crea un archivo de configuración JSON para una aplicación con los parámetros especificados",
  {
    appName: z.string().min(1).describe("Nombre de la aplicación"),
    environment: z.enum(["development", "staging", "production"])
      .describe("Entorno de la aplicación"),
    port: z.number().int().min(1).max(65535).default(3000)
      .describe("Puerto del servidor"),
    debug: z.boolean().default(false)
      .describe("Habilitar modo debug"),
    database: z.object({
      host: z.string().describe("Host de la base de datos"),
      name: z.string().describe("Nombre de la base de datos"),
      port: z.number().int().default(5432).describe("Puerto de la base de datos"),
    }).describe("Configuración de la base de datos"),
  },
  async ({ appName, environment, port, debug, database }) => {
    const config = {
      app: {
        name: appName,
        environment,
        port,
        debug,
      },
      database,
      createdAt: new Date().toISOString(),
    };

    const filename = `config.${environment}.json`;
    await fs.writeFile(filename, JSON.stringify(config, null, 2), "utf-8");

    return {
      content: [{
        type: "text" as const,
        text: JSON.stringify({
          message: `Configuración creada: ${filename}`,
          config,
        }, null, 2),
      }],
    };
  }
);

Ejercicio 3: Tool con dryRun (Medio)

Implementa un tool bulk_rename que renombre archivos en un directorio, reemplazando un patrón en el nombre. Incluye modo dryRun (default: true).

Ver solución
server.tool(
  "bulk_rename",
  "Renombra archivos en un directorio reemplazando un patrón en el nombre. Usa dryRun para preview.",
  {
    directory: z.string().describe("Directorio con los archivos"),
    search: z.string().min(1).describe("Texto a buscar en los nombres"),
    replace: z.string().describe("Texto de reemplazo"),
    dryRun: z.boolean().default(true).describe("Si true, solo muestra preview"),
  },
  async ({ directory, search, replace, dryRun }) => {
    const entries = await fs.readdir(directory);
    const changes: Array<{ from: string; to: string }> = [];

    for (const entry of entries) {
      if (entry.includes(search)) {
        changes.push({ from: entry, to: entry.replace(search, replace) });
      }
    }

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

    if (dryRun) {
      return {
        content: [{
          type: "text" as const,
          text: JSON.stringify({
            mode: "dryRun",
            totalChanges: changes.length,
            changes,
            hint: "Usa dryRun: false para ejecutar",
          }, null, 2),
        }],
      };
    }

    for (const { from, to } of changes) {
      await fs.rename(path.join(directory, from), path.join(directory, to));
    }

    return {
      content: [{
        type: "text" as const,
        text: JSON.stringify({
          mode: "executed",
          renamed: changes.length,
          changes,
        }, null, 2),
      }],
    };
  }
);

Ejercicio 4: Tool que llama API externa (Difícil)

Implementa un tool check_url_status que reciba una URL (o array de URLs), haga un HEAD request a cada una, y retorne el status code, tiempo de respuesta, y headers relevantes.

Ver solución
server.tool(
  "check_url_status",
  "Verifica el estado de una o más URLs con HEAD requests. Retorna status code, tiempo de respuesta y headers.",
  {
    urls: z.array(z.string().url()).min(1).max(10)
      .describe("URLs a verificar (máximo 10)"),
    timeoutMs: z.number().int().positive().default(5000)
      .describe("Timeout por URL en milisegundos"),
  },
  async ({ urls, timeoutMs }) => {
    const results = await Promise.allSettled(
      urls.map(async (url) => {
        const start = Date.now();
        const controller = new AbortController();
        const timeout = setTimeout(() => controller.abort(), timeoutMs);

        try {
          const response = await fetch(url, {
            method: "HEAD",
            signal: controller.signal,
          });
          const elapsed = Date.now() - start;

          return {
            url,
            status: response.status,
            statusText: response.statusText,
            responseTimeMs: elapsed,
            contentType: response.headers.get("content-type"),
            server: response.headers.get("server"),
          };
        } catch (error) {
          const elapsed = Date.now() - start;
          return {
            url,
            error: error instanceof Error ? error.message : "Unknown error",
            responseTimeMs: elapsed,
          };
        } finally {
          clearTimeout(timeout);
        }
      })
    );

    const formattedResults = results.map((result) =>
      result.status === "fulfilled" ? result.value : { error: "Promise rejected" }
    );

    return {
      content: [{
        type: "text" as const,
        text: JSON.stringify({
          checked: urls.length,
          results: formattedResults,
        }, null, 2),
      }],
    };
  }
);

Ejercicio 5: Tool CRUD completo (Difícil)

Implementa un tool manage_contacts que permita crear, listar, buscar y eliminar contactos en un archivo JSON. Incluye validación de email y teléfono.

Ver solución
const CONTACTS_FILE = path.join(process.cwd(), "contacts.json");

interface Contact {
  id: string;
  name: string;
  email: string;
  phone?: string;
  createdAt: string;
}

async function loadContacts(): Promise<Contact[]> {
  try {
    const data = await fs.readFile(CONTACTS_FILE, "utf-8");
    return JSON.parse(data);
  } catch {
    return [];
  }
}

async function saveContacts(contacts: Contact[]): Promise<void> {
  await fs.writeFile(CONTACTS_FILE, JSON.stringify(contacts, null, 2), "utf-8");
}

server.tool(
  "manage_contacts",
  "Gestiona una lista de contactos: crear, listar, buscar por nombre/email, o eliminar por ID",
  {
    action: z.enum(["create", "list", "search", "delete"])
      .describe("Acción a realizar"),
    name: z.string().optional().describe("Nombre del contacto (para create/search)"),
    email: z.string().email().optional().describe("Email del contacto (para create/search)"),
    phone: z.string().optional().describe("Teléfono del contacto (para create)"),
    contactId: z.string().optional().describe("ID del contacto (para delete)"),
  },
  async ({ action, name, email, phone, contactId }) => {
    const contacts = await loadContacts();

    switch (action) {
      case "create": {
        if (!name || !email) {
          return { content: [{ type: "text" as const, text: "Error: name y email son requeridos" }], isError: true };
        }
        const newContact: Contact = {
          id: crypto.randomUUID(),
          name,
          email,
          phone,
          createdAt: new Date().toISOString(),
        };
        contacts.push(newContact);
        await saveContacts(contacts);
        return { content: [{ type: "text" as const, text: JSON.stringify({ created: newContact }, null, 2) }] };
      }

      case "list":
        return { content: [{ type: "text" as const, text: JSON.stringify({ total: contacts.length, contacts }, null, 2) }] };

      case "search": {
        const query = (name || email || "").toLowerCase();
        const found = contacts.filter(c =>
          c.name.toLowerCase().includes(query) || c.email.toLowerCase().includes(query)
        );
        return { content: [{ type: "text" as const, text: JSON.stringify({ query, found: found.length, results: found }, null, 2) }] };
      }

      case "delete": {
        if (!contactId) {
          return { content: [{ type: "text" as const, text: "Error: contactId es requerido" }], isError: true };
        }
        const idx = contacts.findIndex(c => c.id === contactId);
        if (idx === -1) {
          return { content: [{ type: "text" as const, text: `Contacto ${contactId} no encontrado` }], isError: true };
        }
        const removed = contacts.splice(idx, 1)[0];
        await saveContacts(contacts);
        return { content: [{ type: "text" as const, text: JSON.stringify({ deleted: removed }, null, 2) }] };
      }
    }
  }
);

Resumen

En esta cápsula aprendiste:

  • server.tool() recibe 4 argumentos: nombre, descripción, schema Zod, handler
  • Zod schemas van de simples (z.string()) a complejos (z.object(), z.array(), validaciones encadenadas)
  • .describe() es esencial — es lo que el modelo lee para saber qué enviar
  • Error handling tiene dos niveles: errores esperados (isError: true) y errores inesperados (try/catch)
  • Patrones de diseño: dryRun para operaciones destructivas, JSON estructurado en outputs, idempotencia cuando es posible
  • Zod vs manual: Zod es más conciso, tipado, y genera JSON Schema automáticamente

La mayoría del tiempo que pasas construyendo un MCP server lo pasas implementando tools. Los patrones de esta cápsula los usarás en cada server que crees.


Recursos adicionales

  1. Zod Documentation - Referencia completa de tipos y validaciones
  2. MCP Specification — Tools - Especificación oficial
  3. MCP TypeScript SDK — Examples - Ejemplos del SDK
  4. JSON Schema - Formato subyacente generado por Zod
  5. Node.js fs/promises API - API de filesystem usada en los ejemplos
  6. Building Effective Tools (Anthropic) - Best practices para diseño de tools

Siguiente cápsula: Implementar Resources — datos contextuales con URIs estáticos y templates dinámicos. Cómo hacer que tu server exponga datos que el modelo puede consultar.