Where we are
In b2-b3 you built the Daybreak server (three tools, two resources, one prompt). In b4 you connected it to real hosts - Claude Code and Claude Desktop - by editing config files. Those hosts contain a client you never saw. Tonight you write that client: a Python program that launches the server, speaks JSON-RPC over stdio, and feeds a model the tools it discovers. The messages are exactly the ones you stepped through in b1 - now you are the code that sends them.
client.py that connects to your Daybreak server, discovers its tools, and answers "how did March look?" with the real $150.00 number - powered by Claude. Then the same loop rewired to a local Ollama model with zero API key. You will know exactly who initiates the handshake, how a model's tool_use becomes a session.call_tool, and how to tell a protocol error from a failed tool from a dropped connection.
The other side of the wire 7 min live
The server is passive: it waits and answers. The client is active and owns four jobs. It owns the connection (it launches or dials the server), the session (it sends initialize and holds the agreed capabilities), discovery (it calls tools/list and hands the schemas to the model), and routing (when the model asks for a tool, the client is who actually sends tools/call). Every message you watched in b1 was sent or received by a client - tonight that client is your code.
LiveClientSession and stdio_client, line by line4 min▶
The Python SDK gives you two pieces. stdio_client launches the server as a subprocess and hands back a read/write pair of streams. ClientSession wraps those streams in the JSON-RPC machinery - initialize, list_tools, call_tool, and the rest. You open both as async context managers so they close cleanly.
That is a complete client. Run it and you get the same tools/list and tools/call traffic you stepped through in b1 - but sent by you. Below, watch the call your last line makes go over the wire.
LiveSession lifecycle - open once, reuse, close cleanly3 min▶
A session is not per-request; it is per-connection. You initialize once, then send many calls over the same live session. The official quickstart uses an AsyncExitStack so the connection and session are torn down in the right order even if something throws.
- Who starts the handshake? The client - always.
session.initialize()sends the first message; the server only answers. - Where do capabilities live? Agreed once, in the initialize exchange. On the current spec (2025-11-25) the session carries them until you close it.
- Changes 2026-07-28: the stateless core removes this explicit handshake - capabilities travel in
_metaon each request. b8 walks that delta.
The chatbot loop 7 min live
A client that only calls tools you hardcode is not interesting. The chatbot loop makes the model decide. The shape is always the same: user message → send it to the model with the tool definitions → the model returns tool_use blocks → for each, call session.call_tool → feed the tool_result back → the model writes the final answer. This loop is identical whether the model is Claude in the cloud or Llama on your laptop; only the SDK call in the middle changes.
LiveThe Claude path - Anthropic SDK5 min▶
The Anthropic messages.create call takes a tools list. You build it straight from what the session discovered - MCP's inputSchema maps directly to Claude's input_schema. When a reply contains a tool_use block, you run the tool and append a tool_result, then call the model again.
Notice the mapping you will be quizzed on: a model tool_use block becomes one session.call_tool(name, args), and its answer goes back as a tool_result keyed by the same tool_use_id. Same id-pairing discipline as the raw JSON-RPC in b1.
LiveThe Ollama path - local model, no API key5 min▶
The no-API-key path. Swap the Anthropic call for the ollama Python package pointed at a local model such as llama3.2. The MCP half does not change at all - same session, same call_tool. Only the model client and the tool-call field names differ (Ollama returns message.tool_calls, each with function.name and function.arguments).
call_tool, same result handling. If you can read one, you can read the other.
Self-studyMulti-server clients - one session per server3 min read▶
Real hosts connect to several servers at once - Daybreak plus a filesystem server plus GitHub. The rule from b1 holds: one client session per server. You keep a dict of sessions, merge every server's list_tools into one tool list for the model, and route each call_tool to the session that owns that tool name.
Watch for name collisions: two servers can both expose a tool called search. Hosts usually namespace by server to keep them apart.
When things fail 5 min live
Three different things can go wrong, at three different layers, and the fix for each is different. A protocol error is a JSON-RPC error object - the request was malformed or named a tool that does not exist. A tool failure is a normal result with isError:true - the tool ran but the work failed. A transport drop is neither - the connection died and no message comes back at all. Your client has to handle all three.
LiveThe three failure layers on the wire4 min▶
Step the error scenario: the client calls a tool the server never declared. What comes back is a JSON-RPC error - not a result. Compare it with the successful tools/call in Part 1: result vs error is the whole distinction.
Chat with Daybreak ★ 10 min · run it live
Put it together: your client.py launches project/server.py, you ask about March, the model calls query_sales, and the real $150.00 number comes back. This is the whole track paying off - your code, end to end.
Install and set your key: uv add mcp anthropic python-dotenv, then put ANTHROPIC_API_KEY=... in a .env file (and add .env to .gitignore).
Run uv run client.py project/server.py. The client launches the server over stdio and prints the tools it discovered.
At the prompt, type: how did March look? The model reads query_sales' description, fills {"month": "2026-03"}, and your client sends the call.
Read the answer: 2026-03: 5 orders (1 refunded), completed revenue $150.00 - down from $271.00 in February. That number came from the SQLite DB through your server, your client, and the model.
The client is where product happens. Servers are commodity - once Daybreak exposes clean tools, any client can drive it. The differentiated work is in the client: which model, how you handle multi-step tool chains, how you surface errors to the user, whether you require human approval before a consequential tool. That is why teams write clients even when off-the-shelf hosts exist.
Swap Claude for Ollama ★ 8 min · same loop, local model
Prove MCP is model-agnostic. Same server, same session, same call_tool - only the model client changes. No API key, no network, private data stays on your machine.
Install Ollama, then ollama pull llama3.2 (a model that supports tool calling). pip install ollama.
Run your Ollama client against the same server: uv run client_ollama.py project/server.py. No key needed.
Ask the same March question. The local model returns a tool_calls entry; your code routes it to session.call_tool("query_sales", ...) exactly as before.
Compare: same $150.00 from the same DB. The MCP half never moved - that is the point.
Try it yourself - this week ◐ 30-45 min total
- Get the Claude client answering the March question end to end against
project/server.py. Confirm the $150.00 number matches what you saw in b1'scallscenario. - Add resources to your chatbot: before answering, call
session.read_resource("daybreak://policy/refunds")and prepend it to the conversation so the model can cite the refund rule. (Hint: it is oneresources/readcall, just like the tool call - but read-only context, not an action.) - Run the Ollama path against the same server and diff the answers. Note where the local model needed a firmer nudge to call the tool.
- Break it on purpose: call a tool name that does not exist and confirm you get a JSON-RPC error, not an
isErrorresult. Then kill the server mid-session and watch the transport drop.
Official sources covered
Client authoring is the most-taught topic across the major courses; this page teaches the working core against the current SDK, adds the Ollama path nobody else covers, and keeps the wire visible. This page covers:
Three questions before you go 🎯 ◐ 90 seconds
1 · Who sends the very first message that opens a session?
The client always initiates. session.initialize() sends the initialize request; the server only ever answers. The model is not on the wire at all - it talks to your client, which talks to the server.
2 · A model reply contains a tool_use block. In your client, that maps to...
tool_use → session.call_tool(name, args) → tool_result keyed by the same tool_use_id → model writes the answer. That is the whole loop, and it is identical for Claude and Ollama.
3 · Your call_tool returns a normal result with isError:true. Which layer failed, and should you retry?
isError:true means the tool executed but the work failed - a normal result, not a JSON-RPC error. Retries only help layer 3 (transport drops). Layers 1 and 2 need a corrected request or app-level handling.