Módulo 6: Construir un cliente MCP y descubrimiento
Hablar con dos servidores a la vez
Descripción
Esta es la lección donde la arquitectura de la lección 06 deja de ser una explicación y se convierte en una corrida real: un Host, dos subprocesos vivos al mismo tiempo, un registro combinado con las entradas de ambos, y una llamada a cada servidor en la misma corrida, sin que ninguno se entere de la existencia del otro. Es la demostración completa de la analogía del Módulo 1 de esta guía: la recepcionista con dos teléfonos, uno por proveedor.
Conexión con el módulo
Introduces aquí, por primera vez ejecutado a la vez que Reservo, el segundo servidor presentado en la lección 01: sunroom-cafe-mcp-server. No es una versión reducida de Reservo ni comparte código con él — es un servidor MCP completamente separado, con su propio dominio (los especiales del día de un café), que solo declara tools en sus capabilities (como ya confirmaste en la lección 03). Esta lección es la única de todo el módulo donde dos servidores corren simultáneamente.
La demo completa: Host con dos servidores
from host import Host
host = Host()
host.connect_server("reservo", "reservo_full_mcp_server.py")
host.connect_server("sunroom-cafe", "sunroom_cafe_mcp_server.py")
print(f"[host] servers conectados: {list(host.clients.keys())}")
registry = host.discover_all()
for server_name, catalog in registry.items():
print(f"[host] registro combinado -- '{server_name}': "
f"{len(catalog['tools'])} tools, {len(catalog['resources'])} resources, "
f"{len(catalog['prompts'])} prompts")
print()
print("[host] una llamada a CADA server, en la misma corrida:")
quote = host.call_tool_on("reservo", "get_quote", {"room": "Focus", "tier": "pro", "hours": 3})
print(f"[host] reservo.get_quote -> {quote['content'][0]['text']}")
specials = host.call_tool_on("sunroom-cafe", "todays_specials", {})
print(f"[host] sunroom-cafe.todays_specials -> {specials['content'][0]['text']}")
print()
reservo_ids_used = host.clients["reservo"].request_ids
cafe_ids_used = host.clients["sunroom-cafe"].request_ids
print(f"[host] siguiente id libre en 'reservo': {next(reservo_ids_used)} "
f"(su propio contador, sin relacion con 'sunroom-cafe')")
print(f"[host] siguiente id libre en 'sunroom-cafe': {next(cafe_ids_used)}")
host.close_all()
Nota que no hay nada especial en este código respecto a la lección 06 — dos llamadas a connect_server() en vez de una, y después exactamente los mismos métodos (discover_all(), call_tool_on()) que ya existían. Esta es, de hecho, la prueba de que Host fue bien diseñado: agregar un segundo servidor no requirió tocar ninguna línea de la clase, solo llamarla una vez más — la misma propiedad aditiva "M+N, no M×N" que motivó la existencia de MCP desde el Módulo 1, ahora visible del lado del cliente.
Qué esperar: dos subprocesos, dos handshakes, un registro combinado
Corriendo python3.14 este_script.py, esta es la transcripción real, completa, con los dos servidores corriendo al mismo tiempo:
[client -> reservo_full_mcp_server.py] {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "reservo-mcp-client", "version": "1.0.0"}}}
[reservo_full_mcp_server.py -> client] {"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2025-06-18", "capabilities": {"tools": {}, "resources": {}, "prompts": {}}, "serverInfo": {"name": "reservo-mcp-server", "version": "1.0.0"}}}
[client -> reservo_full_mcp_server.py] {"jsonrpc": "2.0", "method": "notifications/initialized"}
[client -> sunroom_cafe_mcp_server.py] {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "reservo-mcp-client", "version": "1.0.0"}}}
[sunroom_cafe_mcp_server.py -> client] {"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2025-06-18", "capabilities": {"tools": {}}, "serverInfo": {"name": "sunroom-cafe-mcp-server", "version": "1.0.0"}}}
[client -> sunroom_cafe_mcp_server.py] {"jsonrpc": "2.0", "method": "notifications/initialized"}
[host] servers conectados: ['reservo', 'sunroom-cafe']
[client -> reservo_full_mcp_server.py] {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}
[reservo_full_mcp_server.py -> client] {"jsonrpc": "2.0", "id": 2, "result": {"tools": [...4 tools...]}}
[client -> reservo_full_mcp_server.py] {"jsonrpc": "2.0", "id": 3, "method": "resources/list", "params": {}}
[reservo_full_mcp_server.py -> client] {"jsonrpc": "2.0", "id": 3, "result": {"resources": [...2 resources...]}}
[client -> reservo_full_mcp_server.py] {"jsonrpc": "2.0", "id": 4, "method": "prompts/list", "params": {}}
[reservo_full_mcp_server.py -> client] {"jsonrpc": "2.0", "id": 4, "result": {"prompts": [{"name": "plan_booking", ...}]}}
[client -> sunroom_cafe_mcp_server.py] {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}
[sunroom_cafe_mcp_server.py -> client] {"jsonrpc": "2.0", "id": 2, "result": {"tools": [{"name": "todays_specials", "description": "Lista los especiales del dia del cafe Sunroom. Sin argumentos.", "inputSchema": {"type": "object", "properties": {}}}]}}
[host] registro combinado -- 'reservo': 4 tools, 2 resources, 1 prompts
[host] registro combinado -- 'sunroom-cafe': 1 tools, 0 resources, 0 prompts
[host] una llamada a CADA server, en la misma corrida:
[client -> reservo_full_mcp_server.py] {"jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": {"name": "get_quote", "arguments": {"room": "Focus", "tier": "pro", "hours": 3}}}
[reservo_full_mcp_server.py -> client] {"jsonrpc": "2.0", "id": 5, "result": {"content": [{"type": "text", "text": "{\"price_cents\": 6000}"}], "isError": false}}
[host] reservo.get_quote -> {"price_cents": 6000}
[client -> sunroom_cafe_mcp_server.py] {"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "todays_specials", "arguments": {}}}
[sunroom_cafe_mcp_server.py -> client] {"jsonrpc": "2.0", "id": 3, "result": {"content": [{"type": "text", "text": "[\"Oat milk latte\", \"Almond croissant\", \"Avocado toast\"]"}], "isError": false}}
[host] sunroom-cafe.todays_specials -> ["Oat milk latte", "Almond croissant", "Avocado toast"]
[host] siguiente id libre en 'reservo': 6 (su propio contador, sin relacion con 'sunroom-cafe')
[host] siguiente id libre en 'sunroom-cafe': 4
---- stderr de reservo_full_mcp_server.py ----
[server] arrancando, esperando mensajes por stdin...
[server] initialize <- cliente {'name': 'reservo-mcp-client', 'version': '1.0.0'}
[server] notifications/initialized <- el cliente confirma que ya puede operar
[server] tools/list -> 4 tools
[server] resources/list -> 2 resources
[server] prompts/list -> 1 prompts
[server] tools/call <- get_quote({'room': 'Focus', 'tier': 'pro', 'hours': 3})
---- stderr de sunroom_cafe_mcp_server.py ----
[cafe] arrancando, esperando mensajes por stdin...
[cafe] initialize <- cliente {'name': 'reservo-mcp-client', 'version': '1.0.0'}
[cafe] notifications/initialized <- el cliente confirma que ya puede operar
[cafe] tools/list -> 1 tools
[cafe] tools/call <- todays_specials()
Esta transcripción es la prueba central del módulo. Léela con atención a los id:
reservo:id1(initialize),2-4(los tres*/list),5(tools/call get_quote) — próximo libre:6.sunroom-cafe:id1(initialize),2(tools/list— el único*/listque le corresponde, porquediscover()respetó que no declaróresourcesniprompts),3(tools/call todays_specials) — próximo libre:4.
Los dos empiezan en id: 1. No es una coincidencia ni un error — es exactamente lo que garantiza self.request_ids = itertools.count(1) como atributo de instancia (lección 02): cada MCPClient, sin importar cuántos otros existan en el mismo programa, arranca su propio conteo desde 1. Compara esto con el Ejercicio 2 de la lección 06, donde un contador global compartido hacía que el segundo cliente arrancara en 2 — aquí, con el diseño correcto, eso nunca ocurre.
Y el registro combinado —registry, con dos claves— confirma lo que la lección 03 ya había mostrado por separado: Reservo trae sus tres primitivos completos; el café trae solo su única tool, con resources y prompts en [], sin que discover() haya intentado llamar ningún método que el café no declaró soportar.
Nota de producción: lo que hace un host real (Claude Code, Claude Desktop) con varios servidores
Un host de producción como Claude Code lanza, literalmente, un subproceso por cada servidor declarado en su configuración (el archivo .mcp.json, que vas a conocer en el Módulo 7) — el mismo patrón exacto de Host.connect_server() que acabas de ejecutar, solo que en un lenguaje distinto y con manejo de errores más sofisticado (reconexión automática, límites de tiempo, aislamiento de fallos — algo que el Ejercicio 3 de esta lección empieza a explorar). La idea central —un client por server, un registro combinado de lo que cada uno ofrece— es exactamente la misma. No hay ninguna capa "mágica" que un host real tenga y que esta guía te esté ocultando: la arquitectura que construiste a mano en Python puro es, en su forma, la misma que corre detrás de cualquier aplicación MCP real que hable con más de un servidor.
Errores comunes
-
Esperar que los dos servidores respondan en el orden en que fueron llamados, si sus llamadas se intercalaran. En esta lección, cada bloque de operaciones sobre un servidor se completa por entero (todo el
discover()dereservo, después todo eldiscover()desunroom-cafe) antes de pasar al siguiente — el código es secuencial, no concurrente. Si quisieras que las dos conexiones avanzaran en paralelo de verdad (por ejemplo, mientras uno espera una respuesta lenta, seguir con el otro), necesitaríasasyncioo hilos — fuera del alcance de esta guía, que usa E/S bloqueante a propósito. -
Pensar que
discover_all()mezcla los catálogos de los dos servidores en una sola lista. No lo hace — el resultado sigue siendo un diccionario{server_name: catalog}, con cada catálogo separado. Un host real que quisiera, por ejemplo, "todas las tools de todos los servidores conectados, en una sola lista para pasarle a un modelo" tendría que aplanar ese diccionario explícitamente — una decisión de diseño del Módulo 7, no de este. -
Confundir el
id: 5dereservocon elid: 3desunroom-cafeen la misma línea de tiempo, como si fueran comparables. No lo son — cada uno cuenta sus propios mensajes, sin relación con los del otro. Quereservoesté enid: 5mientrassunroom-cafeestá enid: 3no dice nada sobre cuál conversación "va más avanzada" en ningún sentido significativo; cada contador mide solo su propia conexión.
Ejercicios
Ejercicio 1: Cuenta los mensajes por servidor (Fácil)
De la transcripción completa de esta lección, ¿cuántos mensajes JSON-RPC con id viajaron por la conexión de reservo, y cuántos por la de sunroom-cafe? Confirma que cada conteo coincide con el último id visto más uno (el "próximo libre" que imprime el script).
Ver solución
reservo (5 mensajes con id): initialize (1), tools/list (2), resources/list (3), prompts/list (4), tools/call get_quote (5). Próximo libre: 6 — coincide con la salida siguiente id libre en 'reservo': 6.
sunroom-cafe (3 mensajes con id): initialize (1), tools/list (2), tools/call todays_specials (3). Próximo libre: 4 — coincide con siguiente id libre en 'sunroom-cafe': 4.
reservo tiene más mensajes porque discover() le llamó los tres */list (declaró las tres capabilities), mientras que a sunroom-cafe solo le llamó tools/list (la única que declaró) — la misma diferencia que ya confirmaste en la lección 04, ahora visible en dos conexiones simultáneas.
Ejercicio 2: Mide el tiempo de conectar cada servidor por separado (Medio)
Usando time.monotonic(), mide cuánto tarda connect_server("reservo", ...) y cuánto tarda connect_server("sunroom-cafe", ...), cada uno por separado. ¿Esperarías que uno tarde más que el otro, dado que Reservo tiene más lógica (cuatro tools, dos resources, un prompt) que el café (una sola tool)?
Ver solución
import time
from host import Host
host = Host()
start_reservo = time.monotonic()
host.connect_server("reservo", "reservo_full_mcp_server.py")
reservo_ms = (time.monotonic() - start_reservo) * 1000
start_cafe = time.monotonic()
host.connect_server("sunroom-cafe", "sunroom_cafe_mcp_server.py")
cafe_ms = (time.monotonic() - start_cafe) * 1000
print(f"conectar 'reservo' tomo {reservo_ms:.1f} ms")
print(f"conectar 'sunroom-cafe' tomo {cafe_ms:.1f} ms")
host.close_all()
Salida esperada (los valores exactos varían según la máquina, pero suelen quedar muy cerca entre sí):
conectar 'reservo' tomo 22.0 ms
conectar 'sunroom-cafe' tomo 22.0 ms
Explicación: los dos tiempos son casi idénticos, a pesar de que Reservo tiene mucha más lógica de negocio que el café. La razón es la misma que ya viste en el Módulo 2, Ejercicio 2 de la lección 07: el costo dominante de connect_server() es arrancar un intérprete de Python nuevo (subprocess.Popen), no procesar el handshake en sí — y ese costo de arranque es prácticamente el mismo sin importar cuánto código tenga el servidor detrás. La cantidad de tools, resources o prompts que un servidor declare no afecta de forma perceptible el tiempo del handshake — sí afectaría, un poco, el tiempo de tools/list si el catálogo fuera enorme (como viste en el Ejercicio 3 del Módulo 3, lección 07), pero no el de initialize en sí.
Ejercicio 3: El café se cae a mitad de una llamada — el host no debería caerse con él (Difícil)
Escribe una versión deliberadamente rota del servidor del café (sunroom_cafe_mcp_server_broken.py) que haga sys.exit(1) apenas recibe un tools/call, sin responder nada. Conecta el Host a reservo y a esta versión rota del café; intenta llamar todays_specials en el café (va a fallar), captura el error sin que el programa completo se caiga, y confirma que reservo sigue respondiendo con normalidad después.
Ver solución
# sunroom_cafe_mcp_server_broken.py -- version rota, solo para este ejercicio
import sys
import json
PROTOCOL_VERSION = "2025-06-18"
MCP_TOOLS = [{"name": "todays_specials", "description": "...",
"inputSchema": {"type": "object", "properties": {}}}]
for raw_line in sys.stdin:
message = json.loads(raw_line.strip())
method = message.get("method")
msg_id = message.get("id")
if method == "initialize":
response = {"jsonrpc": "2.0", "id": msg_id, "result": {
"protocolVersion": PROTOCOL_VERSION, "capabilities": {"tools": {}},
"serverInfo": {"name": "sunroom-cafe-mcp-server", "version": "1.0.0"}}}
sys.stdout.write(json.dumps(response) + "\n"); sys.stdout.flush()
elif method == "notifications/initialized":
continue
elif method == "tools/list":
response = {"jsonrpc": "2.0", "id": msg_id, "result": {"tools": MCP_TOOLS}}
sys.stdout.write(json.dumps(response) + "\n"); sys.stdout.flush()
elif method == "tools/call":
sys.exit(1) # <- el proceso muere SIN escribir ninguna response
import json
from host import Host
host = Host()
host.connect_server("reservo", "reservo_full_mcp_server.py")
host.connect_server("sunroom-cafe", "sunroom_cafe_mcp_server_broken.py")
try:
host.call_tool_on("sunroom-cafe", "todays_specials", {})
except json.JSONDecodeError as exc:
print(f"[host] 'sunroom-cafe' fallo al responder (proceso caido): {exc}")
print("[host] marcando 'sunroom-cafe' como no disponible, sin tocar 'reservo'")
del host.clients["sunroom-cafe"]
# 'reservo' sigue vivo y respondiendo, sin importar lo que le paso al cafe
quote = host.call_tool_on("reservo", "get_quote", {"room": "Focus", "tier": "pro", "hours": 3})
print(f"[host] 'reservo' sigue operando -> get_quote: {quote['content'][0]['text']}")
host.close_all()
Salida real:
[host] 'sunroom-cafe' fallo al responder (proceso caido): Expecting value: line 1 column 1 (char 0)
[host] marcando 'sunroom-cafe' como no disponible, sin tocar 'reservo'
[host] 'reservo' sigue operando -> get_quote: {"price_cents": 6000}
Explicación: cuando el proceso del café muere sin escribir nada, proc.stdout.readline() del lado del cliente devuelve una cadena vacía ("") — el pipe se cerró sin más datos — y json.loads("") lanza json.JSONDecodeError, exactamente el error que se captura en el except. Que reservo siga funcionando después, sin ningún cambio de comportamiento, es la consecuencia directa de que cada MCPClient posee su propio proceso y su propio par de pipes: la muerte del proceso del café no tiene ningún canal por el cual afectar al proceso de Reservo — son dos subprocesos del sistema operativo, sin ninguna relación entre sí más allá de que el mismo programa Python los lanzó. Este es, en miniatura, el mismo principio que justifica por qué agent-security-and-sandboxing-guide (fuera del alcance de esta guía) trata a cada servidor MCP de terceros como un proceso que puede fallar o comportarse mal, sin que ese fallo comprometa al resto del sistema.
Resumen y siguiente paso
- Un
Hostcon dos servidores conectados —reservoysunroom-cafe— corrió dos subprocesos reales al mismo tiempo, cada uno con su propio handshake, su propio conteo deid(ambos arrancando en1, sin colisión), y su propio catálogo descubierto. - El registro combinado de
discover_all()refleja fielmente lo que cada servidor declaró: tres primitivos completos para Reservo, solotoolspara el café — sin llamar ningún método que un servidor no anunció soportar. - Agregar el segundo servidor no requirió tocar ni una línea de la clase
Host— la misma propiedad aditiva que sostiene la arquitectura de MCP desde el Módulo 1. - El fallo de un servidor (Ejercicio 3) queda contenido en su propia conexión — otro servidor conectado al mismo
Hostsigue operando con total normalidad, porque cada uno vive en su propio proceso del sistema operativo.
Vale la pena cerrar con la frontera exacta de lo que acabas de construir: esto es una topología de integración — un Host hablando con dos servers MCP pasivos, cada uno exponiendo herramientas y datos sin ninguna capacidad de decisión propia. No es orquestación multi-agente: ninguno de los dos servidores "razona", ni delega tareas, ni colabora con el otro para lograr un objetivo compartido — cada uno solo responde a lo que el Host le pide, sin saber siquiera que el otro existe. Coordinar varios agentes (procesos que sí mantienen su propio ciclo de razonamiento y pueden coordinarse entre sí) es el tema de multi-agent-orchestration-guide, fuera del alcance de esta guía.
Siguiente lección: 08 — Mini-proyecto: un cliente MCP para Reservo. Reconstruyes, con menos andamiaje, un cliente de descubrimiento genérico — y confirmas que el mismo código funciona igual de bien contra Reservo que contra el café, sin cambiar una sola línea entre uno y otro.
Recursos adicionales
- Model Context Protocol — Specification 2025-06-18: Architecture — La arquitectura host/client/server con múltiples servidores, ejecutada de punta a punta en esta lección.
- Model Context Protocol — Specification 2025-06-18: Lifecycle — Dos handshakes independientes, uno por conexión, corriendo en la misma transcripción.
- Python —
subprocess— La base de cómo cada conexión posee su propio proceso, aislado del resto. - Python — Excepciones (
json.JSONDecodeError) — La excepción que el Ejercicio 3 captura cuando un servidor muere sin responder.