Skip to content

English · Español

Lab 01 — Hand-Rolled MCP Server (stdio + JSON-RPC 2.0)

Goal: stand up src/miniagent/mcp_server.py — a synchronous MCP server speaking JSON-RPC 2.0 over stdio. It exposes the four §A13 tools registered in Lab 00.

Estimated time: 3–4 hours. Most of it is framing (the Content-Length header dance), not application logic.

Prereq: Lab 00 done — registered_tools populated with the four Tool instances.


What you produce

A single executable Python module: src/miniagent/mcp_server.py.

Run it with python -m miniagent.mcp_server and it should:

  1. Block on sys.stdin waiting for a Content-Length: N\r\n\r\n<json> frame.
  2. Parse the JSON-RPC message.
  3. Dispatch on method.
  4. Write the response back to sys.stdout (also Content-Length framed) and flush().
  5. Log diagnostics to sys.stderr (never stdout — stdout is reserved for the protocol).
  6. Exit cleanly when stdin closes (EOF).

Plus a tiny CLI smoke test: experiments/31-mcp-server-smoke/manual.txt that contains the byte stream of a manual session you piped into the server. (Use printf + cat to drive it — Lab 02 will use a real client.)

TODOs

Block A — frame I/O

In src/miniagent/mcp_server.py, before any protocol logic:

  • read_message(stream) -> dict | None:
  • Read bytes from stream.buffer (the binary view; sys.stdin.buffer) until you've consumed a \r\n\r\n header terminator.
  • Parse Content-Length: N from the headers; ignore other headers.
  • Read exactly N bytes of body.
  • Decode UTF-8; json.loads.
  • Return None on EOF.
  • write_message(stream, msg: dict) -> None:
  • body = json.dumps(msg).encode("utf-8").
  • Write f"Content-Length: {len(body)}\r\n\r\n".encode() then body.
  • stream.buffer.flush().
  • Unit test these two in isolation: round-trip a sample message through a BytesIO pair.

Pitfall: sys.stdin.read() does not give you binary; you must use sys.stdin.buffer.read(n). Text-mode I/O may translate CRLF on Windows; binary mode prevents that.

Block B — JSON-RPC envelope handling

  • 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}
    
  • Validate every incoming message has "jsonrpc": "2.0". If not, return error -32600 (Invalid Request).
  • If method is missing → error -32600.
  • If method is unknown → error -32601 (Method not found).
  • If params is the wrong shape → error -32602 (Invalid params).
  • Internal exceptions in tool bodies → error -32603 (Internal error) only for unhandled exceptions. ToolError is a logical failure and becomes result.isError = true instead — do NOT promote it to a JSON-RPC error.

The error-code constants live at the top of the file as a small dict; quote them in comments alongside the JSON-RPC 2.0 spec section.

Block C — initialize handler

  • On method == "initialize":
  • Parse params.protocolVersion; if it's not "2024-11-05" (or whatever version we pin), still respond — just echo the client's version back. (Strict version-checking is Phase 33's concern.)
  • Return:
    {
      "protocolVersion": "2024-11-05",
      "capabilities": {"tools": {}},
      "serverInfo": {"name": "miniagent-server", "version": "0.1"}
    }
    
  • After initialize, expect a notifications/initialized message (no id, no response). If anything else comes first, log a warning to stderr but continue.

Block D — tools/list handler

  • On method == "tools/list":
  • Iterate registered_tools from miniagent.tools.
  • For each, emit:
    {
      "name": tool.name,
      "description": tool.description,
      "inputSchema": tool.input_schema,  # note: MCP uses inputSchema (camelCase)
    }
    
  • Return {"tools": [...]}.

Pitfall: MCP uses inputSchema (camelCase) on the wire even though the Python field is input_schema. Get this wrong and clients won't validate. Document the mapping at the top of mcp_server.py.

Block E — tools/call handler

  • On method == "tools/call":
  • params.name → look up tool = registered_tools[name]. If missing, JSON-RPC error -32602 with "Unknown tool: '<name>'".
  • params.arguments → a dict. Validate against tool.input_schema using jsonschema.Draft7Validator(tool.input_schema).validate(args). If invalid → JSON-RPC error -32602 with the validation message.
  • Call result = tool.fn(**args).
  • If ToolError raised → return:
    {"content": [{"type": "text", "text": str(exc)}], "isError": True}
    
  • If success → return:
    {"content": [{"type": "text", "text": json.dumps(result) if not isinstance(result, str) else result}], "isError": False}
    
    Tools that return dicts (like lookup_irregular_verb) get JSON-stringified into the text channel. Document this — Lab 02's client unpacks it.

Block F — the dispatch loop

  • 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)
    
  • All log calls go to stderr. Configure logging.basicConfig(level=logging.INFO, stream=sys.stderr).
  • if __name__ == "__main__": main().

