Home Blog Resume Contact Ask AI About Me
Home/Blog/How to Build an MCP Client in Python That Cal…
How toLLM EngineeringMCPPythonAI agents

How to Build an MCP Client in Python That Calls Tools

9 min readBy Miloš Mitrović

Almost every Model Context Protocol tutorial builds a server. The client, the piece that actually opens the connection, discovers what a server offers, and calls its tools, gets far less attention, and it is the half you need when you want your own agent to talk to an existing MCP server. The short version: install the mcp SDK, wrap a stdio transport in a Client, then await its list_tools() and call_tool() methods inside one async with block. This guide walks the full path in Python, from an empty directory to a client that drives a real server and feeds the results to Claude.

Key Takeaways

  • Short answer: install the mcp SDK (version 2.0.0 or higher), open a server with Client(stdio_client(params)), then call await client.list_tools() and await client.call_tool(name, args).
  • The Client object owns the entire connection lifecycle inside a single async with block. There is no manual connect or close to remember.
  • The current protocol version is 2026-07-28. stdio suits a local server you launch as a subprocess; streamable HTTP suits a remote server you reach over the network.
  • A tool result comes back as a list of content blocks plus an is_error flag. Check the flag rather than expecting a failing tool to raise.
  • Clients are language-agnostic. The same Python client talks to a server written in Python, TypeScript, or anything else that speaks MCP.

What You Need Before You Start

Keep the prerequisites small. You need Python 3.10 or newer, the uv package manager for a clean virtual environment, and the official Python MCP SDK at version 2.0.0 or higher, which the official Python client quickstart lists as a hard requirement for the current API.

You also need a server to talk to. Any MCP server works, but the fastest way to have one on hand is to spin up a small one first; my walkthrough on building an MCP server in Python with FastMCP gives you a running target in a few minutes. If you plan to hand tool results to a model, keep an Anthropic API key ready from the Anthropic Console.

Build a Python MCP Client That Lists and Calls Tools

These steps produce a working client end to end. Follow them in order and you will have a script that connects, discovers tools, and runs one.

  1. Create the project and install the SDK. uv gives you an isolated environment and pulls the packages you need in one command.
    uv init mcp-client
    cd mcp-client
    uv venv
    source .venv/bin/activate
    uv add mcp anthropic python-dotenv
    touch client.py
  2. Import the client pieces. Three symbols do the work: Client is the connection, StdioServerParameters describes the subprocess to launch, and stdio_client turns that description into a transport.
    import asyncio
    import sys
    
    from mcp import Client, StdioServerParameters
    from mcp.client.stdio import stdio_client
  3. Describe the server process. A stdio client launches the server as a child process and speaks to it over standard input and output, so it needs to know what to run.
    def server_params(path: str) -> StdioServerParameters:
        command = "python" if path.endswith(".py") else "node"
        return StdioServerParameters(command=command, args=[path])
  4. Open the connection and discover tools. Entering the async with block launches the server and agrees a protocol version with it. list_tools() returns the server's tool catalog, each with a name, description, and input_schema.
    async def main() -> None:
        params = server_params(sys.argv[1])
        async with Client(stdio_client(params)) as client:
            listing = await client.list_tools()
            for tool in listing.tools:
                print(tool.name, "-", tool.description)
  5. Call a tool and read the result. call_tool takes the tool name and an arguments dict that matches the tool's input_schema. The result carries a list of content blocks; narrow to text blocks before reading .text.
            result = await client.call_tool(
                "get_forecast", {"city": "Belgrade"}
            )
            if result.is_error:
                print("tool failed:", result.content)
            else:
                for block in result.content:
                    if block.type == "text":
                        print(block.text)
  6. Run it against your server. Pass the path to a server script as the one argument.
    python client.py path/to/server.py
    You should see the tool list print, followed by the output of the call. That is a complete MCP client.

How Does the Connection Actually Work?

The single most useful thing to understand is that Client owns the whole lifecycle. StdioServerParameters is configuration, not a connection; stdio_client() turns it into a transport; and Client opens that transport when you enter its async with block and shuts it down, subprocess included, when you leave. There is no connect and close pair to balance by hand, which removes a whole class of leaked-process bugs.

Under that block, MCP runs a JSON-RPC 2.0 exchange. The MCP architecture overview describes how the client and server negotiate a protocol version and capabilities on connect, so each side knows which primitives (tools, resources, prompts) the other supports before any call goes out. Your code never writes that handshake; the Python MCP SDK handles it when the block opens.

Which Transport Should the Client Use?

