teachyou.ai academy
← All posts
MCPpythonai agentsllm toolsanthropic

Building an MCP Client in Python

Pramod Dutta · Jun 28, 2026 · 13 min read

Building an MCP client in Python is the fastest way to understand how AI agents actually reach out to tools, files, and APIs instead of guessing at them from training data. The Model Context Protocol (MCP) gives you a standard way to connect an LLM-driven app to any number of servers that expose tools, resources, and prompts, and the official Python SDK makes writing that client a matter of a few dozen lines. In this guide you will build a working MCP client step by step: connect over stdio, list tools, call a tool, read a resource, and then hook the client into a real Claude API loop so the model can use those tools on its own.

What an MCP Client Actually Does

An MCP client is the piece of code that sits inside your application and talks to one or more MCP servers. The server is the thing that owns capabilities (a filesystem server, a database server, a Git server, your own custom server), and the client is responsible for:

  • Opening a transport connection to the server (stdio, streamable HTTP, or another transport)
  • Performing the MCP handshake (initialize) so both sides agree on protocol version and capabilities
  • Discovering what the server offers: tools/list, resources/list, prompts/list
  • Invoking those capabilities: tools/call, resources/read, prompts/get
  • Handling errors, timeouts, and cleanup when the session ends

Crucially, the client is not the LLM. The client is plumbing. The LLM (Claude, in most of the examples below) decides which tool to call based on the user's request, and the MCP client is the thing that actually executes that call against the server and hands the result back. Keeping this separation clear will save you a lot of confusion later, especially when you start juggling multiple servers in one app.

Setting Up Your Python Environment

You need Python 3.10 or newer and the official mcp package, which ships the client, server, and transport implementations in one library. A virtual environment keeps this isolated from the rest of your project.

python -m venv .venv
source .venv/bin/activate
pip install mcp anthropic python-dotenv

If you plan to test against a real server rather than writing your own, the mcp package also installs a small CLI you can use to spin up reference servers for local testing. For this walkthrough we will write a tiny server of our own so the whole example is self-contained and runnable end to end.

A Minimal MCP Server to Test Against

