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
| Piece | What it does |
|---|---|
request / notification / ok_response / error_response | JSON-RPC 2.0 messages (a notification has no id) |
MCPServer.handle | the lifecycle (reject calls before initialize) + dispatch |
_call_tool | unknown → -32601, not-allow-listed → -32600, missing arg → -32602, tool error → error content |
resources/read, prompts/get | data + parameterized templates |
InMemoryTransport | serialize → parse → dispatch (proves it's really JSON on the wire) |
MCPClient | initialize, list_tools (cached), call_tool, read_resource, get_prompt, list_changed refresh |
File map
| File | Role |
|---|---|
lab.py | your implementation (fill the # TODOs) |
solution.py | reference + a full-session main() (handshake, call, denied call, runtime list_changed) |
test_lab.py | 16 tests: lifecycle, tools/resources/prompts, permission gating, error codes, notifications, wire fidelity |
requirements.txt | pytest 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/initializedhandshake. -
Calls before
initializeare 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
labandsolution.
How this maps to the real stack
- Your
MCPServer/MCPClientare the hand-rolled version of the MCP Python/TypeScript SDKs;handleis 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
initializecapability handshake, tools/resources/prompts primitives, permission-gatedtools/call, andlist_changednotifications — and can reason about its security surface (tool poisoning, over-broad scopes) and the stdio-vs-Streamable-HTTP transport tradeoff."