Módulo 6: Construir un cliente MCP y descubrimiento

Qué hace un cliente

Descripción

En los Módulos 2 a 5, el cliente fue siempre el mismo tipo de script: un subprocess.Popen, un puñado de send/recv, y un main() que corría una secuencia fija de mensajes para demostrar el servidor de ese módulo. Funcionó, pero era desechable — cada lección lo reescribía desde cero. Esta lección da el primer paso hacia algo distinto: una clase MCPClient, con una responsabilidad bien definida, que vas a reutilizar sin cambios durante el resto de la guía. Antes de escribir su primera línea, esta lección responde una pregunta que parece obvia pero no lo es del todo: ¿qué le toca hacer a un cliente, exactamente, y qué NO le toca?

Conexión con el módulo

Esta lección es puramente de diseño — todavía no hay capabilities que leer (lección 03) ni descubrimiento que ejecutar (lección 04). Es el momento de fijar el contrato de la clase antes de llenarla de métodos, para que las lecciones siguientes solo tengan que agregar comportamiento, no repensar la forma.


Las tres responsabilidades de un cliente

Repasa la arquitectura host/client/server del Módulo 1: el host es la aplicación completa (Claude Code, un agente propio); el server es el proceso que expone tools/resources/prompts; el client es la pieza que vive dentro del host y mantiene, en exclusiva, la conversación con un server. Concretamente, a un cliente MCP le toca:

  1. Poseer la conexión. Lanzar el subproceso del servidor (subprocess.Popen), quedarse con sus pipes (stdin, stdout, stderr), y ser el único punto del programa que escribe o lee de ellos. Nadie más en el host debería tocar esos pipes directamente.
  2. Hacer el handshake y recordar lo que negoció. Ejecutar initialize/notifications/initialized (Módulo 2) y guardar el resultado — capabilities, serverInfo — para consultarlo después, en vez de volver a preguntarlo en cada llamada.
  3. Exponer operaciones limpias sobre los primitivos. En vez de que el resto del código arme JSON-RPC a mano cada vez que quiere cotizar una sala, el cliente ofrece un método (call_tool("get_quote", {...})) que esconde el id, el method, la forma exacta del mensaje.

Y lo que NO le toca a un cliente:

  • Decidir qué tool llamar o qué prompt activar. Esa decisión es del host (típicamente, guiada por un modelo o por el usuario) — el cliente solo ejecuta el pedido una vez que alguien más lo tomó. Confundir esto es el error conceptual más común de este módulo: un cliente MCP no "piensa", transporta.
  • Hablar con más de un servidor. Un MCPClient es, por diseño, una conexión a un servidor. Si el host necesita hablar con dos, instancia dos clients — la lección 06 desarrolla esto a fondo.
  • Ejecutar la lógica de negocio de las tools. get_quote, book_room — esa lógica vive del lado del servidor (M3). El cliente nunca calcula un precio; solo pide que el servidor lo calcule y lee la respuesta.

El esqueleto de MCPClient

Con esas tres responsabilidades claras, el esqueleto de la clase —solo __init__ y connect(), sin descubrimiento todavía— queda así:

# mcp_client.py (version parcial de esta leccion -- se completa en 03-05)
"""Cliente MCP reutilizable: UNA instancia = UNA conexion a UN server, por stdio real
(subprocess.Popen + pipes). Guarda las capabilities negociadas en el handshake para
decidir, despues, que metodos tiene sentido llamar."""
import subprocess
import sys
import json
import itertools

PROTOCOL_VERSION = "2025-06-18"
CLIENT_INFO = {"name": "reservo-mcp-client", "version": "1.0.0"}