Before writing the client, it helps to have something real to point it at. Here is a tiny server with one tool (add_numbers) and one resource (config://app), using FastMCP, the high-level server API in the SDK.

# server.py
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("demo-server")

@mcp.tool()
def add_numbers(a: float, b: float) -> float:
    """Add two numbers and return the sum."""
    return a + b

@mcp.resource("config://app")
def get_config() -> str:
    """Return the current app configuration as a string."""
    return "theme=dark,region=us-east"

if __name__ == "__main__":
    mcp.run(transport="stdio")

Save this as server.py. You will not run it directly, the client will launch it as a subprocess over stdio, which is exactly how most local MCP integrations work in practice (this is the same pattern editors and desktop apps use to talk to local MCP servers).

Building the Core MCP Client in Python

The client side revolves around two objects: a transport (stdio in this case) and a ClientSession that wraps the transport with the MCP protocol logic. Here is the smallest useful client.

# client.py
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

server_params = StdioServerParameters(
    command="python",
    args=["server.py"],
)

async def main():
    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()

            tools_result = await session.list_tools()
            print("Available tools:")
            for tool in tools_result.tools:
                print(f"- {tool.name}: {tool.description}")

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

Run it with python client.py and you should see add_numbers printed with its docstring as the description. Two things matter here for anyone building an MCP client in Python for the first time. First, stdio_client spawns the server as a child process and gives you back a pair of read/write streams, it does not talk HTTP or open a socket. Second, session.initialize() is not optional, skipping it will cause every subsequent call to fail because the two sides never agreed on protocol version and capabilities.

Listing and Inspecting Tools

Every tool returned by list_tools() comes with a JSON Schema in inputSchema, which is exactly the format Claude's tool-use API expects. That overlap is not an accident, MCP tool descriptors were designed to map cleanly onto LLM tool-calling formats.

async def show_tool_schemas(session: ClientSession):
    result = await session.list_tools()
    for tool in result.tools:
        print(f"Tool: {tool.name}")
        print(f"  Description: {tool.description}")
        print(f"  Input schema: {tool.inputSchema}")

For our demo server this prints a schema with two required numeric properties, a and b. When you build clients against real-world servers (filesystem, database, ticketing systems) you will see much richer schemas, sometimes with nested objects and enums, and your client code never needs to hardcode any of it because you read it dynamically at connection time.

Calling a Tool from the Client

Calling a tool is a single session.call_tool(name, arguments) call. The arguments dict must match the tool's input schema.

async def call_add_numbers(session: ClientSession):
    result = await session.call_tool(
        "add_numbers",
        arguments={"a": 12, "b": 30},
    )
    for content in result.content:
        if content.type == "text":
            print(f"Result: {content.text}")

Note that result.content is a list, not a single value. MCP tool results can return multiple content blocks (text, images, embedded resources), so your client code should always iterate rather than assume content[0] is the only thing you care about. If the tool call fails on the server side, result.isError will be True and the content will typically hold a text block describing what went wrong, so check that flag before trusting the output.

Working with Resources

Resources are read-only data the server exposes, things like files, config values, or database rows, addressed by a URI. Your client lists them and reads them separately from tools.

async def read_config_resource(session: ClientSession):
    resources = await session.list_resources()
    for resource in resources.resources:
        print(f"Resource: {resource.uri} - {resource.name}")

    content = await session.read_resource("config://app")
    for item in content.contents:
        if hasattr(item, "text"):
            print(f"Config contents: {item.text}")

Resources are a good place to put anything you want the model to be able to reference without treating it as an action, application settings, documentation snippets, schema definitions. Keeping that boundary (tools do things, resources describe things) makes the rest of your client code, and the model's behavior, much easier to reason about.

Working with Prompts

MCP servers can also expose reusable prompt templates through prompts/list and prompts/get. This is less commonly used than tools and resources, but it is worth knowing your client can pull a pre-built prompt with arguments filled in rather than hardcoding prompt text in your application.

async def fetch_prompt(session: ClientSession):
    prompts = await session.list_prompts()
    for prompt in prompts.prompts:
        print(f"Prompt: {prompt.name}")

    if prompts.prompts:
        result = await session.get_prompt(
            prompts.prompts[0].name,
            arguments={},
        )
        for message in result.messages:
            print(message.role, message.content)

If your server does not define any prompts, prompts.prompts will just be an empty list, and this function is safe to call unconditionally as part of a generic client that probes whatever the server offers.

Connecting Over Streamable HTTP Instead of stdio

stdio is great for local development because the client owns the server's lifecycle. For a remote or long-running server you will want streamable HTTP instead. The client-side code changes very little, you just swap the transport.

import asyncio
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client

async def main():
    url = "https://your-mcp-server.example/mcp"
    async with streamablehttp_client(url) as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()
            tools = await session.list_tools()
            print([t.name for t in tools.tools])

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

Everything downstream of ClientSession (listing tools, calling tools, reading resources) is identical regardless of transport. That is the whole point of MCP as a protocol: your application logic does not need to know or care whether it is talking to a subprocess on your laptop or a server across the internet.

Wiring the MCP Client Into a Claude API Loop

The real payoff of an MCP client in Python is letting Claude decide which tools to call instead of hardcoding the logic yourself. The MCP tool schema maps almost directly onto the Claude API's tool format, so the bridge is short.

import asyncio
import json
from anthropic import Anthropic
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

anthropic_client = Anthropic()

def mcp_tools_to_claude_format(mcp_tools):
    return [
        {
            "name": tool.name,
            "description": tool.description or "",
            "input_schema": tool.inputSchema,
        }
        for tool in mcp_tools
    ]

async def run_agent_turn(session: ClientSession, user_message: str):
    tools_result = await session.list_tools()
    claude_tools = mcp_tools_to_claude_format(tools_result.tools)

    messages = [{"role": "user", "content": user_message}]

    response = anthropic_client.messages.create(
        model="claude-opus-4-5",
        max_tokens=1024,
        tools=claude_tools,
        messages=messages,
    )

    while response.stop_reason == "tool_use":
        messages.append({"role": "assistant", "content": response.content})
        tool_results = []

        for block in response.content:
            if block.type == "tool_use":
                mcp_result = await session.call_tool(
                    block.name,
                    arguments=block.input,
                )
                text_output = "\n".join(
                    c.text for c in mcp_result.content if c.type == "text"
                )
                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": text_output,
                    "is_error": mcp_result.isError,
                })

        messages.append({"role": "user", "content": tool_results})
        response = anthropic_client.messages.create(
            model="claude-opus-4-5",
            max_tokens=1024,
            tools=claude_tools,
            messages=messages,
        )

    final_text = "\n".join(
        block.text for block in response.content if block.type == "text"
    )
    return final_text

async def main():
    server_params = StdioServerParameters(command="python", args=["server.py"])
    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            answer = await run_agent_turn(session, "What is 47 plus 55?")
            print(answer)

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

This loop is the backbone of almost every MCP-powered agent you will build: fetch tools from the MCP session, hand them to Claude in the tools parameter, execute whatever tool_use blocks come back through the same session, feed the results back as tool_result blocks, and repeat until Claude stops asking for tools. Swap the model name for whatever Claude model you are targeting and the pattern holds.

Handling Errors and Timeouts

