Skip to content

English · Español

Lab 02 — Cliente MCP y round-trip

Objetivo: escribir src/miniagent/mcp_client.py que lanza el servidor del Lab 01 como subproceso, realiza el handshake completo, lista tools, llama a cada una, y devuelve valores Python. Luego ejercita el bucle en un experimento que captura el transcript del wire.

Tiempo estimado: 2–3 horas.

Prereq: Lab 01 hecho — mcp_server.py existe y hace round-trip de un byte stream construido a mano.


Qué produces

  • src/miniagent/mcp_client.py — la librería cliente.
  • experiments/31-mcp-roundtrip/demo.py — usa el cliente, imprime un transcript, escribe results.json.
  • experiments/31-mcp-roundtrip/transcript.txt — los mensajes JSON-RPC exactos intercambiados, uno por línea, prefijados con > (cliente→servidor) o < (servidor→cliente). Este es el artefacto que se pega en PHASE_31_REPORT.md.

TODOs

Bloque A — esqueleto de la clase MCPClient

En src/miniagent/mcp_client.py:

  • Reutiliza read_message / write_message de mcp_server.py (mueve ambos a miniagent/_framing.py para compartirlos — son idénticos).
  • Define:
    class MCPClient:
        def __init__(self, server_cmd: list[str]):
            self.server_cmd = server_cmd
            self.proc: subprocess.Popen | None = None
            self._next_id = 0
            self._tool_schemas: dict[str, dict] = {}
        def __enter__(self) -> "MCPClient": ...
        def __exit__(self, *exc) -> None: ...
        def initialize(self) -> dict: ...
        def list_tools(self) -> list[dict]: ...
        def call_tool(self, name: str, **arguments) -> Any: ...
        def _send(self, method: str, params: dict, *, notify=False) -> dict | None: ...
    
  • Úsalo como context manager para que el subproceso siempre se limpie:
    with MCPClient(["python", "-m", "miniagent.mcp_server"]) as c:
        c.initialize()
        tools = c.list_tools()
        result = c.call_tool("conjugate", verb="eat", tense="past_simple", person="3sg")
    

Bloque B — ciclo de vida del subproceso

  • __enter__: self.proc = subprocess.Popen(self.server_cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE, bufsize=0). bufsize=0 importa — stderr bufferizado puede tragarse logs que necesitas.
  • __exit__:
  • Cierra stdin para señalar EOF al servidor.
  • self.proc.wait(timeout=5.0); en timeout, self.proc.kill() y loguea.
  • Si el servidor salió con código no-cero, vuelca el contenido de stderr al stderr del cliente para depuración.
  • Hace aflorar las líneas de stderr en tiempo real: lanza un pequeño hilo daemon que lea self.proc.stderr línea a línea y las prefije con [server] antes de reenviarlas a logging. Sin esto, los crashes del servidor son invisibles.

Bloque C — correlación request/response

  • _send(method, params, notify=False):
  • Si notify, construye {"jsonrpc": "2.0", "method": method, "params": params} (sin id).
  • Si no, id = self._next_id; self._next_id += 1; construye con campo id.
  • write_message(self.proc.stdin, msg).
  • Si notify, devuelve None.
  • Si no, lee una respuesta con read_message(self.proc.stdout). Aserta resp["id"] == id; en mismatch, lanza ProtocolError.
  • Sin peticiones concurrentes. El cliente de la Fase 31 es estrictamente request → wait → response. El multiplexado es preocupación de la Fase 33.

Bloque D — initialize

  • initialize():
  • Envía initialize con nuestro clientInfo y las capabilities soportadas (anunciamos {} por ahora — la Fase 32 podría añadir sampling).
  • Recibe las capabilities del servidor; almacénalas en self.
  • Envía notifications/initialized (notification, sin respuesta).
  • Si el servidor devuelve capabilities que no incluyen "tools", lanza — cualquier tool call fallaría igualmente.

Bloque E — list_tools y caching de schema de tools

  • list_tools():
  • Envía tools/list.
  • Parsea la respuesta. Almacena cada inputSchema de tool (renombra a input_schema internamente) en self._tool_schemas[name].
  • Devuelve la lista cruda (el agente en la Fase 32 la mostrará).

Bloque F — call_tool

  • call_tool(name, **arguments):
  • Busca schema = self._tool_schemas[name] (lanza KeyError si list_tools no ha sido llamado todavía).
  • Validación del lado cliente. jsonschema.Draft7Validator(schema).validate(arguments) antes de enviar. Esto pilla errores de argumento localmente; el servidor también los pillaría, pero un fallo local es más rápido y da mejor stack trace.
  • Envía tools/call con {"name": name, "arguments": arguments}.
  • Parsea la respuesta:
    • Si result.isError == True → lanza ToolError(result.content[0].text).
    • Si result.isError == False → devuelve el valor desenvuelto:
      raw = result["content"][0]["text"]
      try:
          return json.loads(raw)   # dict-returning tools
      except json.JSONDecodeError:
          return raw               # string-returning tools
      
    • Si la clave "error" está en la respuesta (error a nivel JSON-RPC) → lanza ProtocolError(error["message"], error["code"]).
  • No mezcles ToolError y ProtocolError en el cliente. Significan cosas diferentes para el llamador (recuperable vs no recuperable). El agente de la Fase 32 razona diferente sobre cada uno.

