learn-mcp-with-phoebe / Builder session 5 of 8
Learn MCP with Phoebe · Builder track · Session 5 of 8

Build a client

Until now you have written the server and watched hosts drive it. Tonight you cross to the other side of the wire and build the client yourself: the code that owns the connection, initializes the session, discovers tools at runtime, and runs the chat loop that lets a model call Daybreak. You will build it twice - once against the Claude API, once against a local model through Ollama with no API key at all - and prove they run the same loop.

🟡 Medium Practitioners: DE · DS · AI engineers · builders Python · spec 2025-11-25 45 min
0-3 · Recap 3-18 · The connection + session 18-40 · Build the chatbot loop, twice 40-45 · Q&A
Part 0

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.

Live - presented in session Self-study - read after class ▶ Live MCP wire - step through real messages Official sources covered
★ What you walk out with today A working 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.
Part 1 · covers official "Build an MCP client", DLAI "Creating an MCP Client", Academy "Implementing a client"

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.

What client.py does, top to bottom 1 · connectlaunch server 2 · initializeagree version 3 · list_toolsdiscover schemas 4 · chat loopmodel reads tools 5 · call_toolmodel asked 6 · tool_resultback to model loop until the model stops asking for tools, then print its answer Steps 1-3 run once per connection. Steps 4-6 repeat every turn of the conversation.
🔍 Click to zoom - the six things your client does, and the loop it runs
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.

connect.py · the minimum viable client import asyncio from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client # Tell the SDK how to LAUNCH the server (this is the stdio transport). params = StdioServerParameters(command="python", args=["server.py"], env=None) async def main(): async with stdio_client(params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() # the handshake from b1 tools = (await session.list_tools()).tools # runtime discovery print("Tools:", [t.name for t in tools]) result = await session.call_tool("query_sales", {"month": "2026-03"}) print(result.content[0].text) asyncio.run(main())

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.

DaybreakClient · a reusable session holder from contextlib import AsyncExitStack from typing import Optional from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client from anthropic import Anthropic from dotenv import load_dotenv load_dotenv() # reads ANTHROPIC_API_KEY from .env class DaybreakClient: def __init__(self): self.session: Optional[ClientSession] = None self.exit_stack = AsyncExitStack() self.anthropic = Anthropic() async def connect(self, server_script: str): params = StdioServerParameters(command="python", args=[server_script], env=None) read, write = await self.exit_stack.enter_async_context(stdio_client(params)) self.session = await self.exit_stack.enter_async_context(ClientSession(read, write)) await self.session.initialize() tools = (await self.session.list_tools()).tools print("Connected. Tools:", [t.name for t in tools]) async def cleanup(self): await self.exit_stack.aclose()
  • 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 _meta on each request. b8 walks that delta.
Part 2 · covers DLAI "Chatbot Example" + "Creating an MCP Client", official client quickstart

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.

