Skip to content

English · Español

Lab 01 — Servidor MCP hecho a mano (stdio + JSON-RPC 2.0)

Objetivo: levantar src/miniagent/mcp_server.py — un servidor MCP (Model Context Protocol) síncrono hablando JSON-RPC 2.0 sobre stdio. Expone las cuatro tools §A13 registradas en el Lab 00.

Tiempo estimado: 3–4 horas. La mayor parte es framing (el baile de la cabecera Content-Length), no lógica de aplicación.

Prereq: Lab 00 hecho — registered_tools poblado con las cuatro instancias Tool.


Qué produces

Un único módulo Python ejecutable: src/miniagent/mcp_server.py.

Ejecútalo con python -m miniagent.mcp_server y debería:

  1. Bloquear en sys.stdin esperando un frame Content-Length: N\r\n\r\n<json>.
  2. Parsear el mensaje JSON-RPC.
  3. Despachar sobre method.
  4. Escribir la respuesta de vuelta a sys.stdout (también con framing Content-Length) y flush().
  5. Loguear diagnósticos a sys.stderr (nunca a stdout — stdout está reservado para el protocolo).
  6. Salir limpiamente cuando stdin cierra (EOF).

Más un pequeño smoke test de CLI: experiments/31-mcp-server-smoke/manual.txt que contiene el byte stream de una sesión manual que metiste por pipe al servidor. (Usa printf + cat para conducirlo — el Lab 02 usará un cliente real.)

TODOs

Bloque A — I/O de frames

En src/miniagent/mcp_server.py, antes de cualquier lógica de protocolo:

  • read_message(stream) -> dict | None:
  • Lee bytes de stream.buffer (la vista binaria; sys.stdin.buffer) hasta consumir un terminador de cabecera \r\n\r\n.
  • Parsea Content-Length: N de las cabeceras; ignora otras cabeceras.
  • Lee exactamente N bytes de cuerpo.
  • Decodifica UTF-8; json.loads.
  • Devuelve None en EOF.
  • write_message(stream, msg: dict) -> None:
  • body = json.dumps(msg).encode("utf-8").
  • Escribe f"Content-Length: {len(body)}\r\n\r\n".encode() luego body.
  • stream.buffer.flush().
  • Testea unitariamente estos dos en aislamiento: round-trip de un mensaje de muestra a través de un par BytesIO.

Pitfall: sys.stdin.read() no te da binario; debes usar sys.stdin.buffer.read(n). La I/O en modo texto puede traducir CRLF en Windows; el modo binario lo previene.

Bloque B — manejo del envoltorio JSON-RPC

  • error_response(req_id, code, message) -> dict:
    return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
    
  • result_response(req_id, result) -> dict:
    return {"jsonrpc": "2.0", "id": req_id, "result": result}
    
  • Valida que cada mensaje entrante tenga "jsonrpc": "2.0". Si no, devuelve error -32600 (Invalid Request).
  • Si falta method → error -32600.
  • Si method es desconocido → error -32601 (Method not found).
  • Si params tiene la forma equivocada → error -32602 (Invalid params).
  • Excepciones internas en cuerpos de tools → error -32603 (Internal error) solo para excepciones no manejadas. ToolError es un fallo lógico y se convierte en result.isError = true en su lugar — NO lo promuevas a error JSON-RPC.

Las constantes de código de error viven en lo alto del fichero como un pequeño dict; cítalas en comentarios junto a la sección del spec JSON-RPC 2.0.

Bloque C — handler de initialize

  • Cuando method == "initialize":
  • Parsea params.protocolVersion; si no es "2024-11-05" (o cualquier versión que pineamos), responde igual — solo eco la versión del cliente. (La verificación estricta de versión es preocupación de la Fase 33.)
  • Devuelve:
    {
      "protocolVersion": "2024-11-05",
      "capabilities": {"tools": {}},
      "serverInfo": {"name": "miniagent-server", "version": "0.1"}
    }
    
  • Tras initialize, espera un mensaje notifications/initialized (sin id, sin respuesta). Si llega otra cosa primero, loguea un warning en stderr pero continúa.

Bloque D — handler de tools/list

  • Cuando method == "tools/list":
  • Itera registered_tools de miniagent.tools.
  • Para cada uno, emite:
    {
      "name": tool.name,
      "description": tool.description,
      "inputSchema": tool.input_schema,  # note: MCP uses inputSchema (camelCase)
    }
    
  • Devuelve {"tools": [...]}.

Pitfall: MCP usa inputSchema (camelCase) en el wire aunque el campo Python sea input_schema. Si te equivocas, los clientes no validarán. Documenta el mapeo arriba en mcp_server.py.