class MCPClient:
    def __init__(self, server_path: str, verbose: bool = True):
        self.server_path = server_path
        self.verbose = verbose
        self.request_ids = itertools.count(1)   # <- un contador POR INSTANCIA, no global
        self.capabilities = {}
        self.server_info = {}
        self.proc = subprocess.Popen(
            [sys.executable, server_path],
            stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
            text=True, bufsize=1,
        )

    def _send(self, message: dict) -> None:
        line = json.dumps(message)
        if self.verbose:
            print(f"[client -> {self.server_path}] {line}")
        self.proc.stdin.write(line + "\n")
        self.proc.stdin.flush()

    def _recv(self) -> dict:
        line = self.proc.stdout.readline().strip()
        if self.verbose:
            print(f"[{self.server_path} -> client] {line}")
        return json.loads(line)

    def connect(self) -> dict:
        """Handshake completo (M2): initialize -> notifications/initialized."""
        self._send({"jsonrpc": "2.0", "id": next(self.request_ids), "method": "initialize",
                     "params": {"protocolVersion": PROTOCOL_VERSION, "capabilities": {},
                                "clientInfo": CLIENT_INFO}})
        response = self._recv()
        self.capabilities = response["result"]["capabilities"]
        self.server_info = response["result"]["serverInfo"]
        self._send({"jsonrpc": "2.0", "method": "notifications/initialized"})
        return response["result"]

Nota tres decisiones de diseño, cada una directamente de las tres responsabilidades de arriba:

  • self.proc vive en __init__. El subproceso arranca en el momento en que se crea el objeto, no cuando se llama connect(). Esto refleja que "poseer la conexión" empieza desde que el cliente existe.
  • self.request_ids es un atributo de instancia (self.), no una variable global del módulo. Cada MCPClient cuenta sus propios id desde 1, sin relación con ningún otro cliente que exista en el mismo programa — el Ejercicio 3 (M2, mini-proyecto) ya demostró esto con procesos separados; aquí se vuelve una propiedad de la clase.
  • connect() GUARDA capabilities y serverInfo en self, no solo los imprime y los descarta. Ese guardado es lo que hace posible la lección 03: un método supports(primitivo) que consulta self.capabilities sin volver a preguntarle nada al servidor.

Los métodos _send/_recv llevan guion bajo al inicio (_send, no send) — una convención de Python para "esto es interno a la clase, no forma parte de la interfaz que el resto del programa debería usar directamente". El código que use MCPClient va a llamar connect(), y más adelante call_tool()/read_resource()/get_prompt() — nunca _send/_recv a mano, salvo que esté depurando el cliente mismo.


Qué esperar: conectar y leer lo que el servidor negoció

Con solo este esqueleto —sin descubrimiento todavía— ya se puede confirmar que el handshake quedó guardado en el objeto, no solo impreso en pantalla:

from mcp_client import MCPClient

client = MCPClient("reservo_full_mcp_server.py", verbose=False)
result = client.connect()
print("server_info:", result["serverInfo"])
print("capabilities:", result["capabilities"])
print("protocolVersion negociado:", result["protocolVersion"])
client.close()

Corriendo python3.14 este_script.py:

server_info: {'name': 'reservo-mcp-server', 'version': '1.0.0'}
capabilities: {'tools': {}, 'resources': {}, 'prompts': {}}
protocolVersion negociado: 2025-06-18

(client.close() todavía no existe en el esqueleto de esta lección — se agrega junto con el resto de los métodos en la lección 04. Por ahora, para probar este fragmento tal cual, cierra el subproceso a mano con client.proc.stdin.close(); client.proc.wait().)

Tres líneas, y las tres vienen de self.server_info/self.capabilities, no de una variable local que se pierde al salir de la función — esa persistencia es exactamente lo que la lección 03 necesita para poder preguntar, más tarde en el programa, "¿este servidor soporta resources?" sin tener que rehacer el handshake.


Nota de producción: MCPClient frente al ClientSession del SDK oficial

En el SDK oficial mcp (PyPI; pip install "mcp<2" para la línea legacy que enseña esta guía), la clase que cumple este mismo rol se llama ClientSession — internamente hace exactamente lo mismo que MCPClient.connect(): manda initialize, guarda la respuesta, manda notifications/initialized. La diferencia principal es que el SDK usa asyncio (métodos async def, await session.initialize()) para poder manejar varias operaciones concurrentes sin bloquear el programa completo en cada readline() — algo fuera del alcance de esta guía, que usa E/S síncrona y bloqueante a propósito, para que cada paso del protocolo sea visible en el orden exacto en que ocurre. El contrato que expone —conectar, guardar capabilities, ofrecer métodos por primitivo— es el mismo que estás construyendo aquí a mano.


