Módulo 7: Conectar con Claude Code y servidores de terceros

Registrar el servidor Reservo

Descripción

La lección anterior te dio la forma general de .mcp.json. Esta la hace concreta: la entrada real, completa, que registra reservo_full_mcp_server.py —el servidor con sus cuatro tools (M3), sus dos resources (M4) y el prompt plan_booking (M5), tal como quedó armado en el mini-proyecto del Módulo 6— para que un host como Claude Code lo pueda lanzar. Vas a escribir esa entrada, validarla, y después demostrar algo que hasta ahora solo afirmaste: cuando un host lee .mcp.json y lanza el command/args que ahí encuentra, el proceso que arranca es, exactamente, el mismo servidor que ya conoces de memoria — no una simulación, no una versión distinta. También vas a ver el claude mcp add que produciría esta misma entrada, explicado campo por campo.

Conexión con el módulo

Esta lección cierra la primera mitad del módulo (registrar tu propio servidor, de confianza total) antes de que la lección 05 en adelante empiece a hablar de servidores que no escribiste tú. Todo lo que sigue en las lecciones 05-08 usa esta misma mecánica —una entrada de mcpServers— aplicada a un caso con trust profile distinto.


La entrada completa

{
  "mcpServers": {
    "reservo-mcp-server": {
      "type": "stdio",
      "command": "python3.14",
      "args": ["reservo_full_mcp_server.py"],
      "env": {}
    }
  }
}

Cada campo, justificado con lo que ya sabes del servidor:

  • "reservo-mcp-server" (la clave) — el mismo nombre que SERVER_INFO["name"] dentro del servidor, fijado desde el Módulo 2 y reutilizado sin cambios en todos los módulos siguientes. No es una coincidencia de estilo: es útil que el nombre con el que registras un servidor en .mcp.json coincida con el que ese servidor declara en su propio serverInfo durante el handshake, para que no haya ambigüedad sobre qué proceso corresponde a qué entrada cuando estés depurando un .mcp.json con varios servidores (la lección 08 arma justamente ese caso).
  • "type": "stdio"reservo_full_mcp_server.py corre siempre local, como subproceso, tal como estableció la lección 04 del Módulo 1. Nunca "http" para este servidor, en esta guía.
  • "command": "python3.14" — el ejecutable, exactamente como lo escribirías en tu terminal para correr el script tú mismo. No es sys.executable (eso es una expresión de Python en tiempo de ejecución, no un string fijo en un archivo de configuración) — es el nombre del intérprete tal como el sistema operativo lo va a buscar en el PATH.
  • "args": ["reservo_full_mcp_server.py"] — un único argumento: la ruta al script. Si el archivo no estuviera en el mismo directorio desde el que Claude Code arranca el proceso, esta ruta necesitaría ser absoluta o relativa a un directorio conocido — un detalle de despliegue real que esta guía nombra pero no profundiza, porque depende de dónde vive cada proyecto.
  • "env": {} — vacío, y a propósito: reservo_full_mcp_server.py no necesita ninguna credencial ni configuración externa. Guarda todo su estado (BOOKINGS, los contadores de itertools.count(1)) en memoria del propio proceso. Un servidor MCP real que hablara con una base de datos, en cambio, típicamente necesitaría aquí algo como {"DATABASE_URL": "..."}.

Ejemplo trabajado: confirmar que Claude Code lanzaría el mismo servidor de siempre

Hasta ahora, este módulo solo validó la forma de .mcp.json — nunca confirmó que, si un host de verdad tomara command y args de esa entrada y lanzara el proceso, el resultado fuera el servidor real de Reservo. Esta lección lo demuestra: un script que simula exactamente lo que hace un host —leer .mcp.json, lanzar command+args con subprocess.Popen, hacer el handshake initialize— y confirma, con el propio protocolo, que el serverInfo.name que responde el proceso coincide con el nombre bajo el que lo registraste.

# verify_registration.py
"""Simula lo que Claude Code haria al leer .mcp.json: lanzar el servidor con
command+args EXACTOS de la entrada, y confirmar que el nombre negociado en el
handshake coincide con la clave usada para registrarlo. Wire protocol real,
sobre el mismo reservo_full_mcp_server.py de los Modulos 2-6, sin cambiar nada."""
import json
import subprocess
import itertools

with open("mcp_config.json", "r", encoding="utf-8") as file:
    config = json.load(file)

request_ids = itertools.count(1)

