Lesson 2 - Connecting to MCP Servers

Welcome to Connecting to MCP Servers

In Lesson 1 you saw a list of tools appear from a server and learned the host/client/server model behind it. Now you’ll write the code that produces that list. By the end you’ll have a working MCP client in Python that launches a server, shakes hands with it, asks what tools it offers, and calls one — the exact sequence a host runs under the hood.

We’ll use the official MCP Python SDK for the client and a tiny FastMCP server so this lesson runs on its own. The next lesson goes deeper on building servers; here the server is just something for the client to talk to.

By the end of this lesson, you will be able to:

  • Explain MCP transports and why stdio is the default for local servers
  • Launch a server and open a session with the Python MCP client
  • Discover a server’s tools with list_tools and call one with call_tool
  • Describe the Anthropic MCP connector as the production path for remote servers

You’ll need the mcp and anthropic packages installed. Let’s begin.


Transports: How a Client Reaches a Server

Before any tool call, the client and server need a channel to talk over. MCP defines two main transports:

  • stdio — the client launches the server as a local subprocess and talks to it over standard input and output. The two processes are on the same machine; messages flow through pipes. This is the simplest setup and what most local tools (including Claude Desktop’s local servers) use.
  • HTTP / SSE — the server runs somewhere reachable over the network, and the client connects to a URL. This is how remote MCP servers work — a server someone else hosts that many clients can share.

This lesson focuses on stdio, because it needs nothing more than Python and a server file on your own machine. You write a server, point the client at it, and the client starts it for you. No ports, no deployment, no URLs.

Why stdio is the default for learning

With stdio there’s nothing to host. The client owns the server process — it spawns it when the session opens and shuts it down when the session closes. That makes the whole tool-use loop reproducible on one laptop, which is exactly what you want while you’re learning the protocol.


A Minimal Server to Connect To

So the client has something to talk to, here’s a small server written with FastMCP, the high-level server API in the MCP SDK. Save it as server.py. The @mcp.tool() decorator turns a plain Python function into an MCP tool — its name, docstring, and type hints become the tool’s name, description, and input schema automatically.

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

mcp = FastMCP("math-tools")

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

@mcp.tool()
def word_count(text: str) -> int:
    """Count the number of words in a piece of text."""
    return len(text.split())

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

That’s a complete server. mcp.run() with no arguments starts it on the stdio transport, waiting for a client to connect over standard input and output. You don’t run this file yourself — the client will launch it. Lesson 3 unpacks how servers like this are built; for now, treat it as the thing on the other end of the wire.


The Client, Step by Step

Now the main event: the client. We’ll build it piece by piece, then run the whole thing. Every step below is part of one async function.

Why this is asynchronous

MCP clients are asynchronous. A client spends most of its time waiting — for the server to start, for a handshake reply, for a tool result to come back. async/await lets the program wait on those without blocking, which is why the SDK’s client API is built on it. In practice this means three things: your client logic lives inside an async def function, you await each call to the server, and you start everything with asyncio.run(...). Don’t overthink it — follow the shape and it works.

Describe how to launch the server

First, tell the client how to start the server. StdioServerParameters is just a description of a command to run — the program and its arguments:

from mcp import StdioServerParameters

server_params = StdioServerParameters(
    command="python",        # the program to run
    args=["server.py"],      # its arguments — your server file
)

This launches python server.py as a subprocess. (In your own code you can use sys.executable instead of the string "python" to be sure you get the same interpreter that’s running the client.) Nothing has started yet — this is a recipe, not an action.

Start the server and open a session

Now actually launch it. stdio_client(...) spawns the subprocess and hands you a read and a write stream — the two ends of the pipe. You wrap those in a ClientSession, which speaks the MCP protocol for you:

from mcp import ClientSession
from mcp.client.stdio import stdio_client

async with stdio_client(server_params) as (read, write):
    async with ClientSession(read, write) as session:
        ...

The async with blocks matter: they guarantee the subprocess is cleaned up and the session is closed when you’re done, even if something goes wrong inside. Everything that follows happens inside that inner block, where session is live.

The handshake

A new session isn’t ready to use until both sides have introduced themselves. That’s the handshake, and you trigger it with one call:

await session.initialize()

initialize() exchanges version and capability information between client and server. Until it completes, the server won’t answer tool questions. After it returns, the session is ready — you can discover and call tools.

Discovery: ask what the server offers

This is the heart of the protocol. The client doesn’t hard-code which tools exist — it asks:

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

list_tools() returns an object with a .tools list. Each tool carries the name, description, and input schema the server defined. Run against our server.py, this prints:

add: Add two numbers and return the sum.
word_count: Count the number of words in a piece of text.

The client knew nothing about add or word_count in advance — it learned them at connection time. That’s discovery, and it’s what lets a host connect to a server it’s never seen and immediately know what it can do.

Calling a tool

To run a tool, give call_tool the tool’s name and a dictionary of arguments matching its schema:

result = await session.call_tool("add", {"a": 7, "b": 5})
print(result.content[0].text)

This prints:

12.0

Reading the result

Notice we wrote result.content[0].text, not just result. A tool result isn’t a bare value — it’s a list of content blocks, the same shape you saw for model messages in earlier modules. For a simple tool the list has one text block, so result.content[0].text is the answer as a string (here, "12.0"). A tool that returned an image or multiple pieces would put each in its own block. There’s also result.isError, which is False on success — handy when you want to check whether a call failed.

The full client

Here’s everything assembled into one runnable program:

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

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()          # handshake

            print("=== Tools discovered ===")
            tools = (await session.list_tools()).tools
            for tool in tools:
                print(f"  {tool.name}: {tool.description}")

            print("\n=== Calling add(7, 5) ===")
            result = await session.call_tool("add", {"a": 7, "b": 5})
            print(result.content[0].text)

