Módulo 7: Testing, Debugging e Integración

Testing MCP Servers

Testing MCP Servers

Descripción de la cápsula

Testear un MCP server no es testear una app web ni una librería. Tu server habla un protocolo específico, recibe inputs de un modelo de lenguaje (no de un humano), y retorna datos estructurados que otro programa interpreta. Los tests deben verificar que el protocolo funciona, que los tools retornan resultados correctos, que los resources exponen datos válidos, y que los errores se manejan sin crashes.

En esta cápsula vas a escribir tests reales — no teoría de testing, sino archivos de test completos que puedes copiar, adaptar, y ejecutar contra tus propios MCP servers. Usarás Vitest para TypeScript y pytest para Python. Cada test está diseñado para detectar bugs reales que aparecen cuando Claude Code usa tu server.


Setup: Vitest para MCP servers en TypeScript

Instalar dependencias

Desde el directorio de tu proyecto MCP en TypeScript:

npm install -D vitest @types/node

Configurar Vitest

Crea vitest.config.ts en la raíz del proyecto:

import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    globals: true,
    testTimeout: 10000,
    hookTimeout: 10000,
  },
});

Agregar script en package.json

{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest"
  }
}

Estructura de archivos de test

my-mcp-server/
├── src/
│   └── index.ts          # Tu MCP server
├── tests/
│   ├── tools.test.ts     # Tests de tools
│   ├── resources.test.ts # Tests de resources
│   └── server.test.ts    # Tests de integración
├── vitest.config.ts
├── tsconfig.json
└── package.json

Patrón de testing: Client-Server en memoria

La clave para testear MCP servers es crear una conexión en memoria entre un client y tu server. No necesitas levantar un servidor HTTP ni usar stdio. El SDK de MCP proporciona un InMemoryTransport que conecta client y server directamente:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";

async function createTestClient(server: McpServer): Promise<Client> {
  const client = new Client({
    name: "test-client",
    version: "1.0.0",
  });

  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();

  await server.connect(serverTransport);
  await client.connect(clientTransport);

  return client;
}

Este patrón es el que usarás en todos tus tests. El InMemoryTransport.createLinkedPair() crea dos transports conectados: uno para el client y otro para el server. Los mensajes viajan directamente en memoria — rápido, confiable, sin configuración de red.


Unit testing de Tools (TypeScript)

El server de ejemplo

Para los tests, usaremos un server con dos tools (read_file, list_files) y un resource (status://server). Este es el server completo — exporta una función createServer() para que los tests puedan instanciarlo:

// src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import fs from "fs/promises";

export function createServer(): McpServer {
  const server = new McpServer({ name: "file-utils", version: "1.0.0" });

  server.tool(
    "read_file",
    "Lee el contenido de un archivo y retorna su texto",
    { filePath: z.string().describe("Ruta al archivo") },
    async ({ filePath }) => {
      try {
        const content = await fs.readFile(filePath, "utf-8");
        return { content: [{ type: "text" as const, text: content }] };
      } catch (error) {
        return {
          content: [{ type: "text" as const, text: `Error: ${(error as Error).message}` }],
          isError: true,
        };
      }
    }
  );

  server.tool(
    "list_files",
    "Lista archivos en un directorio con filtro opcional por extensión",
    {
      directory: z.string().describe("Ruta al directorio"),
      extension: z.string().optional().describe("Filtrar por extensión (e.g., '.ts')"),
    },
    async ({ directory, extension }) => {
      try {
        const entries = await fs.readdir(directory, { withFileTypes: true });
        let files = entries.filter((e) => e.isFile()).map((e) => e.name);
        if (extension) files = files.filter((f) => f.endsWith(extension));
        return {
          content: [{ type: "text" as const, text: JSON.stringify({ directory, files, count: files.length }, null, 2) }],
        };
      } catch (error) {
        return {
          content: [{ type: "text" as const, text: `Error: ${(error as Error).message}` }],
          isError: true,
        };
      }
    }
  );

  server.resource(
    "server-status",
    "status://server",
    { description: "Estado actual del server" },
    async () => ({
      contents: [{
        uri: "status://server",
        text: JSON.stringify({ status: "running", version: "1.0.0", uptime: process.uptime() }),
        mimeType: "application/json",
      }],
    })
  );

  return server;
}

Test file: tools.test.ts

// tests/tools.test.ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { createServer } from "../src/index.js";
import fs from "fs/promises";
import path from "path";
import os from "os";

let client: Client;
let tmpDir: string;

beforeAll(async () => {
  const server = createServer();
  client = new Client({ name: "test-client", version: "1.0.0" });
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
  await server.connect(serverTransport);
  await client.connect(clientTransport);

  tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "mcp-test-"));

  await fs.writeFile(path.join(tmpDir, "hello.txt"), "Hello, MCP!");
  await fs.writeFile(path.join(tmpDir, "data.json"), '{"key": "value"}');
  await fs.writeFile(path.join(tmpDir, "script.ts"), 'console.log("test");');
});

