English · Español
Lab 02 — Cliente MCP y round-trip¶
Objetivo: escribir
src/miniagent/mcp_client.pyque 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.pyexiste 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, escriberesults.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 enPHASE_31_REPORT.md.
TODOs¶
Bloque A — esqueleto de la clase MCPClient¶
En src/miniagent/mcp_client.py:
- Reutiliza
read_message/write_messagedemcp_server.py(mueve ambos aminiagent/_framing.pypara 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:
Bloque B — ciclo de vida del subproceso¶
-
__enter__:self.proc = subprocess.Popen(self.server_cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE, bufsize=0).bufsize=0importa — 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.stderrlínea a línea y las prefije con[server]antes de reenviarlas alogging. 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}(sinid). - Si no,
id = self._next_id; self._next_id += 1; construye con campoid. write_message(self.proc.stdin, msg).- Si
notify, devuelveNone. - Si no, lee una respuesta con
read_message(self.proc.stdout). Asertaresp["id"] == id; en mismatch, lanzaProtocolError. - 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
initializecon nuestroclientInfoy lascapabilitiessoportadas (anunciamos{}por ahora — la Fase 32 podría añadirsampling). - 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
inputSchemade tool (renombra ainput_schemainternamente) enself._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](lanzaKeyErrorsilist_toolsno 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/callcon{"name": name, "arguments": arguments}. - Parsea la respuesta:
- Si
result.isError == True→ lanzaToolError(result.content[0].text). - Si
result.isError == False→ devuelve el valor desenvuelto: - Si la clave
"error"está en la respuesta (error a nivel JSON-RPC) → lanzaProtocolError(error["message"], error["code"]).
- Si
- No mezcles
ToolErroryProtocolErroren 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_messageyread_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: - Escribe
results.json:{"tools_discovered": 4, "calls_made": 4, "calls_failed": 0, "transcript_path": "transcript.txt"}. - Escribe
manifest.jsonsegú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_error—c.call_tool("conjugate", verb="run", tense="past_simple", person="3sg")lanzaToolError(noProtocolError, 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 unProtocolErroren su lugar. -
test_unknown_tool_raises_protocol_error—c.call_tool("not_a_tool")lanzaProtocolError. -
test_client_cleans_up_on_exception— el subproceso sale incluso si el bloquewithlanza.
Restricciones¶
- Solo
subprocessde stdlib. Sinpexpect, sinsubprocess32. - 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:
- Todos los tests del Bloque H pasan.
demo.pyescribe untranscript.txtque contiene un handshake completo +tools/list+ 4 round-trips detools/call.- El proceso del servidor sale con código 0 cuando el bloque
with MCPClient(...)sale limpiamente. pytest -q tests/test_mcp_roundtrip.pyno muestra subprocesos persistentes (usapsutilo simplementeps aux | grep miniagenttras 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 elpythonque esté en PATH, que puede no ser el intérprete gestionado poruvdel 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 tienesrc/ensys.path, el subproceso falla conModuleNotFoundError: miniagent. El fix:env={**os.environ, "PYTHONPATH": str(Path(__file__).parent.parent / "src")}enPopen, o confía en queuv runlo configure. json.loadssobre un string no-JSON.lookup_spanishdevuelve"comió". La forma del wire envuelve eso encontent[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.