9Model Context Protocol

Implementing a client

6 min read1,108 words

The MCP server is done. The inspector confirms the tools work. Now you need an MCP client that your chat application can call to discover those tools and invoke them — without touching stdio pipes, MCP protocol messages, or SDK session management directly. This lesson builds a small MCPClient wrapper that handles the boilerplate and exposes exactly two methods your application needs.

What the client wraps

The MCP Python SDK provides a ClientSession class that handles the low-level session management — connecting, message passing, protocol handshakes. It works, but it's fiddly: you have to manage session lifetimes, remember to clean up resources, and deal with async context managers directly every time you want to list or call a tool.

Wrapping ClientSession in a small custom class gives you:

  • Automatic resource cleanup — entering and exiting the session is handled for you.
  • A stable surface area — two methods (list_tools and call_tool) that your application calls and nothing more.
  • Isolation from SDK version drift — when the SDK evolves, you have one place to update.

The class is thin. Its job is to be the only piece of your app that knows what a ClientSession is.

The two methods that matter

Your application needs the client to do exactly two things:

  • list_tools() — fetch the list of tools the server exposes, so you can pass them to Claude in the tools=[...] parameter of client.messages.create().
  • call_tool(tool_name, tool_input) — execute a specific tool with the arguments Claude provided in a tool_use block.

Everything else the session supports — resources, prompts, other message types — can be added to the wrapper as you need them. For now, start with these two.

list_tools — discovering what the server offers

from mcp import types

async def list_tools(self) -> list[types.Tool]:
    result = await self.session().list_tools()
    return result.tools

Step by step:

  • self.session() returns the underlying ClientSession managed by the wrapper (initialised when the MCPClient is entered as a context manager).
  • await self.session().list_tools() sends a ListToolsRequest over the wire and awaits the ListToolsResult.
  • return result.tools unwraps the result and hands back just the list of tools — which is what the caller actually cares about.

The return type is a list of types.Tool objects from the MCP SDK. Each has a name, description, and inputSchema — the same shape Claude's tools=[...] parameter expects, so you can pass it through with minimal translation.

call_tool — executing a tool on the server

async def call_tool(
    self, tool_name: str, tool_input: dict
) -> types.CallToolResult | None:
    return await self.session().call_tool(tool_name, tool_input)

Even simpler:

  • tool_name — the name Claude sent in its tool_use block.
  • tool_input — the arguments dict Claude provided.
  • The session's call_tool method handles sending a CallToolRequest, awaiting the result, and returning the full CallToolResult.

The result includes whatever the server tool returned — text, structured data, error information if the tool raised. Your application converts that result into a tool_result block before handing it back to Claude in the next request.

Why async?

MCP client operations are asynchronous because the underlying protocol is inherently I/O-bound — you're waiting on a subprocess or a network socket. Making the client sync would mean blocking your entire application on every tool call, which is fine in a single-user CLI demo but terrible for any real server. Keeping the client async matches the shape of the protocol and matches what you'd want in production.

Your main application loop needs to be async as well. If you're using asyncio.run(main()) as your entry point, everything flows cleanly — the client methods are awaited, the session is managed by async context managers, and cleanup happens on the way out.

The test harness

The SDK provides a simple way to exercise the client without involving Claude:

import asyncio

async def main():
    async with MCPClient(
        command="uv",
        args=["run", "mcp_server.py"],
    ) as client:
        result = await client.list_tools()
        print(result)

if __name__ == "__main__":
    asyncio.run(main())

Three things happening here:

  • MCPClient(command="uv", args=["run", "mcp_server.py"]) tells the client how to spawn the MCP server as a subprocess. In stdio mode, the server process is a child of the client process.
  • async with ... as client enters the client context. On entry, the session is opened; on exit, it's cleaned up — subprocess killed, pipes closed, no leaks. This is why you wrap ClientSession in a custom class — you get this behaviour for free.
  • await client.list_tools() does the round trip and prints the discovered tools.

Run this test and you should see your two tools — read_doc_contents and edit_document — printed with their names, descriptions, and input schemas. That confirms the client is talking to the server correctly.

Wiring the client into the full flow

Once list_tools and call_tool both work, the full request flow from the MCP-clients lesson becomes real code:

  1. Before calling Claude, call client.list_tools() to get the current tool list.
  2. Pass that list as the tools= argument in client.messages.create().
  3. When Claude returns a tool_use block, extract name and input, and call client.call_tool(name, input).
  4. Format the result as a tool_result block and include it in the next API call to Claude.

Asking the chatbot "What is in the report.pdf document?" now does the complete dance: your code lists the tools, Claude decides to call read_doc_contents, the client invokes the server tool, the server returns the document content, and Claude composes a natural-language answer about the 20-metre condenser tower.

The MCPClient wrapper is the load-bearing piece — it's the one abstraction that hides the session management and the protocol ceremony so the rest of your application can treat MCP servers as a simple async API with two methods.

Key Takeaways

  • 1Wrap the SDK's ClientSession in a custom MCPClient class to get automatic resource cleanup, a stable surface area, and isolation from SDK version drift.
  • 2The only two methods your application needs from the wrapper are list_tools() to discover available tools and call_tool(name, input) to execute one.
  • 3list_tools() returns a list of types.Tool objects whose shape matches what Claude's tools=[...] parameter expects, so you can pass them through with minimal translation.
  • 4call_tool() forwards the tool name and input dict to the session and returns the full CallToolResult, which your application then converts into a tool_result block for Claude.
  • 5MCP client operations are async because the protocol is I/O-bound — keep your main loop async too so awaits and context managers compose cleanly.
  • 6Use an async context manager (async with MCPClient(...) as client) to spawn the server subprocess, run your test, and clean up on exit without leaks.