← Volver al Blog

Servidor MCP silencioso: 3 patrones de debug

MCP tiene un problema que la documentación oficial menciona en dos líneas y luego cambia de tema: cuando un servidor MCP falla, la mayor parte del tiempo falla en silencio. No hay stack trace, no hay 500, no hay mensaje visible en el cliente. La tool simplemente no aparece en la lista, o aparece pero nunca es invocada, o es invocada pero no devuelve nada. Y tú te quedas viendo la interfaz de Claude Desktop preguntándote si escribiste bien el nombre del binario.

Este artículo es sobre debug de fallos silenciosos, no sobre introducción a MCP (para eso está MCP en 5 minutos para devs LatAm) ni sobre costos por tokens (para eso está Los Claude Code Skills consumen tokens). Son tres patrones específicos que me costaron cerca de 4 horas cada uno en producción, y la instrumentación mínima que ahora uso para que la próxima vez el bug se declare en 10 minutos.

Patrón 1: stdout contaminado por logs del servidor

El transporte stdio de MCP usa stdout exclusivamente para mensajes JSON-RPC. Cualquier otra cosa que escribas a stdout corrompe el stream y el cliente lo interpreta como un mensaje inválido. Muchos frameworks (Python, Node, Go) tienen loggers que por defecto van a stdout. Si tu handler llama a print("Loading config...") o el runtime de tu framework escribe una línea de startup a stdout, el cliente MCP recibe basura entre los mensajes JSON.

El síntoma es peculiar: la conexión se establece, el initialize handshake pasa, pero cuando el cliente pide tools/list la respuesta llega corrupta y el cliente decide silenciosamente que este servidor no tiene tools. No aparece ningún error en la interfaz. La única forma de darse cuenta es abrir MCP Inspector y ver el panel Server output.

La regla que aprendí a la mala: todo lo que no sea protocolo JSON-RPC va a stderr. En Python:

import sys
import logging

logging.basicConfig(
    level=logging.INFO,
    stream=sys.stderr,  # importante: stderr, no stdout
    format="%(asctime)s %(levelname)s %(message)s"
)

# Y NUNCA hacer print() sin especificar file=sys.stderr
print("Debug info", file=sys.stderr)

En Node:

// console.log escribe a stdout — no lo uses en un servidor MCP stdio
// Usa console.error, que va a stderr
console.error("Server started");

MCP Inspector captura el stream de stderr y lo renderiza en un panel dedicado. Si algo que crees que se está imprimiendo no aparece ahí, sospecha que está yendo a stdout y rompiendo el protocolo.

Patrón 2: cwd distinto en el cliente

Este me tomó una tarde entera porque el servidor funcionaba perfectamente cuando lo iniciaba manualmente desde la terminal. Cuando el cliente (en mi caso Claude Desktop) lo iniciaba, el servidor arrancaba pero mi tool que leía un archivo de configuración fallaba silenciosamente y devolvía una lista vacía.

La razón: el cwd del proceso que arranca el cliente MCP no es donde tú piensas. Claude Desktop en macOS arranca el proceso desde el directorio raíz del usuario o desde /, dependiendo de la versión. Si tu servidor hace open("config.yaml") asumiendo cwd relativo a donde está el ejecutable, va a fallar con FileNotFoundError que tu código captura sin decirle nada al cliente.

Dos formas de arreglar esto. La primera, dura pero definitiva: nunca uses paths relativos en un servidor MCP.

from pathlib import Path

# En vez de esto:
# config = open("config.yaml")  # depende del cwd del cliente

# Haz esto:
SERVER_DIR = Path(__file__).parent.resolve()
config = open(SERVER_DIR / "config.yaml")

La segunda, más simple pero menos portable: declara el cwd explícitamente en la configuración del cliente. En claude_desktop_config.json:

{
  "mcpServers": {
    "mi-servidor": {
      "command": "python",
      "args": ["-m", "mi_servidor"],
      "cwd": "/Users/ken/servers/mi-servidor",
      "env": {}
    }
  }
}

