Lab 01 — MCP Server & Client Over stdio

Phase 03 · Lab 01 · Phase README · Warmup

The problem

MCP is named in the Docker, Citi, and Anthropic job descriptions. To own it (not just name it), build a faithful miniature: a real JSON-RPC 2.0 server and client speaking the actual method names, with the initialize handshake, capability negotiation, tools/resources/prompts, permission gating, and notifications/tools/list_changed. The transport is in-memory (deterministic, offline) but the protocol is real — the tests assert on raw message shapes.

What you build

PieceWhat it does
request / notification / ok_response / error_responseJSON-RPC 2.0 messages (a notification has no id)
MCPServer.handlethe lifecycle (reject calls before initialize) + dispatch
_call_toolunknown → -32601, not-allow-listed → -32600, missing arg → -32602, tool error → error content
resources/read, prompts/getdata + parameterized templates
InMemoryTransportserialize → parse → dispatch (proves it's really JSON on the wire)
MCPClientinitialize, list_tools (cached), call_tool, read_resource, get_prompt, list_changed refresh

File map

FileRole
lab.pyyour implementation (fill the # TODOs)
solution.pyreference + a full-session main() (handshake, call, denied call, runtime list_changed)
test_lab.py16 tests: lifecycle, tools/resources/prompts, permission gating, error codes, notifications, wire fidelity
requirements.txtpytest only

Run it

pytest test_lab.py -v                       # your lab.py (red until you implement)
LAB_MODULE=solution pytest test_lab.py -v   # the reference (green)
python solution.py                          # the full MCP session

Success criteria

  • You can hand-walk the initialize → capabilities → notifications/initialized handshake.
  • Calls before initialize are rejected; non-allow-listed tools are denied.
  • You can name the 3 server + 3 client primitives and their methods.
  • A tool exception returns error content, not a crash; a notification carries no id.
  • All 16 tests pass under both lab and solution.

How this maps to the real stack

  • Your MCPServer/MCPClient are the hand-rolled version of the MCP Python/TypeScript SDKs; handle is the SDK's message router. Real hosts (Claude Desktop, Claude Code, VS Code, Cursor) are the "host" that runs your client.
  • The in-memory transport stands in for stdio (local subprocess) or Streamable HTTP + OAuth (remote). Because the data layer is transport-agnostic, the same server/client code runs over either — swap only the transport.
  • Permission gating is the miniature of a host's consent/permission model; in production add a sandbox (Phase 09) around tool execution and human approval (elicitation, Phase 10) for dangerous tools.

Limits. Real MCP adds sampling/elicitation round-trips, resource subscriptions, progress notifications, pagination, and real auth — all layered on the same JSON-RPC core you built here.

Extensions (your own machine)

  • Add sampling/createMessage: let the server ask the client's (injected) LLM for a completion, so a server can reason without its own model SDK.
  • Add elicitation/create: a human-approval round-trip before a dangerous tool runs.
  • Swap the transport for real stdio: run the server as a subprocess and speak JSON-RPC over its stdin/stdout; then point the MCP Inspector at it.

Interview / resume signal

"Implemented the Model Context Protocol end to end — a JSON-RPC 2.0 server and client with the initialize capability handshake, tools/resources/prompts primitives, permission-gated tools/call, and list_changed notifications — and can reason about its security surface (tool poisoning, over-broad scopes) and the stdio-vs-Streamable-HTTP transport tradeoff."