Errores comunes

  1. Guardar capabilities como variable local en vez de atributo de la instancia. Si connect() hiciera capabilities = response["result"]["capabilities"] sin el self., ese valor desaparecería en cuanto la función terminara — cualquier código que llamara client.capabilities después fallaría con AttributeError, o peor, encontraría el diccionario vacío del __init__.

  2. Confundir _send/_recv (privados, de bajo nivel) con la interfaz pública que se va a construir en las próximas lecciones. Llamar client._send(...) a mano desde fuera de la clase funciona (Python no impone privacidad real), pero rompe la idea central de esta lección: que el resto del programa debería hablarle al cliente en términos de "llamar una tool", no en términos de "escribir esta línea de JSON en este pipe".

  3. Pensar que el cliente decide QUÉ tool llamar. Es el error conceptual más común de esta lección: MCPClient no tiene ninguna lógica de "si el usuario quiere reservar, llamo book_room" — esa decisión vive fuera de la clase, en el host (y, en una conversación real, en el modelo). El cliente solo ejecuta el pedido que alguien más ya tomó.


Ejercicios

Ejercicio 1: Las tres responsabilidades, en tus palabras (Fácil)

Sin mirar el código de esta lección, escribe de memoria las tres responsabilidades de un cliente MCP y las tres cosas que NO le corresponden. Después compara tu lista con la de la sección "Las tres responsabilidades de un cliente".

Ver solución

Le corresponde:

  1. Poseer la conexión (lanzar el subproceso, quedarse con sus pipes).
  2. Hacer el handshake y recordar lo que negoció (capabilities, serverInfo).
  3. Exponer operaciones limpias sobre los primitivos (métodos, no JSON a mano).

NO le corresponde:

  1. Decidir qué tool/resource/prompt usar (eso es del host, guiado por el modelo o el usuario).
  2. Hablar con más de un servidor (una instancia = una conexión).
  3. Ejecutar la lógica de negocio de las tools (eso vive en el servidor).

Si tu lista coincide en el fondo aunque no en las palabras exactas, vas bien — lo importante es la separación entre "transportar un pedido" (cliente) y "decidir el pedido" (host) o "ejecutarlo" (servidor).

Ejercicio 2: Confirma que capabilities persiste después de connect() (Medio)

Extiende el MCPClient de esta lección con un método summary() que devuelva un string de una línea con el nombre del servidor y cuántas categorías de capabilities declaró (sin volver a llamar connect() ni tocar el subproceso). Pruébalo llamándolo justo después de connect().

Ver solución
class MCPClient:
    # ... __init__, _send, _recv, connect igual que en la leccion ...

    def summary(self) -> str:
        name = self.server_info.get("name", "?")
        count = len(self.capabilities)
        return f"{name}: {count} categorias de capabilities declaradas"


client = MCPClient("reservo_full_mcp_server.py", verbose=False)
client.connect()
print(client.summary())

Salida esperada:

reservo-mcp-server: 3 categorias de capabilities declaradas

Explicación: summary() no manda ningún mensaje nuevo al servidor — solo lee self.server_info y self.capabilities, los atributos que connect() ya llenó. Esto confirma en código la idea central de la lección: una vez que el handshake terminó, el cliente puede responder preguntas sobre el servidor sin necesidad de volver a preguntarle nada por el pipe.

Ejercicio 3: Dos instancias, dos procesos, sin excepción (Difícil)

Crea dos instancias de MCPClient apuntando al mismo server_path (reservo_full_mcp_server.py), conéctalas a las dos, y confirma que cada una lanzó su propio subproceso del sistema operativo (compara client_a.proc.pid contra client_b.proc.pid). Después, reserva una sala distinta desde cada una (book_room) y confirma que ambas reciben booking_id: 1 — sin que una reserva "vea" el estado de la otra.