stdio is the right default for a local server you control. When the server lives on another machine, you want streamable HTTP instead. The architecture overview names stdio and streamable HTTP as the two current transports, with SSE kept only for older servers. Match the client transport to how the server is deployed.

TransportWhere the server runsHow the client reaches itAuthStatus
stdioLocal, launched as a subprocessstdio_client(StdioServerParameters(...))OS process boundaryCurrent, best for local
Streamable HTTPRemote, over the networkHTTP POST with optional Server-Sent Events for streamingBearer tokens, API keys, OAuthCurrent, best for remote
SSERemote (older servers)Long-lived Server-Sent Events streamHTTP headersLegacy, being replaced by streamable HTTP

The client code changes very little between them. You swap the transport factory you hand to Client and, for a remote server, add the auth headers the server expects. Everything downstream, list_tools() and call_tool(), stays identical, because the JSON-RPC data layer is the same across transports.

How Do I Feed Tool Results to a Model?

A client that prints tool output is useful for testing. The point of most clients, though, is to let a model decide which tool to call. The pattern is a loop: ask the model with the tool list attached, run whatever tool it picks, hand the result back, and ask again.

from anthropic import Anthropic
from dotenv import load_dotenv

load_dotenv()
anthropic = Anthropic()
MODEL = "claude-sonnet-5"

async def ask(client, query: str) -> str:
    listing = await client.list_tools()
    tools = [{
        "name": t.name,
        "description": t.description,
        "input_schema": t.input_schema,
    } for t in listing.tools]

    messages = [{"role": "user", "content": query}]
    reply = anthropic.messages.create(
        model=MODEL, max_tokens=1000, messages=messages, tools=tools
    )

    for block in reply.content:
        if block.type == "tool_use":
            result = await client.call_tool(block.name, block.input)
            # send result.content back as a tool_result and call the model again
            return f"called {block.name}"
    return "".join(b.text for b in reply.content if b.type == "text")

The key detail is that the tool schema the server advertises through input_schema is exactly the shape the Anthropic API expects under input_schema, so you pass it straight through. When the model returns a tool_use block, its input already conforms to that schema, which is why call_tool accepts it without translation.

Troubleshooting the Errors You Will Actually Hit

A short list covers most of what goes wrong the first time.

  • The client hangs or the server is never detected. This is almost always a bad path or wrong command in StdioServerParameters. Use an absolute path to the server script, confirm the script runs on its own (python server.py should start and wait), and check the command matches the file type (python for .py, node for .js).
  • ModuleNotFoundError: No module named 'mcp'. The virtual environment is not active, or you installed into a different one. Re-run source .venv/bin/activate and uv add mcp, then confirm with python -c "import mcp".
  • A protocol version error on connect. An UnsupportedProtocolVersionError means the server speaks an older or newer spec than your SDK. Upgrade both sides so they share a version; the current one is 2026-07-28. Pinning the client to mcp>=2.0.0 avoids the stale-SDK case.
  • AttributeError on the tool object. The Python SDK exposes the schema as tool.input_schema (snake case), even though the wire protocol field is inputSchema. Read the attribute, not the raw JSON key.
  • A tool call returns something odd but no exception. A failing tool does not raise in the client; it sets result.is_error. Check that flag on every call and read result.content for the message before assuming success.

When the failure is on the server side rather than the client, drive the server directly with the MCP Inspector to confirm it lists and runs tools correctly before you blame your client code.

What to Do Next

Once tool calls work, extend the client in the order you will need the features:

  • Read a server's resources and prompts, not only its tools, with list_resources() and list_prompts(). The primitives are documented in the architecture overview.
  • Point the client at a remote server over streamable HTTP and add the auth the server requires. The Python SDK repository ships the HTTP transport and examples.
  • Test your client against a server in another language, such as one built with the TypeScript SDK, to prove the client is genuinely transport and language agnostic.
  • Study the complete reference client for the full chatbot loop, including multi-turn tool results.

Sources

M
Miloš Mitrović
Revenue Operations & AI Automation

Have a question or a project?

Whether it is about this post or a system you want built, I'm happy to talk.

Get in touch

404

Post not found. It may have been moved or the link is incorrect.

← Back to the blog
Ask AI About Me
Clicking an assistant copies the prompt and opens it: ready to run in ChatGPT, Perplexity, and Grok; in Claude, Gemini, or Copilot press Ctrl+V (Cmd+V on Mac) to paste. Use Copy prompt for any other AI. The assistant reads my site, so it needs web access.
Summarize with AI
ChatGPT, Perplexity, and Grok open with the prompt ready to run. Claude, Gemini, and Copilot open a chat with the prompt copied; press Ctrl+V (Cmd+V on Mac) to paste. The full text is included, so it works even without web access.