afterAll(async () => {
  await fs.rm(tmpDir, { recursive: true, force: true });
  await client.close();
});

describe("read_file tool", () => {
  it("lee un archivo existente y retorna su contenido", async () => {
    const result = await client.callTool({
      name: "read_file",
      arguments: { filePath: path.join(tmpDir, "hello.txt") },
    });

    expect(result.isError).toBeUndefined();
    expect(result.content).toHaveLength(1);
    expect(result.content[0]).toMatchObject({
      type: "text",
      text: "Hello, MCP!",
    });
  });

  it("retorna error para archivo inexistente", async () => {
    const result = await client.callTool({
      name: "read_file",
      arguments: { filePath: path.join(tmpDir, "no-existe.txt") },
    });

    expect(result.isError).toBe(true);
    expect(result.content[0]).toMatchObject({ type: "text" });
    expect((result.content[0] as { text: string }).text).toContain("Error");
  });

  it("lee un archivo JSON correctamente", async () => {
    const result = await client.callTool({
      name: "read_file",
      arguments: { filePath: path.join(tmpDir, "data.json") },
    });

    expect(result.isError).toBeUndefined();
    const parsed = JSON.parse((result.content[0] as { text: string }).text);
    expect(parsed).toEqual({ key: "value" });
  });
});

describe("list_files tool", () => {
  it("lista todos los archivos en un directorio", async () => {
    const result = await client.callTool({
      name: "list_files",
      arguments: { directory: tmpDir },
    });

    expect(result.isError).toBeUndefined();
    const parsed = JSON.parse((result.content[0] as { text: string }).text);
    expect(parsed.count).toBe(3);
    expect(parsed.files).toContain("hello.txt");
    expect(parsed.files).toContain("data.json");
    expect(parsed.files).toContain("script.ts");
  });

  it("filtra por extensión", async () => {
    const result = await client.callTool({
      name: "list_files",
      arguments: { directory: tmpDir, extension: ".ts" },
    });

    const parsed = JSON.parse((result.content[0] as { text: string }).text);
    expect(parsed.count).toBe(1);
    expect(parsed.files).toEqual(["script.ts"]);
  });

  it("retorna lista vacía con extensión sin matches", async () => {
    const result = await client.callTool({
      name: "list_files",
      arguments: { directory: tmpDir, extension: ".py" },
    });

    const parsed = JSON.parse((result.content[0] as { text: string }).text);
    expect(parsed.count).toBe(0);
    expect(parsed.files).toEqual([]);
  });

  it("retorna error para directorio inexistente", async () => {
    const result = await client.callTool({
      name: "list_files",
      arguments: { directory: "/path/that/doesnt/exist" },
    });

    expect(result.isError).toBe(true);
  });
});

Cada test verifica algo que podría fallar en producción: encoding incorrecto, crashes en vez de errores controlados, filtros rotos, o excepciones no capturadas. El test de archivo inexistente es especialmente importante — Claude Code puede enviar rutas que no existen, y tu server debe responder con un error limpio, no un crash.


Unit testing de Resources (TypeScript)

Los tests de resources siguen el mismo patrón de setup — reutiliza el mismo beforeAll/afterAll que usaste para tools:

// tests/resources.test.ts — usa el mismo setup con InMemoryTransport

describe("server-status resource", () => {
  it("retorna status del server como JSON válido", async () => {
    const result = await client.readResource({ uri: "status://server" });
    expect(result.contents).toHaveLength(1);
    expect(result.contents[0].mimeType).toBe("application/json");

    const status = JSON.parse(result.contents[0].text as string);
    expect(status).toHaveProperty("status", "running");
    expect(status).toHaveProperty("version", "1.0.0");
    expect(typeof status.uptime).toBe("number");
  });

  it("el resource aparece en la lista de resources", async () => {
    const { resources } = await client.listResources();
    const statusResource = resources.find((r) => r.uri === "status://server");
    expect(statusResource).toBeDefined();
    expect(statusResource?.name).toBe("server-status");
  });
});

Integration testing del server (TypeScript)