for server_name, entry in config["mcpServers"].items():
    assert entry["type"] == "stdio", "este chequeo es solo para stdio"
    proc = subprocess.Popen(
        [entry["command"], *entry["args"]],
        stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
        text=True, bufsize=1,
    )
    request = {"jsonrpc": "2.0", "id": next(request_ids), "method": "initialize",
               "params": {"protocolVersion": "2025-06-18", "capabilities": {},
                          "clientInfo": {"name": "reservo-mcp-client", "version": "1.0.0"}}}
    proc.stdin.write(json.dumps(request) + "\n")
    proc.stdin.flush()
    response = json.loads(proc.stdout.readline())
    negotiated_name = response["result"]["serverInfo"]["name"]
    print(f"[.mcp.json] nombre registrado: {server_name!r}")
    print(f"[handshake]  serverInfo.name:  {negotiated_name!r}")
    print(f"coinciden: {server_name == negotiated_name}")
    proc.stdin.close()
    proc.wait(timeout=5)

Qué esperar (ejecutando python3.14 verify_registration.py en un directorio con mcp_config.json y reservo_full_mcp_server.py):

[.mcp.json] nombre registrado: 'reservo-mcp-server'
[handshake]  serverInfo.name:  'reservo-mcp-server'
coinciden: True

Nota lo que este script no hizo: no importó nada de Claude Code, no simuló ninguna aplicación completa. Solo tomó entry["command"] y entry["args"] —dos valores sacados de un diccionario que vino de json.load— y los pasó directo a subprocess.Popen([entry["command"], *entry["args"]], ...). Es, literalmente, el mismo mecanismo que ya usaste en el Módulo 6 dentro de MCPClient.__init__ (subprocess.Popen([sys.executable, server_path], ...)), con una diferencia mínima: ahí el comando estaba fijo en el código (sys.executable); aquí viene de un archivo externo, leído en tiempo de ejecución. Esa es, en esencia, toda la magia de .mcp.json — no hay ningún mecanismo nuevo de arranque de procesos, solo una fuente de configuración distinta para el mismo subprocess.Popen que ya conoces.


El claude mcp add equivalente

# CONTENIDO -- se explica en detalle, NUNCA se ejecuta en esta guia.
claude mcp add reservo-mcp-server --scope project -- python3.14 reservo_full_mcp_server.py

Desarmado, campo por campo:

  • reservo-mcp-server — el nombre del servidor, el mismo que terminaría siendo la clave dentro de mcpServers.
  • --scope project — decide dónde vive el .mcp.json resultante y quién lo ve. Claude Code define tres alcances: local (por defecto si se omite el flag — personal, no se comparte ni se versiona), project (un .mcp.json en la raíz del repositorio, pensado para versionarse con git y compartirse con todo el equipo que trabaje en ese proyecto) y user (disponible para ti en cualquier proyecto que abras, sin importar el repositorio). Para un servidor de Reservo que todo el equipo va a usar de la misma forma, project es la elección natural — así queda documentado, en el propio repositorio, qué servidores MCP asume el proyecto.
  • -- — el separador estándar que le dice a la CLI "todo lo que sigue después de esto es el comando a ejecutar, no más flags de claude mcp add". Sin este separador, claude mcp add podría interpretar mal python3.14 como si fuera un flag propio.
  • python3.14 reservo_full_mcp_server.py — el comando y sus argumentos, en el mismo formato en que los escribirías en una terminal — esto es exactamente lo que termina, separado, en command y args dentro del JSON.