Ver solución
from mcp_client import MCPClient

client_a = MCPClient("reservo_full_mcp_server.py", verbose=False)
client_b = MCPClient("reservo_full_mcp_server.py", verbose=False)
client_a.connect()
client_b.connect()

print(f"PID de client_a.proc: {client_a.proc.pid}")
print(f"PID de client_b.proc: {client_b.proc.pid}")
print(f"son procesos distintos: {client_a.proc.pid != client_b.proc.pid}")

booking_a = client_a.call_tool("book_room", {"room": "Focus", "tier": "basic", "hours": 1, "member": "Ana"})
booking_b = client_b.call_tool("book_room", {"room": "Boardroom", "tier": "basic", "hours": 1, "member": "Luis"})
print(f"client_a book_room -> {booking_a['content'][0]['text']}")
print(f"client_b book_room -> {booking_b['content'][0]['text']}")

(call_tool todavía no existe en el esqueleto de esta lección — este ejercicio anticipa la versión completa de MCPClient que vas a tener después de la lección 04; si lo corres antes, usa _send/_recv con el mensaje tools/call armado a mano.)

Salida esperada (los PID varían en cada corrida, siempre distintos entre sí):

PID de client_a.proc: 12341
PID de client_b.proc: 12342
son procesos distintos: True
client_a book_room -> {"booking_id": 1, "confirmed": true}
client_b book_room -> {"booking_id": 1, "confirmed": true}

Explicación: aunque las dos instancias apuntan al mismo archivo de servidor, subprocess.Popen lanza un proceso del sistema operativo nuevo cada vez que se llama — dos PID distintos, dos espacios de memoria completamente separados. Por eso las dos reservas obtienen booking_id: 1: cada proceso servidor tiene su propio diccionario BOOKINGS en memoria, empezando vacío, y su propio itertools.count(1) para generar IDs. Esta es la misma propiedad de aislamiento que ya viste en los mini-proyectos de los Módulos 2 y 3, ahora confirmada desde el lado de la clase MCPClient — y es la base sobre la que se construye toda la lección 06 (un client por server, sin excepción, ni siquiera cuando el "server" es el mismo código ejecutado dos veces).


Resumen y siguiente paso

  • Un cliente MCP tiene tres responsabilidades: poseer la conexión, hacer el handshake y recordar lo que negoció, y exponer operaciones limpias sobre los primitivos — y tres cosas que explícitamente NO le corresponden: decidir qué usar, hablar con más de un servidor, o ejecutar lógica de negocio.
  • El esqueleto de MCPClient de esta lección —__init__ + connect()— ya demuestra la decisión de diseño central: capabilities y serverInfo se guardan como atributos de instancia, no se descartan después de imprimirlos.
  • self.request_ids como atributo de instancia (no variable global del módulo) es lo que garantiza que cada conexión cuenta sus propios id desde 1 — una propiedad que vas a necesitar, sin cambios, en la lección 07.
  • Dos instancias del mismo cliente, apuntando al mismo servidor, lanzan dos procesos completamente aislados — la base sobre la que se construye toda la arquitectura de múltiples servidores del resto del módulo.

Siguiente lección: 03 — Leer las capabilities del servidor. Con connect() guardando self.capabilities, el siguiente paso lógico es usarlas: un método supports() que decide, en código, qué tiene sentido llamar contra cada servidor — y qué pasa cuando ese chequeo se salta.


Recursos adicionales

  1. Model Context Protocol — Specification 2025-06-18: Lifecycle — El handshake que connect() ejecuta, ya revisado a fondo en el Módulo 2.
  2. Model Context Protocol — Specification 2025-06-18: Architecture — El rol del client dentro de host/client/server, la base conceptual de esta lección.
  3. Python — subprocessPopen, la base de cómo el cliente posee su conexión.
  4. Python — Clasesself, atributos de instancia, la diferencia entre variable local y atributo que el Ejercicio 2 pone a prueba.