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_toolspoblado con las cuatro instanciasTool.
Qué produces¶
Un único módulo Python ejecutable: src/miniagent/mcp_server.py.
Ejecútalo con python -m miniagent.mcp_server y debería:
- Bloquear en
sys.stdinesperando un frameContent-Length: N\r\n\r\n<json>. - Parsear el mensaje JSON-RPC.
- Despachar sobre
method. - Escribir la respuesta de vuelta a
sys.stdout(también con framing Content-Length) yflush(). - Loguear diagnósticos a
sys.stderr(nunca a stdout — stdout está reservado para el protocolo). - 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: Nde las cabeceras; ignora otras cabeceras. - Lee exactamente
Nbytes de cuerpo. - Decodifica UTF-8;
json.loads. - Devuelve
Noneen 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()luegobody. 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: -
result_response(req_id, result) -> dict: - Valida que cada mensaje entrante tenga
"jsonrpc": "2.0". Si no, devuelve error-32600(Invalid Request). - Si falta
method→ error-32600. - Si
methodes desconocido → error-32601(Method not found). - Si
paramstiene la forma equivocada → error-32602(Invalid params). - Excepciones internas en cuerpos de tools → error
-32603(Internal error) solo para excepciones no manejadas.ToolErrores un fallo lógico y se convierte enresult.isError = trueen 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:
- Tras
initialize, espera un mensajenotifications/initialized(sinid, 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_toolsdeminiagent.tools. - Para cada uno, emite:
- 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→ buscatool = registered_tools[name]. Si falta, error JSON-RPC-32602con"Unknown tool: '<name>'".params.arguments→ un dict. Valida contratool.input_schemausandojsonschema.Draft7Validator(tool.input_schema).validate(args). Si es inválido → error JSON-RPC-32602con el mensaje de validación.- Llama a
result = tool.fn(**args). - Si se lanza
ToolError→ devuelve: - Si éxito → devuelve:
Las tools que devuelven dicts (como
{"content": [{"type": "text", "text": json.dumps(result) if not isinstance(result, str) else result}], "isError": False}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
logvan a stderr. Configuralogging.basicConfig(level=logging.INFO, stream=sys.stderr). -
if __name__ == "__main__": main().
Bloque G — smoke test manual¶
En experiments/31-mcp-server-smoke/:
-
manual.sh: -
input.bin: una secuencia de bytes hecha a mano que contiene: - request
initialize notifications/initialized- request
tools/list tools/callparaconjugate(eat, past_simple, 3sg)tools/callparalookup_spanish("ate")- EOF (simplemente deja de escribir)
- Usa un helper Python (
build_input.py) para construirinput.binya que el framing Content-Length es doloroso de escribir a mano. -
output.binse commitea (con comentarios# noqaexplicando 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:initializedes una notification, sin respuesta).
Restricciones¶
- Solo stdlib dentro de
mcp_server.py. Sin SDKmcp, sinpydantic. La excepción esjsonschemapara 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:
python -m miniagent.mcp_server < input.bin > output.bincorre limpio, código de salida 0.output.bincontiene frames Content-Length válidos; cada uno se puede parsear de vuelta a JSON.- El frame
tools/call conjugate(...)contiene"ate"en suresult.content[0].text. stderr.logno muestra mensajes de nivelERROR.
Pitfalls¶
- Buffering. Python bufferea stdout por defecto. Sin
flush()traswrite_message, el cliente cuelga para siempre esperando una respuesta que está sentada en un buffer. Testea pipeando porcaty 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. Usalen(json.dumps(msg).encode("utf-8")). Los caracteres multi-byte en el cuerpo JSON (p. ej., acentos en españolcomió) corromperán silenciosamente el frame si cuentas chars. - Las notifications no tienen id. Mandar una respuesta a
notifications/initializedes una violación de protocolo. Ramifica sobre"id" in msgantes de despachar. - Excepciones de tool distintas a
ToolError. Un bug enlookup_spanishlanzandoKeyErrordebe convertirse en JSON-RPC-32603, no propagarse y crashear el proceso. Envuelvetool.fn(...)entry/except Exception. jsonschema.ValidationErrorvs 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.