Los integration tests verifican que el server se comporta correctamente como un MCP server — no los tools individuales, sino el protocolo:

// tests/server.test.ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { createServer } from "../src/index.js";

let client: Client;

beforeAll(async () => {
  const server = createServer();
  client = new Client({ name: "test-client", version: "1.0.0" });
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
  await server.connect(serverTransport);
  await client.connect(clientTransport);
});

afterAll(async () => {
  await client.close();
});

describe("MCP Server integration", () => {
  it("server anuncia tools correctamente", async () => {
    const { tools } = await client.listTools();

    expect(tools.length).toBeGreaterThanOrEqual(2);

    const toolNames = tools.map((t) => t.name);
    expect(toolNames).toContain("read_file");
    expect(toolNames).toContain("list_files");
  });

  it("cada tool tiene descripción y schema", async () => {
    const { tools } = await client.listTools();

    for (const tool of tools) {
      expect(tool.description).toBeTruthy();
      expect(tool.inputSchema).toBeDefined();
      expect(tool.inputSchema.type).toBe("object");
    }
  });

  it("server anuncia resources correctamente", async () => {
    const { resources } = await client.listResources();

    expect(resources.length).toBeGreaterThanOrEqual(1);
    expect(resources[0]).toHaveProperty("uri");
    expect(resources[0]).toHaveProperty("name");
  });

  it("tool inexistente retorna error", async () => {
    await expect(
      client.callTool({ name: "tool_que_no_existe", arguments: {} })
    ).rejects.toThrow();
  });

  it("resource inexistente retorna error", async () => {
    await expect(
      client.readResource({ uri: "inexistente://nada" })
    ).rejects.toThrow();
  });
});

Setup: pytest para MCP servers en Python

Instalar dependencias

pip install pytest pytest-asyncio

Configurar pytest

Crea pytest.ini o agrega a pyproject.toml:

# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

Estructura de archivos

my-mcp-server-python/
├── server.py           # Tu MCP server
├── tests/
│   ├── __init__.py
│   ├── test_tools.py
│   ├── test_resources.py
│   └── test_server.py
└── pyproject.toml

Testing MCP servers en Python

El server de ejemplo

# server.py
from mcp.server.fastmcp import FastMCP
import json, os

mcp = FastMCP("file-utils")

@mcp.tool()
async def read_file(file_path: str) -> str:
    """Lee el contenido de un archivo y retorna su texto."""
    try:
        with open(file_path, "r", encoding="utf-8") as f:
            return f.read()
    except FileNotFoundError:
        raise ValueError(f"Archivo no encontrado: {file_path}")

@mcp.tool()
async def list_files(directory: str, extension: str | None = None) -> str:
    """Lista archivos en un directorio con filtro opcional por extensión."""
    try:
        entries = os.listdir(directory)
        files = [e for e in entries if os.path.isfile(os.path.join(directory, e))]
        if extension:
            files = [f for f in files if f.endswith(extension)]
        return json.dumps({"directory": directory, "files": files, "count": len(files)}, indent=2)
    except FileNotFoundError:
        raise ValueError(f"Directorio no encontrado: {directory}")

@mcp.resource("status://server")
async def server_status() -> str:
    """Estado actual del server."""
    return json.dumps({"status": "running", "version": "1.0.0"})

Tests con pytest

# tests/test_tools.py
import pytest
import json
import os
import tempfile
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client

# Para testing directo de funciones (unit tests)
from server import read_file, list_files

@pytest.fixture
def tmp_dir():
    """Crea un directorio temporal con archivos de prueba."""
    with tempfile.TemporaryDirectory() as d:
        with open(os.path.join(d, "hello.txt"), "w") as f:
            f.write("Hello, MCP!")
        with open(os.path.join(d, "data.json"), "w") as f:
            json.dump({"key": "value"}, f)
        with open(os.path.join(d, "script.py"), "w") as f:
            f.write("print('test')")
        yield d


class TestReadFile:
    async def test_reads_existing_file(self, tmp_dir):
        result = await read_file(os.path.join(tmp_dir, "hello.txt"))
        assert result == "Hello, MCP!"

    async def test_error_nonexistent_file(self, tmp_dir):
        with pytest.raises(ValueError, match="no encontrado"):
            await read_file(os.path.join(tmp_dir, "no-existe.txt"))

    async def test_reads_json_correctly(self, tmp_dir):
        result = await read_file(os.path.join(tmp_dir, "data.json"))
        parsed = json.loads(result)
        assert parsed == {"key": "value"}


