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_toolspopulated with the fourToolinstances.
What you produce¶
A single executable Python module: src/miniagent/mcp_server.py.
Run it with python -m miniagent.mcp_server and it should:
- Block on
sys.stdinwaiting for aContent-Length: N\r\n\r\n<json>frame. - Parse the JSON-RPC message.
- Dispatch on
method. - Write the response back to
sys.stdout(also Content-Length framed) andflush(). - Log diagnostics to
sys.stderr(never stdout — stdout is reserved for the protocol). - 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\nheader terminator. - Parse
Content-Length: Nfrom the headers; ignore other headers. - Read exactly
Nbytes of body. - Decode UTF-8;
json.loads. - Return
Noneon 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()thenbody. stream.buffer.flush().- Unit test these two in isolation: round-trip a sample message through a
BytesIOpair.
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: -
result_response(req_id, result) -> dict: - Validate every incoming message has
"jsonrpc": "2.0". If not, return error-32600(Invalid Request). - If
methodis missing → error-32600. - If
methodis unknown → error-32601(Method not found). - If
paramsis the wrong shape → error-32602(Invalid params). - Internal exceptions in tool bodies → error
-32603(Internal error) only for unhandled exceptions.ToolErroris a logical failure and becomesresult.isError = trueinstead — 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:
- After
initialize, expect anotifications/initializedmessage (noid, 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_toolsfromminiagent.tools. - For each, emit:
- 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 uptool = registered_tools[name]. If missing, JSON-RPC error-32602with"Unknown tool: '<name>'".params.arguments→ a dict. Validate againsttool.input_schemausingjsonschema.Draft7Validator(tool.input_schema).validate(args). If invalid → JSON-RPC error-32602with the validation message.- Call
result = tool.fn(**args). - If
ToolErrorraised → return: - If success → return:
Tools that return dicts (like
{"content": [{"type": "text", "text": json.dumps(result) if not isinstance(result, str) else result}], "isError": False}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
logcalls go to stderr. Configurelogging.basicConfig(level=logging.INFO, stream=sys.stderr). -
if __name__ == "__main__": main().
Block G — manual smoke test¶
In experiments/31-mcp-server-smoke/:
-
manual.sh: -
input.bin: a hand-crafted byte sequence containing: initializerequestnotifications/initializedtools/listrequesttools/callforconjugate(eat, past_simple, 3sg)tools/callforlookup_spanish("ate")- EOF (just stop writing)
- Use a Python helper (
build_input.py) to constructinput.binsince Content-Length framing is painful to write by hand. -
output.binis committed (with# noqacomments 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:initializedis a notification, no response).
Constraints¶
- stdlib only inside
mcp_server.py. NomcpSDK, nopydantic. The exception isjsonschemafor 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:
python -m miniagent.mcp_server < input.bin > output.binruns cleanly, exit code 0.output.bincontains valid Content-Length frames; each can be parsed back into JSON.- The
tools/call conjugate(...)frame contains"ate"in itsresult.content[0].text. stderr.logshows noERRORlevel messages.
Pitfalls¶
- Buffering. Python buffers stdout by default. Without
flush()afterwrite_message, the client hangs forever waiting for a response that's sitting in a buffer. Test by piping throughcatand looking for the response immediately. - Header-body offset. The header is
Content-Length: N\r\n\r\n— that's two\r\npairs (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. Uselen(json.dumps(msg).encode("utf-8")). Multi-byte characters in the JSON body (e.g., Spanish accentscomió) will silently corrupt the frame if you count chars. - Notifications have no id. Sending a response to
notifications/initializedis a protocol violation. Branch on"id" in msgbefore dispatching. - Tool exceptions other than
ToolError. A bug inlookup_spanishraisingKeyErrorshould become JSON-RPC-32603, not propagate and crash the process. Wraptool.fn(...)intry/except Exception. jsonschema.ValidationErrorvs 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.