A client from scratch
Hosts feel magical until you have written the twenty lines that do their job. This lesson builds the client side twice: first bare — connect, discover, call — then with a model in the loop, which is where the one genuinely important pattern in MCP client design lives.
The bare client
Using the official Python SDK (mcp 2.0.0):
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
params = StdioServerParameters(
command="uv", args=["run", "--with", "fastmcp", "python", "paper_trail.py"])
async def main():
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
init = await session.initialize()
print(f"connected to {init.server_info.name} "
f"(protocol {init.protocol_version})")
tools = await session.list_tools()
print("tools:", [t.name for t in tools.tools])
result = await session.call_tool("order_status", {"order_id": "PT-1041"})
print("result:", result.content[0].text)
asyncio.run(main())Run against the Paper Trail server, captured:
connected to paper-trail (protocol 2025-11-25)
tools: ['order_status', 'draft_refund']
result: {"customer":"Meera S","title":"The Covenant of Water","status":"shipped","carrier":"Delhivery","eta":"2026-08-05"}Twenty lines: spawn the subprocess, open the session, handshake, discover, call. Note the layering — stdio_client owns the transport, ClientSession owns the protocol; swap the first line to an HTTP transport and everything below session survives unchanged.
Look closely at the protocol version. Lesson 4's raw client proposed 2025-06-18 and the server agreed; this SDK proposed 2025-11-25 — a newer revision — and the same server agreed to that instead. Two clients, two negotiated versions, one unmodified server, both sessions fully functional. That is capability negotiation doing exactly what it promised, observed in the wild rather than asserted.
Putting a model in the loop
The bare client hard-codes its decisions. A host delegates them to a model, and essentially every real implementation converges on the same shape — worth learning by name because you will meet it everywhere: the two-pass pattern.
Pass one — decide. Send the model the conversation plus the discovered tools (name, description, schema — translated into your LLM API's tool format). The model either answers directly (done) or returns tool-use requests: which tool, what arguments.
The gap — approve and execute. For each requested call: this is where the human gate lives, in your code, before anything runs. Approved → session.call_tool(...); append the result to the conversation as a tool-result message. Denied → append a message saying it was denied, so the model knows and can adapt rather than hallucinate an outcome.
Pass two — synthesise. Send the grown conversation back with tool use disabled. The model now writes prose grounded in real results — and cannot wander off into another round of calls when you wanted an answer.
The skeleton, model-API-agnostic:
async def process_query(session, llm, history, query):
tools = to_llm_format((await session.list_tools()).tools)
history.append({"role": "user", "content": query})
first = llm.chat(history, tools=tools, tool_choice="auto")
for call in first.tool_calls or []:
if not user_approves(call.name, call.arguments):
history.append(denied_message(call))
continue
result = await session.call_tool(call.name, call.arguments)
history.append(tool_result_message(call, result.content[0].text))
final = llm.chat(history, tools=tools, tool_choice="none")
history.append({"role": "assistant", "content": final.text})
return final.textLoop pass one → gap → pass one again and you have multi-step tool use; cap the iterations, because a model that can always call one more tool sometimes will. That loop, plus memory and routing, is the honest core of every agent framework — AI Agents in Production builds the industrial version, and it will feel familiar now.
What this buys over function calling
With hardcoded function calling, adding a server-side capability means editing every client: new schema, new dispatch, redeploy. Here, to_llm_format reads whatever discovery returned this session. Add a tool to Paper Trail tonight; tomorrow's session lists three tools instead of two, the model sees three, and not one line of client code changes. You watched the mechanism in lesson 4 — this is where it pays.
Try this: run the bare client against your own server, then break it on purpose — wrong command path, then a wrong tool name — and map each failure to what you know of the lifecycle. The first fails before initialize; the second fails at tools/call with a protocol-level error, not isError. Knowing which layer refused you is most of client debugging.