class TestListFiles:
    async def test_lists_all_files(self, tmp_dir):
        result = await list_files(tmp_dir)
        parsed = json.loads(result)
        assert parsed["count"] == 3
        assert "hello.txt" in parsed["files"]
        assert "data.json" in parsed["files"]
        assert "script.py" in parsed["files"]

    async def test_filters_by_extension(self, tmp_dir):
        result = await list_files(tmp_dir, extension=".py")
        parsed = json.loads(result)
        assert parsed["count"] == 1
        assert parsed["files"] == ["script.py"]

    async def test_extension_no_matches(self, tmp_dir):
        result = await list_files(tmp_dir, extension=".rs")
        parsed = json.loads(result)
        assert parsed["count"] == 0
        assert parsed["files"] == []

    async def test_nonexistent_directory(self):
        with pytest.raises(ValueError, match="no encontrado"):
            await list_files("/path/that/doesnt/exist")

Integration tests en Python

Para integration tests, conectas un client real a tu server via stdio:

# tests/test_server.py
import pytest
import json
from mcp import ClientSession
from mcp.client.stdio import stdio_client, StdioServerParameters

@pytest.fixture
async def client_session():
    server_params = StdioServerParameters(command="python", args=["server.py"])
    async with stdio_client(server_params) as (read_stream, write_stream):
        async with ClientSession(read_stream, write_stream) as session:
            await session.initialize()
            yield session

class TestServerIntegration:
    async def test_server_lists_tools(self, client_session):
        tools = await client_session.list_tools()
        tool_names = [t.name for t in tools.tools]
        assert "read_file" in tool_names
        assert "list_files" in tool_names

    async def test_each_tool_has_description(self, client_session):
        tools = await client_session.list_tools()
        for tool in tools.tools:
            assert tool.description, f"Tool {tool.name} sin descripción"

    async def test_resource_returns_valid_json(self, client_session):
        result = await client_session.read_resource("status://server")
        data = json.loads(result.contents[0].text)
        assert data["status"] == "running"

Patrones de testing avanzados

Patrón 1: Test de validación de inputs

Este test verifica que tu server rechaza inputs inválidos correctamente. Es importante porque Claude Code puede enviar inputs inesperados:

describe("input validation", () => {
  it("rechaza filePath vacío", async () => {
    const result = await client.callTool({
      name: "read_file",
      arguments: { filePath: "" },
    });

    expect(result.isError).toBe(true);
  });

  it("rechaza directorio como filePath", async () => {
    const result = await client.callTool({
      name: "read_file",
      arguments: { filePath: tmpDir },
    });

    expect(result.isError).toBe(true);
  });
});

Patrón 2: Test de idempotencia

Verifica que llamar un tool múltiples veces produce el mismo resultado:

it("read_file es idempotente", async () => {
  const filePath = path.join(tmpDir, "hello.txt");
  const result1 = await client.callTool({ name: "read_file", arguments: { filePath } });
  const result2 = await client.callTool({ name: "read_file", arguments: { filePath } });

  expect(result1.content).toEqual(result2.content);
});

Patrón 3: Test de concurrencia

it("maneja múltiples requests simultáneos", async () => {
  const filePath = path.join(tmpDir, "hello.txt");
  const results = await Promise.all([
    client.callTool({ name: "read_file", arguments: { filePath } }),
    client.callTool({ name: "read_file", arguments: { filePath } }),
    client.callTool({ name: "list_files", arguments: { directory: tmpDir } }),
  ]);

  expect(results).toHaveLength(3);
  results.forEach((r) => expect(r.isError).toBeUndefined());
});

Troubleshooting de tests

"InMemoryTransport no existe en mi versión del SDK"

Actualiza a la versión más reciente: npm install @modelcontextprotocol/sdk@latest

"Los tests async no terminan"

Asegúrate de cerrar la conexión en afterAll con await client.close().

"TypeError: Cannot read properties of undefined"

Verifica que beforeAll usa await en server.connect() y client.connect().


Ejercicios

Ejercicio 1: Test de error handling (Fácil)

Escribe un test que verifique que tu server retorna isError: true con un mensaje descriptivo cuando un tool recibe un input inválido (e.g., ruta con caracteres especiales).

Ver solución
describe("error handling", () => {
  it("retorna isError con mensaje descriptivo para ruta inválida", async () => {
    const result = await client.callTool({
      name: "read_file",
      arguments: { filePath: "\0invalid\0path" },
    });

    expect(result.isError).toBe(true);
    const text = (result.content[0] as { text: string }).text;
    expect(text).toContain("Error");
    expect(text.length).toBeGreaterThan(10);
  });
});