Bloque G — captura del transcript

En experiments/31-mcp-roundtrip/demo.py:

  • Monkey-patch write_message y read_message (o usa un hook en _framing.py) para que cada mensaje también se append al fichero del transcript, prefijado con dirección.
  • Corre el flujo canónico:
    with MCPClient(["python", "-m", "miniagent.mcp_server"]) as c:
        c.initialize()
        tools = c.list_tools()
        assert len(tools) == 4
        print(c.call_tool("conjugate", verb="eat", tense="past_simple", person="3sg"))  # → "ate"
        print(c.call_tool("lookup_spanish", english_form="ate"))                          # → "comió"
        print(c.call_tool("lookup_irregular_verb", verb="go"))                            # → {...}
        print(c.call_tool("check_subject_verb_agreement", subject="he", verb_form="go"))  # → {...}
    
  • Captura el transcript a transcript.txt. Formato:
    > initialize {"protocolVersion": "2024-11-05", ...}
    < {"protocolVersion": "2024-11-05", "capabilities": ..., ...}
    > notifications/initialized {}
    > tools/list {}
    < {"tools": [...]}
    ...
    
  • Escribe results.json: {"tools_discovered": 4, "calls_made": 4, "calls_failed": 0, "transcript_path": "transcript.txt"}.
  • Escribe manifest.json según §5.

Bloque H — tests

En tests/test_mcp_roundtrip.py:

  • test_initialize_returns_tools_capability.
  • test_list_tools_returns_four — el servidor expone exactamente las cuatro tools §A13.
  • test_call_conjugate_returns_ate.
  • test_call_with_invalid_args_raises_protocol_errorc.call_tool("conjugate", verb="run", tense="past_simple", person="3sg") lanza ToolError (no ProtocolError, ya que "run" es un string válido que falla la propia comprobación de alcance de la tool, no el schema). NOTA: esto requiere que el enum del input_schema incluya "run" — confirma en el Lab 00 que el enum es exactamente los verbos §A13; strings fuera del enum serían un ProtocolError en su lugar.
  • test_unknown_tool_raises_protocol_errorc.call_tool("not_a_tool") lanza ProtocolError.
  • test_client_cleans_up_on_exception — el subproceso sale incluso si el bloque with lanza.

Restricciones

  • Solo subprocess de stdlib. Sin pexpect, sin subprocess32.
  • Sin red. Solo stdio. (La Fase 33 es HTTP; no aquí.)
  • Síncrono. Sin asyncio.
  • Una petición en vuelo a la vez. Sin pipelining.

Condiciones de parada

Hecho cuando:

  1. Todos los tests del Bloque H pasan.
  2. demo.py escribe un transcript.txt que contiene un handshake completo + tools/list + 4 round-trips de tools/call.
  3. El proceso del servidor sale con código 0 cuando el bloque with MCPClient(...) sale limpiamente.
  4. pytest -q tests/test_mcp_roundtrip.py no muestra subprocesos persistentes (usa psutil o simplemente ps aux | grep miniagent tras la corrida para verificar).

Pitfalls

  • Deadlock por buffers de pipe llenos. Si el servidor escribe mucho a stderr y el cliente no lo drena, el buffer de pipe del OS se llena y el servidor bloquea en la escritura, lo que significa que deja de leer stdin, lo que significa que el cliente bloquea en su próxima escritura. El hilo que drena stderr del Bloque B previene esto.
  • Carrera entre EOF y respuesta final. Al cerrar el cliente, envía cualquier petición final → lee su respuesta → DESPUÉS cierra stdin. Cerrar stdin primero carrera con el servidor intentando responder todavía.
  • Subproceso heredando el Python equivocado. subprocess.Popen(["python", ...]) corre el python que esté en PATH, que puede no ser el intérprete gestionado por uv del proyecto. Usa [sys.executable, "-m", "miniagent.mcp_server"] para garantizar alineación de versión.
  • Resolución de módulos / PYTHONPATH. Si corres el test desde un directorio que no tiene src/ en sys.path, el subproceso falla con ModuleNotFoundError: miniagent. El fix: env={**os.environ, "PYTHONPATH": str(Path(__file__).parent.parent / "src")} en Popen, o confía en que uv run lo configure.
  • json.loads sobre un string no-JSON. lookup_spanish devuelve "comió". La forma del wire envuelve eso en content[0].text == "comió". json.loads("comió") lanza — la ruta de fallback de string desnudo es requerida.

Cuándo consultar solutions/

Tras que los tests estén en verde. La solución en solutions/02-mcp-roundtrip-ref.md proporciona un transcript.txt de referencia para comparar byte a byte (módulo whitespace) contra el tuyo.


Siguiente lab: lab/03-mask-driven-toolcall.md — conectar el JSONSchemaMask de la Fase 30 al generador de argumentos de tool call. End-to-end.