client.py · process_query (Claude) async def process_query(self, query: str) -> str: messages = [{"role": "user", "content": query}] tools_resp = await self.session.list_tools() available_tools = [{ "name": tool.name, "description": tool.description, "input_schema": tool.inputSchema, # MCP schema -> Claude tool } for tool in tools_resp.tools] resp = self.anthropic.messages.create( model="claude-sonnet-5", max_tokens=1000, messages=messages, tools=available_tools, ) final_text = [] assistant_content = [] for block in resp.content: if block.type == "text": final_text.append(block.text) assistant_content.append(block) elif block.type == "tool_use": # The model picked the tool and filled the args from the question. result = await self.session.call_tool(block.name, block.input) assistant_content.append(block) messages.append({"role": "assistant", "content": assistant_content}) messages.append({"role": "user", "content": [{ "type": "tool_result", "tool_use_id": block.id, "content": result.content, }]}) follow = self.anthropic.messages.create( model="claude-sonnet-5", max_tokens=1000, messages=messages, tools=available_tools, ) final_text.append(follow.content[0].text) return "\n".join(final_text)

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).

client_ollama.py · same loop, local model (no API key) import ollama # pip install ollama ; run `ollama pull llama3.2` first async def process_query_ollama(session, query: str) -> str: tools_resp = await session.list_tools() tools = [{ "type": "function", "function": { "name": t.name, "description": t.description, "parameters": t.inputSchema, # same MCP schema, Ollama shape }, } for t in tools_resp.tools] messages = [{"role": "user", "content": query}] reply = ollama.chat(model="llama3.2", messages=messages, tools=tools) for call in reply.message.tool_calls or []: result = await session.call_tool(call.function.name, call.function.arguments) messages.append(reply.message) messages.append({ "role": "tool", "name": call.function.name, "content": result.content[0].text, }) final = ollama.chat(model="llama3.2", messages=messages) return final.message.content
Why teach both The Claude path ships the best tool-use quality; the Ollama path proves MCP is model-agnostic and runs offline with private data. Same discovery, same 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.

sketch · routing across sessions self.sessions = {} # server_name -> ClientSession self.tool_owner = {} # tool_name -> server_name # ...after connecting each server, record which session owns each tool... owner = self.tool_owner[block.name] result = await self.sessions[owner].call_tool(block.name, block.input)

Watch for name collisions: two servers can both expose a tool called search. Hosts usually namespace by server to keep them apart.

Part 3 · covers official client error handling, spec error semantics

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.

Three layers, three fixes 1 · Protocol error JSON-RPC error object, no result. Code -32602. 2 · Tool failure result with isError:true. Tool ran, work failed. 3 · Transport drop No message at all. Session is dead. Fix: correct the request - unknown tool, bad params. Fix: read the error text, handle in your app logic. Fix: reconnect + retry, with backoff. Retries belong only on layer 3. Retrying a layer-1 error just repeats the same rejection. Wrap call_tool in try/except; inspect result.isError before trusting the content.
🔍 Click to zoom - the failure layers and where retries actually help
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.

defensive call - all three layers try: result = await self.session.call_tool(name, args) # layer 3 dies here except Exception as e: return f"Transport/protocol failure: {e}" # layers 1 + 3 if getattr(result, "isError", False): # layer 2 return f"Tool ran but failed: {result.content[0].text}" return result.content[0].text
Demo 1 of 2

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.

client.py · main entry point (the runnable file) import asyncio, sys async def main(): if len(sys.argv) < 2: print("Usage: python client.py <path_to_server.py>") sys.exit(1) client = DaybreakClient() try: await client.connect(sys.argv[1]) while True: query = input("\nAsk Daybreak: ").strip() if query.lower() == "quit": break print("\n" + await client.process_query(query)) finally: await client.cleanup() if __name__ == "__main__": asyncio.run(main())
Real world

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.

Demo 2 of 2

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.

If the local model refuses to call the tool Small models sometimes answer from guesswork instead of calling a tool. Tighten the tool description, keep the question concrete ("use query_sales for 2026-03"), and prefer a tool-tuned model. This is a model-quality issue, not an MCP issue - your wire traffic is identical.
Homework

Try it yourself - this week ◐ 30-45 min total

Source material

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:

✓modelcontextprotocol.io - "Build an MCP client" (Python)ClientSession + stdio_client, chatbot loop, tool_use → call_tool
✓DeepLearning.AI - "Creating an MCP Client" + "Chatbot Example"Sessions, the query loop, feeding tool results back
✓Anthropic Academy - "Implementing a client"Client responsibilities, connection + discovery + routing
◐Ollama Python docs - tool callingThe no-API-key path; official Ollama examples stay linked
Check yourself

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.

Builder session 5 cheat sheet · pin this

Client owns four jobsConnection, session, discovery, routing. The server is passive; the client drives.
Two SDK piecesstdio_client launches the server + returns streams; ClientSession wraps them in JSON-RPC.
Open onceinitialize() then list_tools() run per connection; call_tool repeats per turn. Close with AsyncExitStack.
The loopuser msg → model + tools → tool_use → session.call_tool → tool_result → model → answer.
Claude vs OllamaSame MCP half. Claude: block.type=="tool_use". Ollama: message.tool_calls, function.name/arguments. No key locally.
Three failure layers1 protocol error (JSON-RPC error) · 2 tool failure (isError:true) · 3 transport drop. Retry only layer 3.
Multi-serverOne session per server; merge tool lists; route each call to the owning session; watch name collisions.
The payoffuv run client.py project/server.py → "how did March look?" → $150.00 from your own code.