The Half Nobody Talks About
When I first started digging into MCP, every tutorial I found was about building a server. "Here's how to expose your database!" "Here's how to wrap your API!" Cool stuff. But I kept thinking — okay, something has to talk to those servers. Where does that side of the story live?
That's the client. And it turns out, building one is not as scary as it sounds. If you've already read the "Building Your First MCP Server from Scratch" article, you've seen one half of the handshake. This is the other half — the part that reaches out, discovers what tools are available, and actually calls them.
By the end of this article you'll have a working MCP client in Python that can connect to any compliant MCP server, list its tools, and invoke them. Let's go.
What Does an MCP Client Actually Do?
Think of the client as the customer walking into a restaurant. The server is the kitchen — it has a menu of tools it can run. The client's job is to walk in, ask for the menu, pick something, and wait for the food to arrive.
More precisely, an MCP client does four things:
1. Opens a transport connection — this is usually stdio (talking to a local process over stdin/stdout) or HTTP with SSE. The transport layer article covers the differences in depth, but for today we'll use stdio because it's the simplest to test locally.
2. Initializes the session — both sides say hello, exchange versions, and agree on what capabilities they support. This is called the handshake.
3. Lists available tools — the client sends a tools/list request and gets back a JSON description of every tool the server exposes, including names, descriptions, and input schemas.
4. Calls tools — the client sends a tools/call request with the tool name and arguments, and gets back the result.
That's genuinely the whole loop. Once you see it written out in code it clicks fast.
Setting Up Your Environment
We're going to use the official Python MCP SDK, which Anthropic maintains. It handles all the JSON-RPC plumbing so you don't have to write message framing from scratch.
# Create a fresh project folder
mkdir mcp-client-demo && cd mcp-client-demo
# Set up a virtual environment
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# Install the MCP SDK
pip install mcp
→ Successfully installed mcp anthropic httpx ...You also need a server to connect to. The simplest option is to grab a basic one you already have, or create a tiny one-file echo server. For this walkthrough I'll assume you have a server script at server.py that exposes at least one tool. If you built one in the "Building Your First MCP Server" article, use that.
Writing the Client — Step by Step
Create a file called client.py. We'll build it up in pieces so nothing feels like it comes out of nowhere.
First, the imports and the connection setup:
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
# Tell the SDK how to launch your server
server_params = StdioServerParameters(
command="python",
args=["server.py"],
)StdioServerParameters is just a config object that says "to start the server, run this command with these arguments." The SDK will spawn the process for you and wire up stdin/stdout automatically. No networking required.
Now the main async function that does everything:
async def main():
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
# Step 1: handshake
await session.initialize()
# Both sides have said hello ✓
# Step 2: discover tools
tools_response = await session.list_tools()
print("Available tools:")
for tool in tools_response.tools:
print(f" - {tool.name}: {tool.description}")
# Step 3: call a tool
result = await session.call_tool(
"add_numbers",
arguments={"a": 7, "b": 13}
)
print(f"Result: {result.content}")
asyncio.run(main())When I ran this for the first time against a simple math server I'd built, seeing "Available tools: - add_numbers: Adds two numbers together" print out in my terminal felt genuinely magical. The client had no hardcoded knowledge of what the server could do — it discovered it dynamically at runtime.
Why async?
MCP uses async I/O throughout because real clients often need to handle multiple tool calls, streaming responses, or concurrent sessions. The SDK enforces this pattern so your code scales naturally when things get more complex.
Reading the Tool Response Properly
The result.content you get back from call_tool is a list of content blocks — usually TextContent objects. Here's how to extract the text cleanly:
result = await session.call_tool("add_numbers", {"a": 7, "b": 13})
for block in result.content:
if block.type == "text":
print(f"Tool returned: {block.text}")
→ Tool returned: 20This pattern matters because a tool could return multiple content blocks, or eventually image blocks, or other types. Checking block.type keeps your code future-proof.
Connecting to an HTTP Server Instead
If your server is running over HTTP with SSE (the other main transport), the swap is almost trivial. You just change the connection setup:
from mcp.client.sse import sse_client
async def main():
async with sse_client("http://localhost:8000/sse") as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
# everything else is identicalThe rest of your code — list_tools, call_tool, reading content blocks — stays exactly the same. That's one of the things I genuinely like about the MCP SDK design: the transport is pluggable but the session API is stable.
Which transport should you use?
Use stdio when the client and server run on the same machine and you control both. Use HTTP/SSE when your server is remote, shared, or running as a standalone service. The MCP Transport Layers article has a deeper breakdown if you need it.
Putting It Together: A Practical Example
Here's a slightly more real version — a client that loops over all available tools and prints their schemas, then prompts you to pick one and run it. Think of it as a mini MCP shell:
import asyncio, json
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 = (await session.list_tools()).tools
for i, t in enumerate(tools):
print(f"[{i}] {t.name} — {t.description}")
idx = int(input("Pick a tool number: "))
chosen = tools[idx]
# Ask the user for each required argument
args = {}
props = chosen.inputSchema.get("properties", {})
for key in props:
args[key] = input(f" Enter {key}: ")
result = await session.call_tool(chosen.name, args)
for block in result.content:
if block.type == "text":
print(f"Result: {block.text}")
asyncio.run(main())This tiny shell is actually useful for debugging your servers. When I was building out a server for a side project, I kept this running in one terminal to poke at tools while I edited the server in another. Faster than writing a test every time.
Common Mistakes (I Made All of These)
Forgetting to call initialize(). If you skip the handshake and jump straight to list_tools, you'll get a confusing error about the session not being ready. Always initialize first.
Not awaiting everything. Every MCP call is async. If you call session.list_tools() without await, you get a coroutine object, not results.
Passing the wrong argument types. Tool input schemas often expect specific types — int not str. Check the schema before you call, especially if you're getting input from the user like in the shell example above.
Assuming the server process starts instantly. On some machines, especially if the server has slow imports, the first tool call can fail because the process hasn't finished starting. A small asyncio.sleep(0.5) after initialize() can save you a debugging headache.
Where to Go From Here
Once you've got a basic client working, the natural next step is hooking an LLM into it. Instead of a human picking which tool to call, you let the model decide — passing it the tool list from list_tools as context, running the tool it selects, and feeding the result back in. That's essentially what Claude does internally when you give it MCP access.
If you want to go deeper on the protocol itself, check out the MCP Sampling article which covers how the server can ask the client to make LLM calls on its behalf — that's where things get really interesting.
But for now? Run your client, connect it to a server, and watch the handshake happen. That moment when you see your own app dynamically discover tools it's never seen before — that's the thing that makes MCP click.
More tutorials in this category, or explore the full field guide.