Ejercicio 2: Test de resource con datos dinámicos (Fácil)

Escribe un test que verifique que el resource status://server retorna un uptime mayor a 0.

Ver solución
it("status resource tiene uptime positivo", async () => {
  const result = await client.readResource({ uri: "status://server" });
  const status = JSON.parse(result.contents[0].text as string);
  expect(status.uptime).toBeGreaterThan(0);
});

Ejercicio 3: Test suite para un tool CRUD (Medio)

Escribe un test suite para un tool manage_notes que permite crear, listar y borrar notas. El test debe seguir el flujo completo: crear → listar → borrar → verificar.

Ver solución
describe("manage_notes CRUD", () => {
  it("flujo completo: crear → listar → borrar → verificar", async () => {
    const createResult = await client.callTool({
      name: "manage_notes",
      arguments: { action: "create", title: "Test Note", content: "Contenido" },
    });
    const created = JSON.parse((createResult.content[0] as { text: string }).text);
    expect(created.created).toHaveProperty("id");

    const listResult = await client.callTool({
      name: "manage_notes", arguments: { action: "list" },
    });
    const listed = JSON.parse((listResult.content[0] as { text: string }).text);
    expect(listed.notes.some((n: { title: string }) => n.title === "Test Note")).toBe(true);

    await client.callTool({
      name: "manage_notes",
      arguments: { action: "delete", noteId: created.created.id },
    });

    const listAfter = await client.callTool({
      name: "manage_notes", arguments: { action: "list" },
    });
    const after = JSON.parse((listAfter.content[0] as { text: string }).text);
    expect(after.notes.some((n: { title: string }) => n.title === "Test Note")).toBe(false);
  });
});

Ejercicio 4: Test de integración Python (Medio)

Escribe un test con pytest que conecte a tu server Python via stdio, liste los tools disponibles, y verifique que cada tool tiene un inputSchema con al menos un parámetro definido.

Ver solución
async def test_tools_have_parameters(self, client_session):
    tools = await client_session.list_tools()

    for tool in tools.tools:
        schema = tool.inputSchema
        assert schema.get("type") == "object", f"{tool.name}: schema no es object"
        properties = schema.get("properties", {})
        assert len(properties) > 0, f"{tool.name}: sin parámetros definidos"

Ejercicio 5: Test de edge cases con caracteres Unicode (Difícil)

Escribe un test que cree un archivo con contenido Unicode (emojis, caracteres CJK, diacríticos), lo lea con tu tool read_file, y verifique que el contenido se preserva exactamente.

Ver solución
it("preserva contenido Unicode correctamente", async () => {
  const unicodeContent = "Hello 🌍\nCafé ñoño\n你好世界\nÄÖÜ àèì\n🎉🚀💡";
  const filePath = path.join(tmpDir, "unicode.txt");
  await fs.writeFile(filePath, unicodeContent, "utf-8");

  const result = await client.callTool({
    name: "read_file",
    arguments: { filePath },
  });

  expect(result.isError).toBeUndefined();
  expect((result.content[0] as { text: string }).text).toBe(unicodeContent);
});

Resumen

En esta cápsula aprendiste:

  • InMemoryTransport es la clave para testear MCP servers sin levantar procesos o servidores — conecta client y server directamente en memoria
  • Unit tests de tools verifican que cada tool retorna resultados correctos y maneja errores sin crash
  • Unit tests de resources verifican que los datos expuestos son válidos y accesibles
  • Integration tests verifican que el server anuncia capabilities correctamente y responde al protocolo MCP
  • Vitest (TypeScript) y pytest (Python) se configuran con mínimo esfuerzo para proyectos MCP
  • Patrones avanzados — validación de inputs, idempotencia, concurrencia — detectan bugs que las pruebas manuales no encuentran
  • El setup de testing (archivos temporales, fixtures, cleanup) es tan importante como los tests mismos

Cada test que escribiste aquí detecta un bug real que aparece cuando Claude Code usa tu server con inputs que no anticipaste.


Recursos adicionales

  1. Vitest Documentation — Framework de testing para TypeScript
  2. pytest Documentation — Framework de testing para Python
  3. pytest-asyncio — Plugin para tests async en Python
  4. MCP TypeScript SDK — Testing — SDK con InMemoryTransport
  5. MCP Python SDK — SDK oficial para Python
  6. Vitest — Mocking — Mocking de dependencias externas

Siguiente cápsula: Debugging: Herramientas — MCP Inspector para debugging visual, logging efectivo, y tracing de requests en MCP servers.