asyncio.run(main())

Running it produces:

=== Tools discovered ===
  add: Add two numbers and return the sum.
  word_count: Count the number of words in a piece of text.

=== Calling add(7, 5) ===
12.0

That’s a complete MCP client: it launched a server, completed the handshake, discovered two tools it had never seen, and called one. Everything a host does to make a server’s tools available to a model starts with exactly this sequence.


The Production Path: The Anthropic MCP Connector

The stdio client above is perfect for local development and for understanding the protocol. But in production you often want Claude to call a remote MCP server’s tools directly — a server someone has deployed at an HTTPS URL — without you writing the discovery-and-call loop yourself.

For that, the Anthropic Messages API has an MCP connector. You hand client.beta.messages.create(...) a list of MCP servers and a matching mcp_toolset, and Claude connects to those servers server-side, discovers their tools, and calls them as part of generating its response:

from anthropic import Anthropic

client = Anthropic()  # reads ANTHROPIC_API_KEY from the environment

response = client.beta.messages.create(
    model="claude-haiku-4-5",
    max_tokens=1024,
    betas=["mcp-client-2025-11-20"],
    mcp_servers=[
        {
            "type": "url",
            "url": "https://your-server.example.com/mcp",
            "name": "my-tools",
        }
    ],
    tools=[
        {"type": "mcp_toolset", "mcp_server_name": "my-tools"}
    ],
    messages=[
        {"role": "user", "content": "Use my-tools to add 7 and 5."}
    ],
)

A few things to read in that call:

  • betas=["mcp-client-2025-11-20"] opts into the connector feature.
  • mcp_servers declares the remote server: type is "url", url is the server’s HTTPS endpoint, and name is a label you choose.
  • tools must include an mcp_toolset entry whose mcp_server_name matches a name in mcp_servers. Both halves are required — declaring the server without the toolset (or vice versa) is rejected.
  • model="claude-haiku-4-5" is the model that decides when to call those tools.

This snippet is shown for reference, not run here

The MCP connector requires a reachable, deployed MCP server URL — there’s no local subprocess to spawn. https://your-server.example.com/mcp is a placeholder; pointing the connector at it would just fail to connect. Because deploying a public server is out of scope for this course, all of our hands-on work uses the local stdio client above. Reach for the connector once you have a real remote server to call.

So you now have both paths: the stdio client for learning and local work (which you’ll keep using), and the MCP connector for production, when Claude should call a deployed server’s tools on your behalf.


Practice Exercises

Exercise 1: Call the other tool

Using the full client.py above, add a second call that runs word_count on the text "model context protocol is an open standard" and prints the result. What number do you expect, and how do you read it off the result?

Hint

Call await session.call_tool("word_count", {"text": "model context protocol is an open standard"}), then print result.content[0].text. The text has 7 words, so the result is 7. The argument key ("text") must match the parameter name in the server’s word_count function.

Exercise 2: Order the steps

Put these client steps in the correct order: call_tool, initialize, stdio_client, list_tools. Why can’t discovery happen before the handshake?

Hint

The order is: stdio_client (launch the server and get the streams) → initialize (handshake) → list_tools (discovery) → call_tool (run a tool). list_tools can’t come first because the server won’t answer protocol requests until initialize() has completed the version/capability exchange.

Exercise 3: Connector or stdio?

For each scenario, say whether you’d use the stdio client or the MCP connector: (a) experimenting on your laptop with a server you just wrote, (b) letting Claude call tools on a teammate’s MCP server deployed at an HTTPS URL.

Hint

(a) → stdio client: it’s local, there’s no URL, and the client launches the server as a subprocess. (b) → MCP connector: the server is remote and reachable at a URL, so Claude can connect to it server-side via mcp_servers + mcp_toolset. The deciding factor is local subprocess vs deployed URL.


Summary

You built a working MCP client. Over stdio, the client launches a server as a subprocess, and a ClientSession speaks the protocol for you. The sequence is always the same: stdio_client(...) starts the server and gives you read/write streams, await session.initialize() performs the handshake, await session.list_tools() discovers the tools the server exposes, and await session.call_tool(name, args) runs one — with the answer in result.content[0].text. The whole thing is asynchronous because the client spends its time waiting on the server. For production, the Anthropic MCP connector (mcp_servers + mcp_toolset, behind the mcp-client-2025-11-20 beta) lets Claude call a remote server’s tools directly, given a deployed URL.

Key Concepts

  • Transport — the channel between client and server; stdio (local subprocess) or HTTP/SSE (remote URL).
  • StdioServerParameters — a description of how to launch the server (command + args).
  • initialize() — the handshake; the session isn’t usable until it completes.
  • list_tools() — discovery; returns the server’s tools (.tools) at connection time.
  • call_tool(name, args) — runs a tool; read the result from .content[0].text.
  • MCP connector — the Messages API path that lets Claude call a remote MCP server’s tools directly.

Why This Matters

Every MCP-aware host runs this exact client sequence under the hood — spawn or connect, handshake, discover, call. Understanding it means you can debug why a tool isn’t showing up, build your own host logic, and choose the right path (local stdio vs remote connector) for the job. It’s the bridge between knowing what MCP is and using it in real systems.


Next Steps

Continue to Lesson 3 - Building Tools Over MCP

Write your own FastMCP server: define tools, run it on stdio, and call them from a client.

Back to Module Overview

Return to the Model Context Protocol module overview


Continue Building Your Skills

You can now connect to a server and call its tools the way a host does. Next you’ll switch sides and build the server — turning your own Python functions into MCP tools that any client can discover and call.