El campo cwd no está muy documentado pero funciona en Claude Desktop y en Cline. En Cursor, la última vez que revisé, había que setear el path absoluto del binario y confiar en que el servidor no tocara archivos relativos.

MCP Inspector no te ayuda directamente con este bug (Inspector arranca desde tu terminal, así que hereda tu cwd). La forma de reproducirlo es agregar temporalmente esta línea al arranque del servidor:

import os, sys
print(f"cwd={os.getcwd()}", file=sys.stderr)
print(f"argv={sys.argv}", file=sys.stderr)

Y revisar el panel Server output de Inspector después de recargar la configuración del cliente. Si el cwd que ves ahí no es lo que esperas, encontraste el bug.

Patrón 3: capabilities vacío en el initialize response

Este es el más frustrante porque el protocolo lo permite y el cliente lo acepta sin quejarse. El initialize handshake devuelve un objeto capabilities que declara qué categorías de funcionalidad expone el servidor (tools, resources, prompts, logging). Si tú declaras capabilities: {} (o lo declaras pero sin tools), el cliente asume que este servidor no tiene tools y nunca llama a tools/list.

El servidor sigue corriendo. El handshake se completó. Los logs dicen que todo está bien. Pero la lista de tools está vacía en el cliente porque el cliente ni siquiera preguntó.

3 patrones de fallo silencioso en MCP

En la mayoría de los SDKs oficiales de MCP esto está manejado por defecto: si registras una tool, capabilities.tools se llena automáticamente. Pero si estás usando un SDK viejo, o implementando el protocolo desde cero, o si un framework de terceros hace el handshake por ti, este check se puede perder.

La forma de detectarlo: en MCP Inspector, después de conectar, mira la primera respuesta del servidor (el initialize response). Debe verse algo así:

{
  "protocolVersion": "2026-07-28",
  "capabilities": {
    "tools": {"listChanged": true},
    "resources": {"subscribe": false, "listChanged": false}
  },
  "serverInfo": {"name": "mi-servidor", "version": "0.1.0"}
}

Si capabilities.tools está ausente, el cliente no va a llamar a tools/list. La solución es agregar la declaración manualmente en el handler de initialize:

async def handle_initialize(request):
    return {
        "protocolVersion": "2026-07-28",
        "capabilities": {
            "tools": {"listChanged": True},
        },
        "serverInfo": {"name": "mi-servidor", "version": "0.1.0"}
    }

La instrumentación mínima que ahora siempre uso

Después de perder alrededor de 12 horas de mi vida en estos tres patrones, ahora todo servidor MCP que escribo tiene lo siguiente antes de la primera línea de lógica útil:

  1. stderr logger configurado explícitamente, con nivel INFO y timestamps
  2. stderr print de cwd y argv en el arranque, para que Inspector muestre inmediatamente dónde está corriendo el proceso
  3. Un test manual con MCP Inspector antes de conectar al cliente real. Si Inspector no puede llamar a tools/list, Claude Desktop tampoco va a poder. Debug primero en Inspector, después en el cliente
  4. Assertion explícita en initialize que valide que capabilities.tools está declarado

El costo de agregar esos cuatro checks al esqueleto de un servidor MCP es de unos 15 minutos. El costo de no tenerlos es la mitad de una tarde por cada uno de los tres patrones que aparecen.

MCP como protocolo tiene un problema de diseño respecto al debug: los errores del transporte no se propagan al cliente, y los errores del cliente al elegir no invocar una tool no se propagan a nadie. Hasta que eso mejore en versiones futuras del spec, la única defensa es instrumentar el servidor tú mismo desde la primera línea.

Referencias


ken imoto · WebRTC & Voice AI engineer · kenimoto.dev

Convirtiendo LLMs de Mentirosos en Expertos Libro relacionado Convirtiendo LLMs de Mentirosos en Expertos Ingeniería de Contexto desde cero — RAG, MCP, CLAUDE.md y Agentic RAG, con benchmarks que muestran hasta 4,6× de mejora Ver la página del libro →