« Phase 03 README · Warmup · Hitchhiker's · Principal Deep Dive · Core Contributor · Staff Notes
Phase 03 — Deep Dive: The Model Context Protocol
The load-bearing idea of MCP is not "tools for LLMs." It is that a stateful, versioned RPC session — three message shapes, five error codes, one mandatory handshake — is enough to make any AI app talk to any tool provider without either side knowing the other's internals. This doc dissects the mechanism at the level the lab implements it, because the abstractions leak exactly where people skip the mechanics.
Three message shapes, and the one bit that distinguishes them
MCP's data layer is JSON-RPC 2.0. Every message on the wire is one of three shapes, and the entire dispatch logic keys off structural predicates, not off a type field:
- Request — carries
id(int or string, non-null),method, and optionalparams. It demands exactly one reply carrying the sameid. - Response — echoes the
id, and carriesresultXORerror, never both, never neither.erroris{code, message, data?}. - Notification — a request shape with no
idkey at all. Fire-and-forget: the receiver must not reply.
Look at how MCPServer.handle decides. It does not check a type tag; it computes is_notification = "id" not in message. That single predicate is the fork in the whole state machine. A message with id: null is not a notification — null is a present key. The lab's notification() helper deliberately omits the key rather than setting it to None, and a test asserts "id" not in notification(...). If you emit {"jsonrpc": "2.0", "id": null, "method": "notifications/initialized"}, a strict peer treats it as a malformed request (id must not be null in a request) and you have corrupted the session before it began. The absence of a key is load-bearing.
The five standard error codes and where each is raised
JSON-RPC reserves a band of codes; MCP uses the standard five and maps its own conditions onto them:
-32700parse error — the bytes were not valid JSON. Raised at the transport edge, before any dispatch, because there is noidto attach the error to (id isnull).-32600invalid request — well-formed JSON but not a valid request object; the lab also uses it for two protocol-policy rejections: a message whosejsonrpcfield isn't"2.0", and any method call issued before the session is initialized.-32601method not found — unknownmethod, and in_call_tool, an unknown tool name.-32602invalid params — the method exists but the params are wrong; the lab raises it for a missing required tool argument and for a missing prompt-template variable.-32603internal error — an unhandled exception inside a handler. Crucially the server converts the exception into this response instead of propagating it; a handler bug must not take down the session.
The dispatch discipline in handle is a three-tier try: RPCError (a deliberately-raised, coded protocol error) maps straight to error_response(id, code, message); a bare Exception maps to -32603; and a return None short-circuits notifications. This ordering is the mechanism: intended errors carry precise codes, unintended ones degrade to internal-error, and neither escapes as a Python traceback across the transport boundary.
The stateful handshake and its invariant
MCP is stateful, which is unusual for an RPC protocol and is the first thing people get wrong. The lifecycle:
- Client sends
initialize(a request,id: 1) withprotocolVersion, its owncapabilities, andclientInfo. - Server replies with its
protocolVersion, itscapabilities, andserverInfo. In the lab that's{"tools": {"listChanged": true}, "resources": {}, "prompts": {}}. - Client sends
notifications/initialized(a notification, noid). Only now is the session live.
The invariant, enforced in code: if not self._initialized: raise RPCError(INVALID_REQUEST, "server not initialized"). Every method except initialize itself is gated behind _initialized, and _initialized only flips true when the notifications/initialized notification arrives. So the ordering is not advisory — a tools/list sent between step 1 and step 3 returns -32600. Why enforce this at the mechanism level? Because capabilities are negotiated state. Until both sides have exchanged capability objects, neither knows which methods are legal. Skipping the handshake means the client might call sampling/createMessage on a server that never declared support, or the server might emit notifications/tools/list_changed to a client that can't handle it. The handshake is the point where the two peers agree on the contract; calling a method before it is calling into undefined behavior.
Dispatch to primitives, and the _call_tool error lattice
Once initialized, _dispatch is a flat method-name switch to six handlers: tools/list, tools/call, resources/list, resources/read, prompts/list, prompts/get. The interesting one is _call_tool, whose four-way error lattice is the whole permission-and-safety story compressed into one function:
- unknown tool name →
-32601(method-not-found; the tool is not a method the server offers). - name exists but is not in the per-client allow-list →
-32600(invalid request; you asked for something you're not permitted to). This is the permission boundary, and it is checked before execution. - required argument missing →
-32602(invalid params). - the tool function itself raises → not an error response. It returns
{"content": [{"type": "text", "text": "ERROR: ..."}], "isError": true}.
That last mapping is the subtle one. A tool that divides by zero is not a protocol error — the protocol worked perfectly, the tool failed. So the failure is delivered as tool-call content with isError: true, which the model can read and react to ("the query failed, try a narrower filter"), rather than as a JSON-RPC error, which would abort the call from the client's perspective and hide the reason from the model. Collapsing these two failure domains is the single most common bug: if you let the tool exception bubble into -32603, the agent loses the ability to recover, and a crashing tool can wedge the session.
The in-memory transport proves the layering
InMemoryTransport.send does exactly three things: json.dumps(message), json.loads(wire), server.handle(parsed). The serialize-then-parse round trip is not ceremony — it is the proof that the client and server exchange plain JSON dicts, not shared Python objects. Nothing survives that round trip except what JSON can represent. That is why the same MCPServer and MCPClient code would run unchanged over stdio (write JSON to a subprocess's stdin, read from stdout) or Streamable HTTP (POST JSON, stream responses). The data layer is transport-agnostic because everything it touches has already been flattened to JSON. Swap InMemoryTransport for a stdio pipe and not one line of handle changes.
listChanged: the one dynamic capability
Static discovery (*/list) plus a change-notification is how MCP stays dynamic without polling. The mechanism: the server declared tools.listChanged: true at initialize. When add_tool(..., notify=True) runs after initialization, the server appends notification("notifications/tools/list_changed") to an _outbox. The client, on _pump_notifications, drains that outbox, sees the notification, and re-issues tools/list to refresh its tools_cache. The notification carries no payload — it is a cache-invalidation signal, not a diff. The client must re-fetch to learn what changed. This is deliberate: sending diffs would require the server to track per-client cache state; a bare invalidation keeps the server stateless about client caches and the client authoritative about its own.
Worked trace of one session
→ {"id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{"elicitation":{}},"clientInfo":{...}}}—← {"id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{"listChanged":true},...},"serverInfo":{...}}}.→ {"method":"notifications/initialized"}(noid) — server flips_initialized, returnsNone, nothing on the wire.→ {"id":2,"method":"tools/list"}—← {"id":2,"result":{"tools":[{"name":"add",...},{"name":"weather",...},{"name":"delete_db",...}]}}.→ {"id":3,"method":"tools/call","params":{"name":"add","arguments":{"x":2,"y":3}}}—← {"id":3,"result":{"content":[{"type":"text","text":"5"}]}}.→ {"id":4,"method":"tools/call","params":{"name":"delete_db","arguments":{}}}—delete_dbexists but is not allow-listed —← {"id":4,"error":{"code":-32600,"message":"tool 'delete_db' not permitted..."}}.- Server registers
subtractwithnotify=True, queueslist_changed. Client pumps, sees it, re-issuestools/list, cache now includessubtract.
Every step is one JSON dict crossing a json.dumps/json.loads boundary. That is the entire protocol. The naive approaches fail precisely at these seams: give a notification an id and the peer waits forever for a reply that a well-behaved server will refuse to send; skip the handshake and every call is -32600; let a tool exception escape and one bad tool crashes the whole agent instead of returning a recoverable error the model can read.