Building an MCP client
- Source
- From the RAG & Agents Engineering course
- By
- Sourav Banerjee
- Last updated
- 9 Nov 2026
MCP server / client
A Larkspur client must discover the handbook tool before it can call it. You will separate host, session and transport responsibilities, read the initialise exchange and follow a tool definition into the model-facing schema.
Step 1: Open one client session to one server
MCP names three roles. The host is the application the user talks to, here the chatbot with its LLM. The client is the piece inside the host that holds one connection to one server. The server offers tools, resources and prompts. A host that uses three servers holds three clients, and the model never talks to any server directly: it sees definitions and asks for calls, and the host decides which client sends them.
The reference implementation wraps the 38.1 loop in an MCP_ChatBot class and adds the client:
Implementation excerpt from the configured application. The practice material supplies its models, data and session setup:
server_params = StdioServerParameters(command="uv", args=["run", "research_server.py"], env=None)
async with stdio_client(server_params) as (read, write): # starts the server as a subprocess
async with ClientSession(read, write) as session: # one session, one server
await session.initialize()
response = await session.list_tools()
self.available_tools = [{"name": t.name, "description": t.description,
"input_schema": t.inputSchema} for t in response.tools]
await self.chat_loop() # calls session.call_tool(...)
Step 2: Tie subprocess lifetime to the context manager
With stdio the client owns the server's life: stdio_client starts the process, and leaving the async with block ends the session and stops it. Nothing has to be running beforehand.
Step 3: Read the recorded handshake and call
Here is the session the client opens against the Larkspur handbook server, recorded over stdio with SDK 1.30.0. Sizes are the compact JSON of each message.
| Message | Direction | What it carries | Size |
|---|---|---|---|
initialize request |
client to server | protocolVersion: "2025-11-25", the client's capabilities (none) and clientInfo |
152 |
initialize result |
server to client | the agreed version, capabilities for tools, resources and prompts, serverInfo |
276 |
notifications/initialized |
client to server | no id and no reply: the session is open | 54 |
tools/list request |
client to server | nothing but the method | 46 |
tools/list result |
server to client | one definition, search_handbook, with input and output schemas |
608 |
tools/call request |
client to server | {"name": "search_handbook", "arguments": {"query": "How long does a refund take?"}} |
135 |
tools/call result |
server to client | refund-window and refund-timing as text, the same text in structuredContent, isError: false |
835 |
Step 4: Diagnose version, capability and error fields
Three details in that trace matter when you debug:
- The handshake fixes what the session can do. The server declared
tools,resourcesandprompts, each withlistChanged: false, so this client will not be told if the tool list changes; it has to list again. - Requests and replies pair by
id. The client numbers its requests 0, 1, 2 and the server echoes the id; the notification carries none because nothing answers it. serverInfo.versionwas1.30.0, the SDK version, because the server did not set one. Pass a version of your own if clients should be able to tell your releases apart.
Step 5: Translate the discovered schema for the model
The client also translates for the model. The reference implementation's live code runs on OpenAI and wraps each MCP definition as {"type": "function", "function": {"name": ..., "description": ..., "parameters": tool.inputSchema}}; the Anthropic version kept in comments uses input_schema: tool.inputSchema. Both read the same inputSchema, so one server serves either model. On the way back the Anthropic version passes result.content as the tool_result, and the OpenAI version passes result.content[0].text as the tool message.
Try it yourself
Trace initialise, the initialised notification, tools/list and a handbook tool call. Check which direction each message travels and where the client learns the tool schema. Leave the stdio context and verify the child process ends. An MCP session is not an already-running remote service.
Practice notebook
Run the complete implementation from the practice material after checking the trace or calculation above. Use its stated environment for live model or server calls; recorded model choices and timings are observations, not guaranteed outputs.
Run the notebooks
The code for this lesson is in these notebooks. Colab runs one in your browser: install the packages its first cell imports and set the API keys it reads. To run every notebook as written, clone the repository and use its uv environment.
| Notebook | GitHub | Colab |
|---|---|---|
| Creating an MCP client | Open on GitHub | Open in Colab |
Key Takeaways
- The host holds one client per server, and the model never reaches a server directly. It reads definitions and asks for calls; the host routes them.
- Over
stdiothe client owns the server's life:stdio_clientstarts the subprocess and leaving theasync withblock stops it. - The
initializehandshake fixes what the session can do. WithlistChanged: false, list tools again to see changes, and set your own server version so clients can tell releases apart.
Check yourself
1. A host uses three MCP servers. How many clients does it hold, and does the model ever talk to a server directly?
Three clients, each holding one connection to one server. The model never talks to a server: it sees definitions and asks for calls, and the host decides which client sends them.
2. In the recorded Larkspur session, the notifications/initialized message gets no reply. How do replies pair with requests, and why is this message different?
Requests and replies pair by id: the client numbers its requests 0, 1, 2 and the server echoes the id. notifications/initialized is a notification, so it carries no id and nothing answers it; it tells the server the session is open.
3. The server's tool list changes while a client is connected, and the client keeps offering the old list. What in the handshake explains this?
The server declared its capabilities with listChanged: false in the initialize result, so this client will not be told when the list changes. The handshake fixes what the session can do. The client has to call tools/list again to see the change.