Bloque E — handler de tools/call

  • Cuando method == "tools/call":
  • params.name → busca tool = registered_tools[name]. Si falta, error JSON-RPC -32602 con "Unknown tool: '<name>'".
  • params.arguments → un dict. Valida contra tool.input_schema usando jsonschema.Draft7Validator(tool.input_schema).validate(args). Si es inválido → error JSON-RPC -32602 con el mensaje de validación.
  • Llama a result = tool.fn(**args).
  • Si se lanza ToolError → devuelve:
    {"content": [{"type": "text", "text": str(exc)}], "isError": True}
    
  • Si éxito → devuelve:
    {"content": [{"type": "text", "text": json.dumps(result) if not isinstance(result, str) else result}], "isError": False}
    
    Las tools que devuelven dicts (como lookup_irregular_verb) se serializan a JSON en el canal de texto. Documéntalo — el cliente del Lab 02 lo desempaqueta.

Bloque F — el bucle de despacho

  • main():
    log = logging.getLogger("miniagent.server")
    while True:
        msg = read_message(sys.stdin)
        if msg is None:
            log.info("EOF on stdin, shutting down")
            break
        if "id" not in msg:
            # notification — handle but don't respond
            handle_notification(msg)
            continue
        response = dispatch(msg)
        write_message(sys.stdout, response)
    
  • Todas las llamadas a log van a stderr. Configura logging.basicConfig(level=logging.INFO, stream=sys.stderr).
  • if __name__ == "__main__": main().

Bloque G — smoke test manual

En experiments/31-mcp-server-smoke/:

  • manual.sh:
    #!/usr/bin/env bash
    set -euo pipefail
    python -m miniagent.mcp_server < input.bin > output.bin 2> stderr.log
    
  • input.bin: una secuencia de bytes hecha a mano que contiene:
  • request initialize
  • notifications/initialized
  • request tools/list
  • tools/call para conjugate(eat, past_simple, 3sg)
  • tools/call para lookup_spanish("ate")
  • EOF (simplemente deja de escribir)
  • Usa un helper Python (build_input.py) para construir input.bin ya que el framing Content-Length es doloroso de escribir a mano.
  • output.bin se commitea (con comentarios # noqa explicando cada frame).
  • manifest.json: versiones + seed estándar (no se usa seed realmente) + hash del transcript.
  • results.json: {"frames_in": 4, "frames_out": 3, "all_success": true} (nota: initialized es una notification, sin respuesta).

Restricciones

  • Solo stdlib dentro de mcp_server.py. Sin SDK mcp, sin pydantic. La excepción es jsonschema para validación de input schema (ya es una dependencia de la Fase 31).
  • Síncrono. Sin async. La Fase 33 introduce serving async; la Fase 31 se mantiene simple.
  • stdout es sagrado. Los logs van a stderr. Cualquier print() a stdout corrompe el protocolo — búscalo y elimínalo.
  • Sin estado global fuera de registered_tools. El servidor es un despachador puro sobre el registro.

Condiciones de parada

Hecho cuando:

  1. python -m miniagent.mcp_server < input.bin > output.bin corre limpio, código de salida 0.
  2. output.bin contiene frames Content-Length válidos; cada uno se puede parsear de vuelta a JSON.
  3. El frame tools/call conjugate(...) contiene "ate" en su result.content[0].text.
  4. stderr.log no muestra mensajes de nivel ERROR.

Pitfalls

  • Buffering. Python bufferea stdout por defecto. Sin flush() tras write_message, el cliente cuelga para siempre esperando una respuesta que está sentada en un buffer. Testea pipeando por cat y mirando si la respuesta aparece inmediatamente.
  • Offset de cabecera-cuerpo. La cabecera es Content-Length: N\r\n\r\n — son dos pares \r\n (uno terminando la línea de cabecera, uno separando cabeceras del cuerpo). El off-by-one es el bug más común.
  • Longitud UTF-8 vs longitud de string. len(json.dumps(msg)) es conteo de caracteres, no conteo de bytes. Usa len(json.dumps(msg).encode("utf-8")). Los caracteres multi-byte en el cuerpo JSON (p. ej., acentos en español comió) corromperán silenciosamente el frame si cuentas chars.
  • Las notifications no tienen id. Mandar una respuesta a notifications/initialized es una violación de protocolo. Ramifica sobre "id" in msg antes de despachar.
  • Excepciones de tool distintas a ToolError. Un bug en lookup_spanish lanzando KeyError debe convertirse en JSON-RPC -32603, no propagarse y crashear el proceso. Envuelve tool.fn(...) en try/except Exception.
  • jsonschema.ValidationError vs schema-check. validate() lanza ante violación de instancia; check_schema() lanza ante malformación del schema mismo. Usamos ambos — al arranque del servidor, comprueba que el schema de cada tool está bien formado; en cada llamada, valida los args.

Cuándo consultar solutions/

Tras que tu smoke test produzca un stream válido de 3 frames de salida. La solución en solutions/01-mcp-server-ref.md revisa la tabla de despacho y el formato de frame. La implementación de Borja puede diferir en estilo pero debe producir frames byte-idénticos para el input de test canónico.


Siguiente lab: lab/02-mcp-roundtrip.md — escribe un cliente que conduzca el servidor.