Skip to content
Forward Deployed

Forward Deployed
Lessons and contents
MCP FoundationsLesson 4 of 8
8 lessons
Learn

Building an MCP client

Source
From the RAG & Agents Engineering course
By
Sourav Banerjee
Last updated
9 Nov 2026

Report an issue

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, resources and prompts, each with listChanged: 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.version was 1.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 stdio the client owns the server's life: stdio_client starts the subprocess and leaving the async with block stops it.
  • The initialize handshake fixes what the session can do. With listChanged: 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.

Rate your confidence and keep notes on the module page.