Block G — manual smoke test

In 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: a hand-crafted byte sequence containing:
  • initialize request
  • notifications/initialized
  • tools/list request
  • tools/call for conjugate(eat, past_simple, 3sg)
  • tools/call for lookup_spanish("ate")
  • EOF (just stop writing)
  • Use a Python helper (build_input.py) to construct input.bin since Content-Length framing is painful to write by hand.
  • output.bin is committed (with # noqa comments explaining each frame).
  • manifest.json: standard versions + seed (no seed actually used) + transcript hash.
  • results.json: {"frames_in": 4, "frames_out": 3, "all_success": true} (note: initialized is a notification, no response).

Constraints

  • stdlib only inside mcp_server.py. No mcp SDK, no pydantic. The exception is jsonschema for input-schema validation (already a Phase 31 dependency).
  • Synchronous. No async. Phase 33 introduces async serving; Phase 31 stays simple.
  • stdout is sacred. Logs go to stderr. Any print() to stdout corrupts the protocol — search for and remove.
  • No global state outside registered_tools. The server is a pure dispatcher over the registry.

Stop conditions

Done when:

  1. python -m miniagent.mcp_server < input.bin > output.bin runs cleanly, exit code 0.
  2. output.bin contains valid Content-Length frames; each can be parsed back into JSON.
  3. The tools/call conjugate(...) frame contains "ate" in its result.content[0].text.
  4. stderr.log shows no ERROR level messages.

Pitfalls

  • Buffering. Python buffers stdout by default. Without flush() after write_message, the client hangs forever waiting for a response that's sitting in a buffer. Test by piping through cat and looking for the response immediately.
  • Header-body offset. The header is Content-Length: N\r\n\r\n — that's two \r\n pairs (one terminating the header line, one separating headers from body). Off-by-one is the most common bug.
  • UTF-8 length vs string length. len(json.dumps(msg)) is character count, not byte count. Use len(json.dumps(msg).encode("utf-8")). Multi-byte characters in the JSON body (e.g., Spanish accents comió) will silently corrupt the frame if you count chars.
  • Notifications have no id. Sending a response to notifications/initialized is a protocol violation. Branch on "id" in msg before dispatching.
  • Tool exceptions other than ToolError. A bug in lookup_spanish raising KeyError should become JSON-RPC -32603, not propagate and crash the process. Wrap tool.fn(...) in try/except Exception.
  • jsonschema.ValidationError vs schema-check. validate() raises on instance violation; check_schema() raises on schema-itself malformation. We use both — at server start, check every tool's schema is well-formed; on each call, validate args.

When to consult solutions/

After your smoke test produces a valid 3-frame output stream. The solution at solutions/01-mcp-server-ref.md cross-checks the dispatch table and the frame format. Borja's implementation may differ in style but must produce byte-identical frames for the canonical test input.


Next lab: lab/02-mcp-roundtrip.md — write a client that drives the server.