Production MCP clients need to expect servers to misbehave: hang, crash, or return malformed output. A few habits pay off quickly.

  • Wrap call_tool in asyncio.wait_for with a sane timeout so one slow server does not stall your whole agent loop.
  • Always check result.isError before treating tool output as trustworthy data.
  • Catch Exception broadly around session.initialize(), a server that fails to start (wrong command, missing dependency) will raise here, not later.
  • Log the raw JSON-RPC error when a call fails, MCP error objects carry a code and message that are usually more specific than a generic Python traceback.
async def safe_call_tool(session: ClientSession, name: str, arguments: dict, timeout: float = 10.0):
    try:
        return await asyncio.wait_for(
            session.call_tool(name, arguments=arguments),
            timeout=timeout,
        )
    except asyncio.TimeoutError:
        print(f"Tool call to {name} timed out after {timeout}s")
        return None
    except Exception as exc:
        print(f"Tool call to {name} failed: {exc}")
        return None

Managing Sessions and Cleanup

Both stdio_client and ClientSession are async context managers, which means the async with blocks in every example above already handle cleanup for you: closing pipes, terminating the subprocess, and cancelling any pending requests when you exit the block. The mistake to avoid is holding a ClientSession open across unrelated parts of your application by manually calling __aenter__ without a matching __aexit__. If you need a long-lived session (for example, a chat app that keeps one MCP connection open across many turns), wrap the whole app lifecycle in a single async with at startup rather than opening and closing the session per request, reconnecting for every message adds latency and, for stdio transports, respawns the server process every time.

Testing Your Python MCP Client

You do not need a live LLM to test the client layer. Since call_tool and read_resource are just async functions, you can exercise them directly against your test server with pytest-asyncio.

# test_client.py
import pytest
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

@pytest.mark.asyncio
async def test_add_numbers():
    server_params = StdioServerParameters(command="python", args=["server.py"])
    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            result = await session.call_tool("add_numbers", arguments={"a": 2, "b": 3})
            text = result.content[0].text
            assert text == "5" or text == "5.0"

Run it with pytest -v test_client.py. Testing at this layer, before Claude ever enters the picture, catches schema mismatches and server bugs early, and it runs fast enough to include in CI without needing API keys.

Common Pitfalls

  • Forgetting `await session.initialize()`. Every other call silently hangs or errors without it. It is the single most common bug in a first MCP client.
  • Assuming `tool.inputSchema` is optional. Some hand-written servers omit descriptions or fields; validate arguments client-side before calling if you are exposing this to end users.
  • Not checking `isError`. A failed tool call still returns a normal CallToolResult, it does not raise an exception by default, so skipping the check means silently feeding error text to the model as if it were real data.
  • Reconnecting per message. For stdio transports this respawns the server process every turn, which is slow and can leak zombie processes if cleanup is not handled correctly.
  • Mixing sync and async code carelessly. The entire MCP client API is async; if your app is otherwise synchronous, isolate the MCP calls behind asyncio.run() at a clear boundary rather than sprinkling asyncio.run() calls throughout.

FAQ

What is the difference between an MCP client and an MCP server? The server owns and exposes capabilities (tools, resources, prompts). The client connects to one or more servers, discovers what they offer, and invokes them on behalf of an application or LLM. A single application can run many MCP clients, one per server it talks to.

Do I need the Anthropic API to use an MCP client? No. The MCP client and server communicate over JSON-RPC independent of any LLM. You can call tools and read resources directly from Python without ever involving a model, as shown in the early examples. The LLM only comes in when you want a model to decide which tools to call.

Can one MCP client connect to multiple servers at once? Yes, but each ClientSession maps to one transport connection to one server. To use several servers, open several sessions (for example, one for a filesystem server, one for a database server) and merge their tool lists before handing them to the model, tagging each tool so you know which session to route a call back to.

Is stdio or streamable HTTP better for production? stdio is simplest for local tools your own process controls, since the client owns the server's lifecycle. Streamable HTTP is the right choice for a remote or shared server that many clients connect to independently, since it does not require spawning a subprocess per client.

Why does `list_tools()` return a JSON Schema for each tool? Because MCP was designed to interoperate with LLM tool-calling formats, and JSON Schema is the format used by Claude, and most other providers, to describe tool inputs. This lets you pass tool.inputSchema almost unchanged into the Claude API's tools parameter, as shown in the agent loop example.

What happens if a tool call raises an exception on the server? A well-behaved FastMCP server catches the exception and returns a CallToolResult with isError=True and a text block describing the failure, rather than crashing the process. Your client should always check isError before trusting the result, and treat an unexpected process crash (the stdio_client context manager exiting unexpectedly) as a separate failure mode to handle with reconnection logic.