Correr este comando, dentro de un proyecto real con Claude Code instalado, produciría —o extendería, si el archivo ya existía con otras entradas— un .mcp.json con la entrada exacta que escribiste a mano al principio de esta lección. Las variables de entorno se agregarían con flags adicionales (--env DATABASE_URL=postgres://..., repetible por cada variable) que terminan poblando el campo env — no hacen falta aquí, porque reservo_full_mcp_server.py no necesita ninguna.


Qué pasa después del registro (concepto, no ejecutado)

Con reservo-mcp-server registrado en .mcp.json, lo que Claude Code hace a continuación es, estructuralmente, exactamente lo que construiste en el Módulo 6: se comporta como un MCPClient más. Lanza el proceso con command/args, hace initialize/notifications/initialized, y después ejecuta el flujo de descubrimiento completo (tools/list + resources/list + prompts/list) para saber qué le ofrece el servidor — el mismo orden, los mismos tres métodos, que ya ejecutaste de punta a punta en la lección 05 del Módulo 6. La diferencia real no está en el protocolo —ese es idéntico— sino en quién decide, después del descubrimiento, qué usar: en tus scripts de los módulos anteriores, tu propio código de cliente decidía qué tools/call hacer; dentro de Claude Code, es el modelo (claude-sonnet-5, en una conversación real) el que ve las cuatro tools descubiertas y decide, en el momento, si conviene llamar get_quote para responder lo que le estés preguntando. Esa decisión del modelo —qué invocar y cuándo, en una conversación real— es exactamente lo que esta guía nombra como concepto, sin ejecutarlo: no hay llamada a la API de Claude en ningún módulo de esta guía, solo el protocolo MCP que la hace posible.


Errores comunes

  1. Usar sys.executable dentro de .mcp.json. .mcp.json es un archivo de texto plano, no un script de Python — sys.executable es una expresión que solo tiene sentido dentro de un proceso Python ya corriendo. En command, siempre va el nombre (o la ruta) literal del ejecutable, como "python3.14" o "/usr/local/bin/python3.14".

  2. Olvidar que la ruta en args es relativa al directorio desde el que Claude Code lanza el proceso, no a la ubicación de .mcp.json. Si reservo_full_mcp_server.py no está en ese directorio de trabajo, args: ["reservo_full_mcp_server.py"] fallaría al arrancar con un error de "archivo no encontrado" — la solución es una ruta absoluta, o relativa de forma consistente con dónde corre el proyecto.

  3. Pensar que el claude mcp add de esta lección modificaría el comportamiento del servidor. No lo hace, y no podría: claude mcp add solo escribe (o edita) .mcp.json — nunca toca reservo_full_mcp_server.py. El servidor sigue siendo exactamente el mismo código que ya construiste y probaste en los Módulos 2 a 6, sin importar si lo lanzas tú con subprocess.Popen o Claude Code lo lanza leyendo .mcp.json.

  4. Elegir --scope local para un servidor que todo el equipo necesita. Con local (o sin el flag, que es su valor por defecto), la configuración queda solo en tu máquina — un compañero de equipo que clone el mismo repositorio no vería reservo-mcp-server registrado, porque ese .mcp.json nunca se generó donde git lo versiona. --scope project es la elección correcta cuando el objetivo es que el servidor esté disponible para cualquiera que trabaje en ese proyecto.


Ejercicios

Ejercicio 1: Encuentra el error en esta entrada (Fácil)

{
  "mcpServers": {
    "reservo-mcp-server": {
      "type": "stdio",
      "command": "python3.14",
      "args": "reservo_full_mcp_server.py"
    }
  }
}

¿Qué está mal, y qué reportaría validate_server_entry de la lección 02 si corrieras esta entrada contra esa función?

Ver solución

args está escrito como un string ("reservo_full_mcp_server.py"), no como una lista (["reservo_full_mcp_server.py"]). validate_server_entry de la lección 02 verifica explícitamente isinstance(entry["args"], list), así que reportaría: reservo-mcp-server: args debe ser una lista. Aunque el archivo sigue siendo JSON perfectamente válido —no hay ningún error de sintaxis—, la forma que Claude Code espera para args es siempre una lista, incluso cuando hay un solo argumento.

Ejercicio 2: Escribe el claude mcp add para un scope distinto (Medio)

Reescribe el comando claude mcp add de esta lección para que registre reservo-mcp-server con --scope local en vez de --scope project (para probarlo solo en tu propia máquina, sin compartirlo con el equipo todavía), y agrégale una variable de entorno hipotética RESERVO_LOG_LEVEL=debug usando el flag --env.

Ver solución
# CONTENIDO -- se explica, no se ejecuta.
claude mcp add reservo-mcp-server --scope local --env RESERVO_LOG_LEVEL=debug -- python3.14 reservo_full_mcp_server.py

Esto produciría (si reservo_full_mcp_server.py leyera esa variable, cosa que la versión de esta guía no hace) una entrada equivalente a:

{
  "mcpServers": {
    "reservo-mcp-server": {
      "type": "stdio",
      "command": "python3.14",
      "args": ["reservo_full_mcp_server.py"],
      "env": {"RESERVO_LOG_LEVEL": "debug"}
    }
  }
}

Explicación: cambiar --scope project por --scope local no cambia ni un campo de la entrada JSON en sí —sigue siendo el mismo type/command/args— cambia únicamente dónde se guarda esa entrada (un .mcp.json personal, fuera del control de versiones, en vez de uno compartido en la raíz del repositorio). El flag --env es repetible: cada aparición agrega una clave más al objeto env final.

Ejercicio 3: Verifica el registro con una tool real, no solo el handshake (Difícil)

Extiende verify_registration.py de esta lección para que, después de confirmar que serverInfo.name coincide, también haga notifications/initialized, tools/list, y confirme con un assert que list_rooms aparece entre las tools descubiertas — demostrando que el servidor lanzado a partir de .mcp.json no solo responde al handshake, sino que tiene el catálogo completo de Reservo disponible.

Ver solución
import json
import subprocess
import itertools

with open("mcp_config.json", "r", encoding="utf-8") as file:
    config = json.load(file)

request_ids = itertools.count(1)
entry = config["mcpServers"]["reservo-mcp-server"]

proc = subprocess.Popen(
    [entry["command"], *entry["args"]],
    stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
    text=True, bufsize=1,
)


def send(message):
    proc.stdin.write(json.dumps(message) + "\n")
    proc.stdin.flush()


def recv():
    return json.loads(proc.stdout.readline())


send({"jsonrpc": "2.0", "id": next(request_ids), "method": "initialize",
      "params": {"protocolVersion": "2025-06-18", "capabilities": {},
                 "clientInfo": {"name": "reservo-mcp-client", "version": "1.0.0"}}})
init_response = recv()
assert init_response["result"]["serverInfo"]["name"] == "reservo-mcp-server"

send({"jsonrpc": "2.0", "method": "notifications/initialized"})
send({"jsonrpc": "2.0", "id": next(request_ids), "method": "tools/list", "params": {}})
tools_response = recv()
tool_names = [tool["name"] for tool in tools_response["result"]["tools"]]

assert "list_rooms" in tool_names
print(f"OK -- servidor lanzado desde .mcp.json, {len(tool_names)} tools descubiertas: {tool_names}")

proc.stdin.close()
proc.wait(timeout=5)

Salida esperada:

OK -- servidor lanzado desde .mcp.json, 4 tools descubiertas: ['list_rooms', 'get_quote', 'book_room', 'cancel_booking']

Explicación: este ejercicio conecta, con código, las dos mitades del módulo hasta ahora: la validación de la lección 02 (¿el JSON tiene la forma correcta?) y la confirmación de esta lección (¿lanzar ese JSON produce, de verdad, el servidor completo de Reservo?). Los dos assert son la versión programática de una pregunta que cualquier host real necesita poder responder antes de confiar en una entrada de .mcp.json: no solo "¿arranca sin errores?", sino "¿arranca el servidor correcto, con el catálogo que se espera?".


Resumen y siguiente paso

  • La entrada real de reservo-mcp-server en .mcp.json: type: "stdio", command: "python3.14", args: ["reservo_full_mcp_server.py"], env: {} — cada campo justificado por lo que ya sabes del servidor desde los Módulos 2 a 6.
  • Un host que lee esa entrada y lanza command+args con subprocess.Popen está corriendo el mismo mecanismo exacto que MCPClient.__init__ del Módulo 6 — se confirmó con protocolo real: el serverInfo.name negociado coincide con la clave del registro.
  • claude mcp add reservo-mcp-server --scope project -- python3.14 reservo_full_mcp_server.py produce esta misma entrada — --scope decide dónde vive el archivo resultante y quién lo comparte.
  • Lo que Claude Code hace después del registro —descubrir con tools/list/resources/list/prompts/list, y dejar que el modelo decida qué invocar— es, estructuralmente, el mismo flujo del Módulo 6, con el modelo en el lugar donde antes estaba tu propio código de cliente.

Siguiente lección: 04 — Transportes stdio vs. HTTP en producción. Con Reservo ya registrado por stdio, el criterio completo para decidir cuándo un servidor nuevo debería usar, en cambio, Streamable HTTP.


Recursos adicionales

  1. Claude Code — Model Context Protocol (MCP) — La documentación oficial de claude mcp add, sus flags (--scope, --env, --transport) y los tres alcances (local/project/user).
  2. Model Context Protocol — Specification 2025-06-18: Lifecycle — El handshake que el script de esta lección ejecuta contra el servidor lanzado desde .mcp.json.
  3. Python — subprocess.Popen — El mecanismo de arranque de procesos, idéntico al que ya usaste en MCPClient del Módulo 6.
  4. Python — json — La lectura de .mcp.json que alimenta command/args en esta lección.