« 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 optional params. It demands exactly one reply carrying the same id.
  • Response — echoes the id, and carries result XOR error, never both, never neither. error is {code, message, data?}.
  • Notification — a request shape with no id key 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:

  • -32700 parse error — the bytes were not valid JSON. Raised at the transport edge, before any dispatch, because there is no id to attach the error to (id is null).
  • -32600 invalid request — well-formed JSON but not a valid request object; the lab also uses it for two protocol-policy rejections: a message whose jsonrpc field isn't "2.0", and any method call issued before the session is initialized.
  • -32601 method not found — unknown method, and in _call_tool, an unknown tool name.
  • -32602 invalid 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.
  • -32603 internal 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:

  1. Client sends initialize (a request, id: 1) with protocolVersion, its own capabilities, and clientInfo.
  2. Server replies with its protocolVersion, its capabilities, and serverInfo. In the lab that's {"tools": {"listChanged": true}, "resources": {}, "prompts": {}}.
  3. Client sends notifications/initialized (a notification, no id). 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

  1. → {"id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{"elicitation":{}},"clientInfo":{...}}}← {"id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{"listChanged":true},...},"serverInfo":{...}}}.
  2. → {"method":"notifications/initialized"} (no id) — server flips _initialized, returns None, nothing on the wire.
  3. → {"id":2,"method":"tools/list"}← {"id":2,"result":{"tools":[{"name":"add",...},{"name":"weather",...},{"name":"delete_db",...}]}}.
  4. → {"id":3,"method":"tools/call","params":{"name":"add","arguments":{"x":2,"y":3}}}← {"id":3,"result":{"content":[{"type":"text","text":"5"}]}}.
  5. → {"id":4,"method":"tools/call","params":{"name":"delete_db","arguments":{}}}delete_db exists but is not allow-listed — ← {"id":4,"error":{"code":-32600,"message":"tool 'delete_db' not permitted..."}}.
  6. Server registers subtract with notify=True, queues list_changed. Client pumps, sees it, re-issues tools/list, cache now includes subtract.

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.