⌂ Home MCP Track — Building with the Model Context Protocol ⚡ MODULE 04 of 8 · MCP Track
~75 min Intermediate Lab — 75% Code
MODULE 04 · MCP Clients & Hosts

MCP Clients & Hosts

Build custom MCP clients from scratch, connect to stdio and HTTP servers, aggregate tools from multiple servers in a single host, and wire them all to Claude. Both Python and TypeScript, all code runnable.

After this module you will:
  • Explain the host/client split and why one host can hold many clients
  • Connect to a local stdio MCP server using the Python and TypeScript SDKs
  • Connect to a remote HTTP/SSE MCP server with auth headers
  • Build an MCPHost that aggregates tools from multiple servers and routes LLM calls
  • Handle the five most common client-side error scenarios with a robust wrapper class

1. Host vs Client — What's the Difference?

Analogy — Your Browser & Its Connections

BEFORE: Before you had separate applications for every website, you had one browser — one host — that could open hundreds of tabs simultaneously, each communicating with a completely different server. The browser is the host. Each individual TCP connection to a different website's IP address is a client connection.

PAIN: If the browser were the connection, you could only talk to one website at a time. Every navigation would kill your current conversation with one server before starting a new one. No tabs, no multitasking, no aggregating information from multiple sources in parallel.

MAPPING: In MCP, the host is your application (Claude Desktop, your agent, your IDE plugin) — the thing the user runs. The client is the low-level connection manager inside the host that speaks JSON-RPC with a single MCP server. One host runs multiple clients simultaneously, each owning one server connection, so your agent can pull tools from a file server, a database server, and a calculator server all at once.

Technical Definition — Host, Client, Server

The host is the application process that has the LLM context (chat history, user intent). It owns the lifecycle of all client connections and is responsible for routing LLM tool calls to the right server. The host is what you build when you write a Claude agent.

A client is a connection-scoped object inside the host. It manages one server connection: spawning (stdio) or connecting (HTTP), running the initialize handshake, and handling the message framing. There is exactly one client per server connection. The MCP Python SDK exposes this as ClientSession.

The server (MCP01–MCP03) provides tools, resources, and prompts. The server does not know about the LLM or the user — it only speaks to its one connected client.

ConceptOwnsKnows aboutSDK class
HostAll clients, LLM conversation, tool routingUser intent, all servers, the LLMYour code (MCPHost)
ClientOne server connection + its capabilitiesOne server onlyClientSession
ServerTools, resources, promptsIts one connected clientFastMCP / McpServer
Why It Matters Real agents use 3–8 MCP servers simultaneously — a file server, a database server, a web search server, an email server. Without the host/client separation, you'd need to invent your own connection multiplexer. With it, each server gets a dedicated ClientSession, tool lists are merged automatically, and the agent calls tools across all of them with a single interface. At Anthropic, internal agents regularly connect to 6+ MCP servers at startup.
The host/client split is the architectural reason MCP scales to multi-server agents. Now let's look at what a client actually does step by step before writing any code.

2. Client Architecture

Every MCP client — regardless of transport — must perform the same five-step lifecycle to become ready for tool calls. Skip any step and the session is broken.

The Five-Step Client Lifecycle
  1. Transport connect: For stdio, spawn the server subprocess. For HTTP, open an SSE stream to the server URL. Both result in a bidirectional read/write pair.
  2. Initialize handshake: Client sends initialize with its protocolVersion and capabilities. Server responds with server_info (name, version) and its own capabilities. Both sides agree on protocol compatibility.
  3. Capability discovery: Client calls tools/list, resources/list, prompts/list — but only for capabilities the server declared. Calling resources/list on a tools-only server returns an error.
  4. Tool routing: Client builds an internal map of tool_name → client. When the LLM calls a tool, the host uses this map to route the call to the right client's call_tool().
  5. Error & reconnection: For stdio, detect subprocess exit. For HTTP, detect dropped SSE streams. Both need a strategy to restart and re-initialize — covered in section 7.
Animation 1 — Client Lifecycle: Spawn → Initialize → Discover → Route → Handle Errors
STEP 1
Transport
spawn / connect
STEP 2
Initialize
handshake + caps
STEP 3
Discover
tools/resources/prompts
STEP 4
Route
tool_name → client
STEP 5
Error/Reconnect
detect + restart

The protocol sequence for the initialize handshake looks like this — understanding it is essential for debugging connection failures:

JSON-RPC — initialize handshake sequence
// 1. Client → Server
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2024-11-05",
    "capabilities": {
      "roots": { "listChanged": true },
      "sampling": {}
    },
    "clientInfo": { "name": "my-mcp-host", "version": "1.0.0" }
  }
}

// 2. Server → Client
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2024-11-05",
    "capabilities": {
      "tools": { "listChanged": true },
      "resources": {},
      "prompts": {}
    },
    "serverInfo": { "name": "document-server", "version": "1.0.0" }
  }
}

// 3. Client → Server (notification — no response expected)
{
  "jsonrpc": "2.0",
  "method": "notifications/initialized"
}

// 4. Now safe to call tools/list, resources/list, prompts/list
Protocol Annotation The three-message sequence (request → response → notification) is mandatory. The notifications/initialized message in step 3 tells the server the client is ready. If you call tools/list before sending it, some servers will return an error or silently drop the request. The SDK handles this automatically when you use session.initialize().
Architecture understood. Let's build the stdio client first — it's the simpler case and the foundation for everything that follows.

3. Building a stdio Client

A stdio clientstdio client — an MCP client that connects to a server by spawning it as a subprocess and communicating over its stdin/stdout streams. The client process manages the server process's full lifecycle: start, communicate, and terminate. spawns the MCP server as a child process and communicates over its stdin/stdout pipes. This is the right transport for local servers — zero network overhead, no auth, and the client automatically terminates the server when it exits.

WHAT — StdioServerParameters

StdioServerParameters describes how to launch the server subprocess: which command to run, what arguments to pass, and any environment variables to inject. The SDK spawns the process using these parameters when you enter the stdio_client context.

WHY — Context managers for resource safety

The nested async with pattern ensures the server process is terminated and all streams are closed even if an exception occurs mid-session. Never skip the context managers — a leaked subprocess silently consumes memory and holds file descriptors open until your host process exits.

GOTCHA — session.initialize() is not optional

Calling any method on a ClientSession before await session.initialize() will raise a RuntimeError. The SDK enforces this at runtime. Always initialize first, then discover, then call tools.

Python — stdio_client_example.py
# stdio_client_example.py
# Connects to the document_server.py from MCP01 and calls its tools.
from __future__ import annotations

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


async def run_stdio_client() -> None:
    # ── CHUNK 1: Define how to launch the server ──
    # StdioServerParameters tells the SDK how to spawn the server subprocess.
    # Use absolute paths in production to avoid "file not found" errors.
    server_params = StdioServerParameters(
        command="python",
        args=["document_server.py"],    # path to the MCP01 server
        env=None,                        # inherit parent environment
    )

    # ── CHUNK 2: Open transport and session ──
    # stdio_client() spawns the subprocess and returns (read_stream, write_stream).
    # ClientSession wraps those streams with the full MCP protocol.
    # Both context managers are nested: session closes before transport closes.
    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:

            # ── CHUNK 3: Mandatory initialize handshake ──
            # Sends initialize → waits for server_info + capabilities.
            # Returns an InitializeResult with server name, version, capabilities.
            init_result = await session.initialize()
            print(f"Connected to: {init_result.serverInfo.name} "
                  f"v{init_result.serverInfo.version}")
            print(f"Capabilities: {init_result.capabilities}")

            # ── CHUNK 4: Discover available tools ──
            # Only call this after initialize() succeeds.
            # tools_result.tools is a list of Tool objects with name, description,
            # and inputSchema (the JSON Schema for each tool's parameters).
            tools_result = await session.list_tools()
            print(f"\nAvailable tools ({len(tools_result.tools)}):")
            for tool in tools_result.tools:
                print(f"  - {tool.name}: {tool.description[:60]}...")

            # ── CHUNK 5: Call a tool ──
            # call_tool() sends tools/call and returns a CallToolResult.
            # result.content is a list of content blocks (text, image, etc.)
            # result.isError is True if the tool raised an exception.
            result = await session.call_tool(
                "search_documents",
                {"query": "MCP", "limit": 3}
            )

            if result.isError:
                print(f"\nTool error: {result.content[0].text}")
            else:
                # Content blocks are typed — text blocks have a .text attribute
                for block in result.content:
                    if block.type == "text":
                        docs = json.loads(block.text)
                        print(f"\nSearch results ({len(docs)} found):")
                        for doc in docs:
                            print(f"  [{doc['score']}] {doc['id']}: {doc['preview'][:60]}")

            # Also list resources (if the server supports them)
            # GOTCHA: Only call list_resources() if init_result.capabilities.resources
            # is set. Calling it on a tools-only server returns an error.
            if hasattr(init_result.capabilities, 'resources') and init_result.capabilities.resources:
                resources = await session.list_resources()
                print(f"\nResources: {len(resources.resources)}")


if __name__ == "__main__":
    asyncio.run(run_stdio_client())
TypeScript — stdioClientExample.ts
// stdioClientExample.ts
// Connects to a local MCP server over stdio.
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

async function runStdioClient(): Promise {
  // ── CHUNK 1: Create transport (defines how to spawn the server) ──
  // StdioClientTransport accepts the same command/args pattern as Python.
  const transport = new StdioClientTransport({
    command: "python",
    args: ["document_server.py"],
  });

  // ── CHUNK 2: Create client and connect ──
  // In the TS SDK, Client is the equivalent of ClientSession.
  // client.connect() spawns the subprocess and runs the initialize handshake
  // in one step — no separate initialize() call needed.
  const client = new Client(
    { name: "my-mcp-host", version: "1.0.0" },
    { capabilities: {} }
  );

  try {
    await client.connect(transport);
    console.log("Connected to MCP server");

    // ── CHUNK 3: Discover tools ──
    const toolsResult = await client.listTools();
    console.log(`\nAvailable tools (${toolsResult.tools.length}):`);
    for (const tool of toolsResult.tools) {
      console.log(`  - ${tool.name}: ${tool.description?.slice(0, 60)}...`);
    }

    // ── CHUNK 4: Call a tool ──
    // callTool() returns { content: ContentBlock[], isError?: boolean }
    const result = await client.callTool({
      name: "search_documents",
      arguments: { query: "MCP", limit: 3 },
    });

    if (result.isError) {
      console.error("\nTool error:", result.content[0]);
    } else {
      for (const block of result.content) {
        if (block.type === "text") {
          const docs = JSON.parse(block.text as string) as Array<{
            score: number; id: string; preview: string;
          }>;
          console.log(`\nSearch results (${docs.length} found):`);
          for (const doc of docs) {
            console.log(`  [${doc.score}] ${doc.id}: ${doc.preview.slice(0, 60)}`);
          }
        }
      }
    }
  } finally {
    // ── CHUNK 5: Always close the client ──
    // This terminates the subprocess and cleans up all streams.
    // Use try/finally — not try/catch — so cleanup runs even on errors.
    await client.close();
  }
}

runStdioClient().catch(console.error);
Terminal — run the client
python stdio_client_example.py
Connected to: document-server v1.0.0 Capabilities: CapabilitiesResult(tools=ToolsCapability(listChanged=True), ...) Available tools (3): - search_documents: Search the document store for documents matching... - calculate_similarity: Calculate cosine similarity between two text... - save_note: Save a note with a title and content. Notes persist... Search results (2 found): [3] mcp-overview: MCP is an open protocol for connecting AI to tools. [1] fastmcp-guide: FastMCP reduces MCP server boilerplate with decorat...
What Just Happened?
  • The server subprocess was spawned — your Python process launched document_server.py as a child process, passing JSON-RPC over stdin/stdout.
  • Three-step handshake ran automatically — initialize request, server_info response, initialized notification — all handled by session.initialize().
  • Tool list was fetchedtools/list returned all three tools with their names, descriptions, and JSON Schemas.
  • Tool was called successfullytools/call ran search_documents and returned parsed results.
stdio is perfect for local servers. For shared, remote servers built with MCP03's HTTP/SSE transport, you need an HTTP client instead.

4. Building an HTTP Client

An HTTP MCP client connects to a remote server — the kind built in MCP03 with FastAPI and SSE transportServer-Sent Events (SSE) — a one-directional HTTP streaming mechanism where the server pushes events to the client over a long-lived HTTP connection. MCP uses SSE for server-to-client messages and regular HTTP POST for client-to-server messages.. The client API is nearly identical to stdio — only the transport setup differs.

WHAT — sse_client

sse_client(url) opens an HTTP connection to the server's SSE endpoint (e.g., http://localhost:8000/sse). The server streams events to the client over this persistent connection. Client-to-server messages go via HTTP POST to the messages endpoint.

WHY — Auth headers for production HTTP servers

Unlike stdio (which inherits the parent's OS security context), HTTP servers are network-accessible. Pass auth tokens via the headers parameter. The headers are attached to every request the client makes — initialization, tool calls, and resource fetches alike.

GOTCHA — SSE URL vs messages URL

The MCP03 HTTP server exposes two endpoints: /sse for the event stream and /messages for posting client requests. Pass only the /sse URL to sse_client() — the SDK derives the messages URL from it automatically.

Python — http_client_example.py
# http_client_example.py
# Connects to the HTTP/SSE MCP server from MCP03.
# Start the MCP03 server first: uvicorn server:app --port 8000
from __future__ import annotations

import asyncio
import json
import os
from mcp import ClientSession
from mcp.client.sse import sse_client


SERVER_URL = os.getenv("MCP_SERVER_URL", "http://localhost:8000/sse")
API_TOKEN  = os.getenv("MCP_API_TOKEN", "")  # set in production


async def run_http_client() -> None:
    # ── CHUNK 1: Auth headers (production pattern) ──
    # Pass as dict to sse_client. These headers are sent on all requests.
    # For development with no auth, pass headers={} or omit the argument.
    headers: dict[str, str] = {}
    if API_TOKEN:
        headers["Authorization"] = f"Bearer {API_TOKEN}"

    # ── CHUNK 2: Open SSE transport ──
    # sse_client opens the long-lived SSE stream to SERVER_URL.
    # The server must be running before this call — no retry on initial connect.
    async with sse_client(SERVER_URL, headers=headers) as (read, write):
        async with ClientSession(read, write) as session:

            # ── CHUNK 3: Same initialize/discover/call pattern as stdio ──
            # The protocol is transport-agnostic — everything after this point
            # is identical whether you used stdio_client or sse_client.
            init_result = await session.initialize()
            print(f"Connected to: {init_result.serverInfo.name}")

            tools = await session.list_tools()
            print(f"Tools available: {[t.name for t in tools.tools]}")

            # ── CHUNK 4: Call a tool over HTTP ──
            # Under the hood: HTTP POST to /messages with the tools/call body.
            # The server processes the call and streams the result back via SSE.
            result = await session.call_tool(
                "read_file",
                {"path": "/tmp/hello.txt"}
            )

            if result.isError:
                print(f"Error: {result.content[0].text}")
            else:
                for block in result.content:
                    if block.type == "text":
                        print(f"File contents:\n{block.text}")

            # ── CHUNK 5: Resources work the same way ──
            # If the server supports resources, fetch them identically to stdio.
            if getattr(init_result.capabilities, 'resources', None):
                resources = await session.list_resources()
                print(f"Resources: {[str(r.uri) for r in resources.resources]}")

                if resources.resources:
                    first_uri = resources.resources[0].uri
                    content = await session.read_resource(first_uri)
                    print(f"First resource content: {str(content.contents[0].text)[:200]}")


if __name__ == "__main__":
    asyncio.run(run_http_client())
TypeScript — httpClientExample.ts
// httpClientExample.ts
// Connects to a remote HTTP/SSE MCP server with auth headers.
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";

const SERVER_URL = process.env.MCP_SERVER_URL ?? "http://localhost:8000/sse";
const API_TOKEN  = process.env.MCP_API_TOKEN ?? "";

async function runHttpClient(): Promise {
  // ── CHUNK 1: SSE transport with auth headers ──
  // SSEClientTransport connects to SERVER_URL and attaches headers to all requests.
  const headers: Record = {};
  if (API_TOKEN) headers["Authorization"] = `Bearer ${API_TOKEN}`;

  const transport = new SSEClientTransport(new URL(SERVER_URL), { headers });

  const client = new Client(
    { name: "my-http-host", version: "1.0.0" },
    { capabilities: {} }
  );

  try {
    // ── CHUNK 2: connect() runs the full handshake ──
    // Equivalent to Python's session.initialize() — same three-step sequence.
    await client.connect(transport);
    console.log("Connected to remote MCP server");

    // ── CHUNK 3: List tools ──
    const toolsResult = await client.listTools();
    console.log("Tools:", toolsResult.tools.map((t) => t.name).join(", "));

    // ── CHUNK 4: Call a tool ──
    const result = await client.callTool({
      name: "read_file",
      arguments: { path: "/tmp/hello.txt" },
    });

    if (result.isError) {
      console.error("Tool error:", result.content[0]);
    } else {
      for (const block of result.content) {
        if (block.type === "text") {
          console.log("File contents:\n", block.text);
        }
      }
    }

    // ── CHUNK 5: List and read resources ──
    const serverCaps = client.getServerCapabilities();
    if (serverCaps?.resources) {
      const resources = await client.listResources();
      console.log("Resources:", resources.resources.map((r) => r.uri).join(", "));

      if (resources.resources.length > 0) {
        const first = resources.resources[0];
        const content = await client.readResource({ uri: first.uri as string });
        const text = content.contents[0];
        if (text.type === "text") console.log("First resource:\n", text.text?.slice(0, 200));
      }
    }
  } finally {
    await client.close();
  }
}

runHttpClient().catch(console.error);
What Just Happened?

After the transport is established, the client code is completely identical to the stdio case. session.initialize(), list_tools(), call_tool(), list_resources() — all the same API. This is the power of MCP's transport-agnostic design: you build your tool-calling logic once, and it works against both local and remote servers without modification.

We can connect to one server at a time. Now let's build the multi-server host — the class that turns your agent into an aggregator of many MCP servers simultaneously.

5. Multi-Server Host

The MCPHost class connects to multiple MCP servers simultaneously, merges their tool lists, routes LLM tool routingTool routing — the process by which a host maps a tool name to the specific client (and therefore server) that provides it. When the LLM calls search_documents, the host must know that this tool lives on the "document-server" client, not the "calculator" client. calls to the right server, and presents a single unified interface to the LLM. This is the architectural pattern used by Claude Desktop itself.

WHAT — The tool_map

The tool_map dict maps every tool name to the server name that provides it. When two servers have tools with the same name, the last one wins — in production you'd prefix tool names with the server name (e.g., file_server__read_file) to avoid collisions.

WHY — Unified tool list for the LLM

The Anthropic API (and every other LLM API) accepts a single flat list of tools. The host's job is to aggregate tools from all servers into that flat list, then reverse-route LLM call responses back to the right ClientSession. The LLM never sees server boundaries — it just sees tools.

GOTCHA — Concurrent initialization

Adding servers sequentially with for server in servers: await host.add_server(server) works, but is slow if you have many servers. Use asyncio.gather() to initialize all servers concurrently — see the example below.

Python — mcp_host.py
# mcp_host.py — multi-server host with Claude integration
from __future__ import annotations

import asyncio
import json
from contextlib import AsyncExitStack
from typing import Any

import anthropic
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from mcp.client.sse import sse_client


class MCPHost:
    """Connects to multiple MCP servers and routes LLM tool calls."""

    def __init__(self) -> None:
        self.clients: dict[str, ClientSession] = {}
        self.tool_map: dict[str, str] = {}      # tool_name → server_name
        self.all_tools: list[dict] = []          # flat list for the LLM API
        self._stack = AsyncExitStack()           # manages all context managers

    # ── CHUNK 1: Connect to a stdio server ──
    async def add_stdio_server(self, name: str, params: StdioServerParameters) -> None:
        """Add a local MCP server (spawned as a subprocess)."""
        read, write = await self._stack.enter_async_context(stdio_client(params))
        session = await self._stack.enter_async_context(ClientSession(read, write))
        await session.initialize()
        await self._register_server(name, session)

    # ── CHUNK 2: Connect to an HTTP/SSE server ──
    async def add_http_server(self, name: str, url: str, headers: dict[str, str] | None = None) -> None:
        """Add a remote MCP server (connected via HTTP/SSE)."""
        read, write = await self._stack.enter_async_context(sse_client(url, headers=headers or {}))
        session = await self._stack.enter_async_context(ClientSession(read, write))
        await session.initialize()
        await self._register_server(name, session)

    async def _register_server(self, name: str, session: ClientSession) -> None:
        """Store the session and build the tool_map for this server."""
        self.clients[name] = session
        tools_result = await session.list_tools()
        for tool in tools_result.tools:
            self.tool_map[tool.name] = name
            # Convert MCP tool schema to Anthropic API tool format
            self.all_tools.append({
                "name": tool.name,
                "description": tool.description or "",
                "input_schema": tool.inputSchema or {"type": "object", "properties": {}},
            })
        print(f"  [{name}] registered {len(tools_result.tools)} tools: "
              f"{[t.name for t in tools_result.tools]}")

    # ── CHUNK 3: Route a tool call to the right server ──
    async def call_tool(self, tool_name: str, args: dict[str, Any]) -> Any:
        """Route a tool call to the server that owns it."""
        server_name = self.tool_map.get(tool_name)
        if server_name is None:
            raise ValueError(f"Tool '{tool_name}' not found in any connected server. "
                             f"Available: {list(self.tool_map.keys())}")
        session = self.clients[server_name]
        result = await session.call_tool(tool_name, args)
        if result.isError:
            raise RuntimeError(f"Tool '{tool_name}' on '{server_name}' returned error: "
                               f"{result.content[0].text if result.content else 'unknown'}")
        return result.content

    # ── CHUNK 4: Run an agentic conversation with Claude ──
    async def run_agent(self, user_message: str) -> str:
        """Run a full agentic loop: prompt → tool calls → final answer."""
        client = anthropic.Anthropic()
        messages = [{"role": "user", "content": user_message}]

        while True:
            response = client.messages.create(
                model="claude-opus-4-5",
                max_tokens=4096,
                tools=self.all_tools,
                messages=messages,
            )

            # If Claude isn't calling a tool, we're done
            if response.stop_reason == "end_turn":
                text_blocks = [b.text for b in response.content if hasattr(b, 'text')]
                return " ".join(text_blocks)

            # Append Claude's response (may contain text + tool_use blocks)
            messages.append({"role": "assistant", "content": response.content})

            # Process all tool calls in parallel for speed
            tool_use_blocks = [b for b in response.content if b.type == "tool_use"]
            if not tool_use_blocks:
                text_blocks = [b.text for b in response.content if hasattr(b, 'text')]
                return " ".join(text_blocks)

            tool_results = []
            for block in tool_use_blocks:
                try:
                    content_blocks = await self.call_tool(block.name, block.input)
                    result_text = " ".join(
                        b.text for b in content_blocks if hasattr(b, 'text')
                    )
                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": result_text,
                    })
                except Exception as e:
                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": f"Error: {e}",
                        "is_error": True,
                    })

            messages.append({"role": "user", "content": tool_results})

    async def close(self) -> None:
        """Shut down all server connections and subprocesses."""
        await self._stack.aclose()


# ── CHUNK 5: Usage — connect to MCP01 + MCP02 simultaneously ──
async def main() -> None:
    host = MCPHost()
    try:
        print("Connecting to MCP servers...")

        # Add both servers concurrently for faster startup
        await asyncio.gather(
            host.add_stdio_server(
                "doc-server",
                StdioServerParameters(command="python", args=["document_server.py"])
            ),
            host.add_stdio_server(
                "file-server",
                StdioServerParameters(command="python", args=["file_server.py"])
            ),
        )

        print(f"\nTotal tools available: {len(host.all_tools)}")
        print(f"Tool routing map: {host.tool_map}")

        # Ask Claude a question that requires tools from both servers
        print("\nRunning agent...")
        answer = await host.run_agent(
            "Search my documents for anything about MCP, then save a note "
            "summarizing what you found."
        )
        print(f"\nAgent answer:\n{answer}")

    finally:
        await host.close()


if __name__ == "__main__":
    asyncio.run(main())
TypeScript — mcpHost.ts
// mcpHost.ts — multi-server host with Claude + OpenAI-compatible API
import Anthropic from "@anthropic-ai/sdk";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";

interface ToolDef {
  name: string;
  description: string;
  input_schema: Record;
}

interface ServerEntry {
  client: Client;
  toolNames: string[];
}

class MCPHost {
  private servers = new Map();
  private toolMap = new Map();   // tool_name → server_name
  public allTools: ToolDef[] = [];

  // ── CHUNK 1: Add a stdio server ──
  async addStdioServer(name: string, command: string, args: string[]): Promise {
    const transport = new StdioClientTransport({ command, args });
    const client = new Client({ name: "mcp-host", version: "1.0.0" }, { capabilities: {} });
    await client.connect(transport);
    await this._registerServer(name, client);
  }

  // ── CHUNK 2: Add an HTTP/SSE server ──
  async addHttpServer(name: string, url: string, headers?: Record): Promise {
    const transport = new SSEClientTransport(new URL(url), { headers: headers ?? {} });
    const client = new Client({ name: "mcp-host", version: "1.0.0" }, { capabilities: {} });
    await client.connect(transport);
    await this._registerServer(name, client);
  }

  private async _registerServer(name: string, client: Client): Promise {
    const toolsResult = await client.listTools();
    const toolNames: string[] = [];

    for (const tool of toolsResult.tools) {
      this.toolMap.set(tool.name, name);
      toolNames.push(tool.name);
      this.allTools.push({
        name: tool.name,
        description: tool.description ?? "",
        input_schema: (tool.inputSchema as Record) ?? { type: "object", properties: {} },
      });
    }

    this.servers.set(name, { client, toolNames });
    console.log(`  [${name}] registered tools: ${toolNames.join(", ")}`);
  }

  // ── CHUNK 3: Route a tool call ──
  async callTool(toolName: string, args: Record): Promise {
    const serverName = this.toolMap.get(toolName);
    if (!serverName) throw new Error(`Tool '${toolName}' not found. Available: ${[...this.toolMap.keys()].join(", ")}`);
    const { client } = this.servers.get(serverName)!;

    const result = await client.callTool({ name: toolName, arguments: args });
    if (result.isError) throw new Error(`Tool '${toolName}' error: ${result.content[0]}`);

    return result.content
      .filter((b) => b.type === "text")
      .map((b) => (b as { type: "text"; text: string }).text)
      .join("\n");
  }

  // ── CHUNK 4: Agentic loop with Claude ──
  async runAgent(userMessage: string): Promise {
    const anthropic = new Anthropic();
    const messages: Anthropic.MessageParam[] = [{ role: "user", content: userMessage }];

    while (true) {
      const response = await anthropic.messages.create({
        model: "claude-opus-4-5",
        max_tokens: 4096,
        tools: this.allTools as Anthropic.Tool[],
        messages,
      });

      if (response.stop_reason === "end_turn") {
        return response.content
          .filter((b) => b.type === "text")
          .map((b) => (b as Anthropic.TextBlock).text)
          .join(" ");
      }

      messages.push({ role: "assistant", content: response.content });

      const toolUseBlocks = response.content.filter((b): b is Anthropic.ToolUseBlock => b.type === "tool_use");
      if (toolUseBlocks.length === 0) {
        return response.content.filter((b) => b.type === "text").map((b) => (b as Anthropic.TextBlock).text).join(" ");
      }

      const toolResults = await Promise.all(
        toolUseBlocks.map(async (block) => {
          try {
            const text = await this.callTool(block.name, block.input as Record);
            return { type: "tool_result" as const, tool_use_id: block.id, content: text };
          } catch (err) {
            return { type: "tool_result" as const, tool_use_id: block.id, content: `Error: ${err}`, is_error: true };
          }
        })
      );

      messages.push({ role: "user", content: toolResults });
    }
  }

  // ── CHUNK 5: Close all connections ──
  async close(): Promise {
    for (const [name, { client }] of this.servers) {
      await client.close();
      console.log(`Closed connection to ${name}`);
    }
  }
}

// Usage
async function main(): Promise {
  const host = new MCPHost();
  try {
    console.log("Connecting to MCP servers...");
    await Promise.all([
      host.addStdioServer("doc-server", "python", ["document_server.py"]),
      host.addStdioServer("file-server", "python", ["file_server.py"]),
    ]);

    console.log(`\nTotal tools: ${host.allTools.length}`);
    const answer = await host.runAgent(
      "Search my documents for anything about MCP, then save a note summarizing what you found."
    );
    console.log("\nAgent answer:\n", answer);
  } finally {
    await host.close();
  }
}

main().catch(console.error);
Animation 2 — Multi-Server Tool Routing: LLM Call → Host Lookup → Right Client → Result
LLM (Claude)
MCPHost
tool_map lookup
doc-server
search_documents
save_note
file-server
read_file
list_directory
What Just Happened?
  • MCPHost connected to two servers concurrently — using asyncio.gather(), both initialize handshakes ran in parallel, cutting startup time roughly in half.
  • Tool map was built automatically — after listing tools from both servers, tool_map knows that search_documentsdoc-server and read_filefile-server.
  • Claude sees a flat unified tool list — the LLM has no idea there are two servers; it just sees five tools and picks whichever it needs.
  • Tool routing is invisible — when Claude calls read_file, the host silently dispatches to the file-server client. The agent loop doesn't change.
Multi-server hosts work. But before shipping to production, we need to understand exactly what the initialize handshake negotiates — and what happens when it fails.

6. Capability Negotiation Deep Dive

The capability negotiationCapability negotiation — the process during the MCP initialize handshake where client and server exchange what optional features each supports. The result determines which protocol methods are safe to call. Calling an unsupported method after negotiation returns a -32601 "Method not found" error. during initialize is how client and server agree on what they can do together. It is not optional — it protects both sides from calling methods the other doesn't understand.

Animation 3 — Capability Negotiation: initialize → server_info → Capabilities Object Expanding
Client sends
protocolVersion: "2024-11-05"
capabilities.roots: {}
capabilities.sampling: {}
clientInfo.name: "my-host"
Server responds
protocolVersion: "2024-11-05"
capabilities.tools: {listChanged: true}
capabilities.resources: {}
capabilities.prompts: undefined
serverInfo.name: "doc-server"

Protocol Version Mismatch

If client and server send different protocolVersionprotocolVersion — a date-formatted string (e.g., "2024-11-05") identifying the MCP protocol revision. Both client and server must agree on a version. The server echoes back the version it will use; if it's not one the client understands, the client must disconnect. values, the server echoes back its preferred version. If the client doesn't support that version, it should disconnect gracefully rather than continuing with a broken session.

Python — graceful version mismatch handling
SUPPORTED_VERSIONS = {"2024-11-05", "2024-10-07"}

async def safe_initialize(session: ClientSession) -> bool:
    """Returns True if the server is compatible, False otherwise."""
    try:
        result = await session.initialize()
        server_version = result.protocolVersion

        if server_version not in SUPPORTED_VERSIONS:
            print(f"Incompatible server: uses protocol {server_version}, "
                  f"we support {SUPPORTED_VERSIONS}. Disconnecting.")
            return False

        print(f"Connected. Protocol: {server_version}, "
              f"Server: {result.serverInfo.name} v{result.serverInfo.version}")
        return True

    except Exception as e:
        print(f"Initialize failed: {e}")
        return False


async def safe_list_resources(session: ClientSession, init_result) -> list:
    """Only fetch resources if the server declared that capability."""
    caps = init_result.capabilities

    # Graceful degradation: check before calling
    if not (hasattr(caps, 'resources') and caps.resources is not None):
        print("Server does not support resources — skipping resources/list")
        return []

    resources = await session.list_resources()
    return resources.resources
Capability keyEnables method callsIf missing → behavior
capabilities.toolstools/list, tools/callSkip tool discovery; server is resources/prompts only
capabilities.resourcesresources/list, resources/read, resources/subscribeSkip resource fetch; will get -32601 if called
capabilities.promptsprompts/list, prompts/getSkip prompt discovery; surface message to user
capabilities.tools.listChangedReceive notifications/tools/list_changedTool list is static for the session
Never Assume Capabilities Do not assume all servers support all capabilities. A server built with FastMCP and no @mcp.resource() decorators will not declare capabilities.resources. Always check init_result.capabilities before calling any list or read method. This is especially important when your host connects to third-party servers you didn't write.
Capability negotiation is the "safe to proceed" check. Now let's handle the five ways things go wrong at runtime — and build a robust wrapper that survives all of them.

7. Client-Side Error Handling

Five failure modes cover 95% of real-world MCP client problems. Each requires a different response — crashing on all of them is not a production strategy.

#ScenarioTransportRecovery strategy
1Server process crashesstdioDetect via returncode, restart subprocess, re-initialize
2Network timeoutHTTPRetry with exponential backoffExponential backoff — a retry strategy where the wait time between attempts doubles each time (e.g., 1s, 2s, 4s, 8s…) up to a maximum. It prevents thundering-herd problems when many clients retry simultaneously after a server restart.
3tool_not_found errorBothRefresh tool list, surface error to user/LLM
4invalid_params errorBothLog input args, return error to LLM for self-correction
5Auth token expiredHTTPRefresh token, rebuild transport, reconnect
WHAT — RobustMCPClient

A wrapper class that adds retry logic, process-restart capability, and structured error classification on top of the raw ClientSession. Each error scenario has an explicit handler so the host can decide what to do next rather than crashing.

WHY — Tool calls must be idempotent-safe

Before retrying a tool call, consider whether re-running it is safe. search_documents is safe to retry — it's a read. save_note might create a duplicate — it's a write. Implement retry only for read/query tools by default; expose a flag for callers to opt into write retries.

GOTCHA — Re-initialize after reconnect

After restarting a server subprocess or reconnecting to an HTTP server, you must call initialize() again. The new server process has no memory of the previous session. Any cached init_result from before the crash is now stale and must be refreshed.

Python — robust_mcp_client.py
# robust_mcp_client.py — production-grade client wrapper
from __future__ import annotations

import asyncio
import logging
from contextlib import AsyncExitStack
from typing import Any

from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from mcp.client.sse import sse_client

logger = logging.getLogger(__name__)


class MCPClientError(Exception):
    """Base class for client-side MCP errors."""


class ToolNotFoundError(MCPClientError):
    """Raised when the requested tool doesn't exist on the server."""


class InvalidParamsError(MCPClientError):
    """Raised when the tool rejected the provided arguments."""


class ServerUnavailableError(MCPClientError):
    """Raised when the server process died or the HTTP connection dropped."""


class RobustMCPClient:
    """
    MCP client wrapper that handles the five common failure scenarios.

    Usage:
        client = RobustMCPClient(StdioServerParameters(command="python", args=["server.py"]))
        async with client:
            result = await client.call_tool("search_documents", {"query": "hello"})
    """

    MAX_RETRIES = 3
    BACKOFF_BASE = 1.0   # seconds
    BACKOFF_MAX  = 30.0  # seconds

    def __init__(
        self,
        params: StdioServerParameters | None = None,
        http_url: str | None = None,
        http_headers: dict[str, str] | None = None,
    ) -> None:
        if params is None and http_url is None:
            raise ValueError("Provide either StdioServerParameters or http_url")
        self._params = params
        self._http_url = http_url
        self._http_headers = http_headers or {}
        self._session: ClientSession | None = None
        self._stack = AsyncExitStack()
        self._connected = False
        self._known_tools: set[str] = set()

    async def __aenter__(self) -> "RobustMCPClient":
        await self._connect()
        return self

    async def __aexit__(self, *_: Any) -> None:
        await self._stack.aclose()

    # ── Error Scenario 1 & 2: Connect with retry ──
    async def _connect(self) -> None:
        """Connect to the server, retrying with exponential backoff on failure."""
        for attempt in range(self.MAX_RETRIES):
            try:
                # Start fresh each attempt
                if self._stack._exit_callbacks:
                    await self._stack.aclose()
                    self._stack = AsyncExitStack()

                if self._params is not None:
                    # stdio: spawn subprocess
                    read, write = await self._stack.enter_async_context(
                        stdio_client(self._params)
                    )
                else:
                    # HTTP: open SSE stream — Scenario 2: timeout handled here
                    read, write = await self._stack.enter_async_context(
                        sse_client(self._http_url, headers=self._http_headers)
                    )

                self._session = await self._stack.enter_async_context(
                    ClientSession(read, write)
                )
                init_result = await self._session.initialize()
                tools = await self._session.list_tools()
                self._known_tools = {t.name for t in tools.tools}
                self._connected = True
                logger.info(f"Connected to {init_result.serverInfo.name} "
                            f"(attempt {attempt + 1})")
                return

            except Exception as e:
                self._connected = False
                if attempt < self.MAX_RETRIES - 1:
                    delay = min(self.BACKOFF_BASE * (2 ** attempt), self.BACKOFF_MAX)
                    logger.warning(f"Connect failed (attempt {attempt + 1}): {e}. "
                                   f"Retrying in {delay:.1f}s...")
                    await asyncio.sleep(delay)
                else:
                    raise ServerUnavailableError(
                        f"Failed to connect after {self.MAX_RETRIES} attempts: {e}"
                    ) from e

    # ── Error Scenario 1: Detect and restart crashed stdio server ──
    async def _ensure_connected(self) -> None:
        """Reconnect if the server process died (stdio only)."""
        if not self._connected:
            logger.warning("Server disconnected — attempting reconnect...")
            await self._connect()

    # ── Core call_tool with all error scenarios handled ──
    async def call_tool(
        self,
        tool_name: str,
        args: dict[str, Any],
        *,
        retry_on_disconnect: bool = True,
    ) -> list:
        """
        Call a tool with full error handling.

        Args:
            tool_name: Name of the tool to call.
            args: Tool arguments. These are logged on InvalidParams for debugging.
            retry_on_disconnect: If True, reconnect and retry once on disconnect.

        Returns:
            List of MCP content blocks from the tool result.

        Raises:
            ToolNotFoundError: Tool doesn't exist on this server.
            InvalidParamsError: Server rejected the arguments.
            ServerUnavailableError: Cannot reach the server after retries.
            MCPClientError: Any other MCP-level error.
        """
        await self._ensure_connected()

        # Scenario 3: tool_not_found — fail fast, no retry
        if tool_name not in self._known_tools:
            # Try refreshing the tool list first (server may have restarted)
            try:
                tools = await self._session.list_tools()
                self._known_tools = {t.name for t in tools.tools}
            except Exception:
                pass  # If list_tools fails, fall through to the error below

            if tool_name not in self._known_tools:
                raise ToolNotFoundError(
                    f"Tool '{tool_name}' not found. "
                    f"Available: {sorted(self._known_tools)}"
                )

        try:
            result = await self._session.call_tool(tool_name, args)
        except Exception as e:
            err_str = str(e).lower()

            # Scenario 1/2: Connection dropped → reconnect and retry once
            if retry_on_disconnect and any(
                kw in err_str for kw in ("connection", "closed", "eof", "timeout", "broken")
            ):
                logger.warning(f"Connection error on tool call: {e}. Reconnecting...")
                self._connected = False
                await self._connect()
                result = await self._session.call_tool(tool_name, args)
            else:
                raise MCPClientError(f"Tool call failed: {e}") from e

        # Scenario 4: invalid_params — the tool itself rejected the args
        if result.isError:
            error_text = result.content[0].text if result.content else "unknown error"

            if "invalid" in error_text.lower() or "param" in error_text.lower():
                logger.error(
                    f"InvalidParams for tool '{tool_name}'. "
                    f"Args sent: {args}. Error: {error_text}"
                )
                raise InvalidParamsError(
                    f"Tool '{tool_name}' rejected arguments: {error_text}. "
                    f"Args provided: {args}"
                )

            raise MCPClientError(f"Tool '{tool_name}' error: {error_text}")

        return result.content

    # ── Scenario 5: Refresh HTTP auth token ──
    async def refresh_auth(self, new_token: str) -> None:
        """Re-establish the HTTP connection with a new auth token."""
        if self._http_url is None:
            raise ValueError("refresh_auth is only applicable to HTTP clients")

        self._http_headers["Authorization"] = f"Bearer {new_token}"
        self._connected = False
        logger.info("Auth token refreshed — reconnecting...")
        await self._connect()


# ── Usage example ──
async def main() -> None:
    params = StdioServerParameters(command="python", args=["document_server.py"])

    async with RobustMCPClient(params=params) as client:
        try:
            # Normal call
            content = await client.call_tool("search_documents", {"query": "MCP"})
            print("Search result:", content[0].text[:100] if content else "empty")

        except ToolNotFoundError as e:
            print(f"Tool missing: {e}")

        except InvalidParamsError as e:
            print(f"Bad args (check LLM output): {e}")

        except ServerUnavailableError as e:
            print(f"Server down (alert on-call): {e}")

        except MCPClientError as e:
            print(f"MCP error: {e}")


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    asyncio.run(main())
TypeScript — robustMcpClient.ts
// robustMcpClient.ts — production-grade MCP client with error recovery
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";

export class MCPClientError extends Error { name = "MCPClientError"; }
export class ToolNotFoundError extends MCPClientError { name = "ToolNotFoundError"; }
export class InvalidParamsError extends MCPClientError { name = "InvalidParamsError"; }
export class ServerUnavailableError extends MCPClientError { name = "ServerUnavailableError"; }

interface StdioConfig { type: "stdio"; command: string; args: string[]; }
interface HttpConfig  { type: "http";  url: string; headers?: Record; }
type ServerConfig = StdioConfig | HttpConfig;

export class RobustMCPClient {
  private static MAX_RETRIES = 3;
  private static BACKOFF_BASE = 1000;  // ms
  private static BACKOFF_MAX  = 30000; // ms

  private client: Client | null = null;
  private knownTools = new Set();
  private config: ServerConfig;

  constructor(config: ServerConfig) { this.config = config; }

  // ── Connect with exponential backoff (Scenarios 1 & 2) ──
  async connect(): Promise {
    for (let attempt = 0; attempt < RobustMCPClient.MAX_RETRIES; attempt++) {
      try {
        if (this.client) await this.client.close().catch(() => {});

        this.client = new Client({ name: "robust-host", version: "1.0.0" }, { capabilities: {} });

        const transport = this.config.type === "stdio"
          ? new StdioClientTransport({ command: this.config.command, args: this.config.args })
          : new SSEClientTransport(new URL(this.config.url), { headers: this.config.headers ?? {} });

        await this.client.connect(transport);

        const tools = await this.client.listTools();
        this.knownTools = new Set(tools.tools.map((t) => t.name));
        console.log(`Connected. Tools: ${[...this.knownTools].join(", ")}`);
        return;

      } catch (err) {
        if (attempt < RobustMCPClient.MAX_RETRIES - 1) {
          const delay = Math.min(RobustMCPClient.BACKOFF_BASE * 2 ** attempt, RobustMCPClient.BACKOFF_MAX);
          console.warn(`Connect attempt ${attempt + 1} failed: ${err}. Retrying in ${delay}ms...`);
          await new Promise((r) => setTimeout(r, delay));
        } else {
          throw new ServerUnavailableError(`Failed after ${RobustMCPClient.MAX_RETRIES} attempts: ${err}`);
        }
      }
    }
  }

  // ── Call tool with all five error scenarios handled ──
  async callTool(toolName: string, args: Record): Promise {
    if (!this.client) throw new ServerUnavailableError("Not connected");

    // Scenario 3: tool_not_found
    if (!this.knownTools.has(toolName)) {
      // Refresh tool list before giving up
      try {
        const tools = await this.client.listTools();
        tools.tools.forEach((t) => this.knownTools.add(t.name));
      } catch {}
      if (!this.knownTools.has(toolName)) {
        throw new ToolNotFoundError(`Tool '${toolName}' not found. Available: ${[...this.knownTools].join(", ")}`);
      }
    }

    let result;
    try {
      result = await this.client.callTool({ name: toolName, arguments: args });
    } catch (err) {
      const msg = String(err).toLowerCase();
      // Scenarios 1 & 2: connection dropped
      if (["connection", "closed", "eof", "timeout"].some((kw) => msg.includes(kw))) {
        console.warn(`Connection error — reconnecting: ${err}`);
        await this.connect();
        result = await this.client!.callTool({ name: toolName, arguments: args });
      } else {
        throw new MCPClientError(`Tool call failed: ${err}`);
      }
    }

    if (result.isError) {
      const errText = result.content[0] ? String(result.content[0]) : "unknown";
      // Scenario 4: invalid_params
      if (/invalid|param/i.test(errText)) {
        console.error(`InvalidParams for '${toolName}'. Args: ${JSON.stringify(args)}`);
        throw new InvalidParamsError(`Tool '${toolName}' rejected args: ${errText}`);
      }
      throw new MCPClientError(`Tool '${toolName}' error: ${errText}`);
    }

    return result.content
      .filter((b) => b.type === "text")
      .map((b) => (b as { type: "text"; text: string }).text)
      .join("\n");
  }

  // ── Scenario 5: Refresh HTTP auth ──
  async refreshAuth(newToken: string): Promise {
    if (this.config.type !== "http") throw new Error("Only applicable to HTTP clients");
    this.config.headers = { ...(this.config.headers ?? {}), Authorization: `Bearer ${newToken}` };
    await this.connect();
  }

  async close(): Promise { await this.client?.close(); }
}

// Usage
const client = new RobustMCPClient({ type: "stdio", command: "python", args: ["document_server.py"] });
try {
  await client.connect();
  const result = await client.callTool("search_documents", { query: "MCP" });
  console.log("Result:", result.slice(0, 100));
} catch (err) {
  if (err instanceof ToolNotFoundError) console.error("Tool missing:", err.message);
  else if (err instanceof InvalidParamsError) console.error("Bad args:", err.message);
  else if (err instanceof ServerUnavailableError) console.error("Server down:", err.message);
  else throw err;
} finally {
  await client.close();
}
What Just Happened?
  • Scenario 1 (crash) and 2 (timeout) are handled in _connect() with exponential backoff. The host retries up to 3 times with increasing delays before giving up.
  • Scenario 3 (tool not found) triggers a fresh tools/list before raising — in case the server restarted with new tools.
  • Scenario 4 (invalid params) logs the exact args sent to the server. In an LLM agent loop, this error propagates back to the model so it can fix its tool call.
  • Scenario 5 (token expired) is handled by refresh_auth(), which swaps the header and reconnects the entire SSE stream with fresh credentials.
You now have all the building blocks. Let's put them together in a hands-on lab — two servers, one host, one agent query that touches both.

8. Lab: Build a Multi-Server Agent

Connect two MCP servers simultaneously — the document server from MCP01 and a new calculator server — then run an agent query that calls tools from both.

Prerequisites You need document_server.py from MCP01 in your working directory. Install dependencies: pip install mcp anthropic. Set ANTHROPIC_API_KEY in your environment.
1

Create the Calculator Server

Save this as calculator_server.py — a simple MCP server with two math tools.

Python — calculator_server.py
# calculator_server.py
import math
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("calculator-server")


@mcp.tool()
def calculate(expression: str) -> dict:
    """Evaluate a safe mathematical expression and return the result.

    Supports: +, -, *, /, **, sqrt(), abs(), round(), sin(), cos(), pi, e.
    Does NOT support arbitrary Python — only math operations.

    Args:
        expression: Math expression string, e.g. "sqrt(144) + 2**3"

    Returns:
        Dict with 'result' (float), 'expression' (str), 'formatted' (str).
    """
    # Allowlist-based safe eval
    allowed_names = {
        k: v for k, v in math.__dict__.items() if not k.startswith("_")
    }
    allowed_names.update({"abs": abs, "round": round})

    try:
        result = eval(expression, {"__builtins__": {}}, allowed_names)  # noqa: S307
        return {
            "result": float(result),
            "expression": expression,
            "formatted": f"{expression} = {result}",
        }
    except Exception as e:
        raise ValueError(f"Cannot evaluate '{expression}': {e}") from e


@mcp.tool()
def unit_convert(value: float, from_unit: str, to_unit: str) -> dict:
    """Convert a value between common units.

    Supported pairs (case-insensitive):
    - km/miles, meters/feet, kg/pounds, celsius/fahrenheit, liters/gallons

    Args:
        value: Numeric value to convert.
        from_unit: Source unit name.
        to_unit: Target unit name.

    Returns:
        Dict with 'result', 'from', 'to', 'formatted'.
    """
    conversions: dict[tuple[str, str], float] = {
        ("km", "miles"): 0.621371,
        ("miles", "km"): 1.60934,
        ("meters", "feet"): 3.28084,
        ("feet", "meters"): 0.3048,
        ("kg", "pounds"): 2.20462,
        ("pounds", "kg"): 0.453592,
        ("liters", "gallons"): 0.264172,
        ("gallons", "liters"): 3.78541,
    }

    from_u, to_u = from_unit.lower(), to_unit.lower()

    # Special case: celsius ↔ fahrenheit (not a simple multiplier)
    if from_u == "celsius" and to_u == "fahrenheit":
        result = (value * 9 / 5) + 32
    elif from_u == "fahrenheit" and to_u == "celsius":
        result = (value - 32) * 5 / 9
    elif (from_u, to_u) in conversions:
        result = value * conversions[(from_u, to_u)]
    else:
        raise ValueError(
            f"Unsupported conversion: {from_unit} → {to_unit}. "
            f"Supported: {sorted({k[0] for k in conversions})} + celsius/fahrenheit"
        )

    return {
        "result": round(result, 6),
        "from": f"{value} {from_unit}",
        "to": f"{result:.4f} {to_unit}",
        "formatted": f"{value} {from_unit} = {result:.4f} {to_unit}",
    }


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

Verify Both Servers Start

Terminal 1
mcp dev calculator_server.py
Starting MCP Inspector... Server connected: calculator-server v1.0.0 Tools discovered: calculate, unit_convert Inspector running at: http://localhost:5173

Test in the Inspector: call calculate with {"expression": "sqrt(144) + 2**3"}. Expected: {"result": 20.0, "formatted": "sqrt(144) + 2**3 = 20.0"}. Press Ctrl+C when done, then test document_server.py the same way.

3

Create the Multi-Server Agent Script

Save this as multi_agent.py. It uses MCPHost from Section 5.

Python — multi_agent.py
# multi_agent.py — connects to both servers and runs an agent query
# Save mcp_host.py from Section 5 to the same directory first.
import asyncio
from mcp import StdioServerParameters
from mcp_host import MCPHost


async def main() -> None:
    host = MCPHost()

    try:
        print("Connecting to MCP servers...")
        await asyncio.gather(
            host.add_stdio_server(
                "doc-server",
                StdioServerParameters(command="python", args=["document_server.py"])
            ),
            host.add_stdio_server(
                "calc-server",
                StdioServerParameters(command="python", args=["calculator_server.py"])
            ),
        )

        print(f"\nTotal tools: {len(host.all_tools)}")
        for t in host.all_tools:
            owner = host.tool_map[t['name']]
            print(f"  [{owner}] {t['name']}")

        # Query that requires BOTH servers:
        # - search_documents → doc-server
        # - calculate → calc-server
        query = (
            "Search my documents for information about MCP transports. "
            "Also, what is 42 * 3.14159 rounded to 2 decimal places? "
            "Give me both answers."
        )

        print(f"\nQuery: {query}\n")
        print("Running agent loop...")
        answer = await host.run_agent(query)
        print(f"\nFinal answer:\n{answer}")

    finally:
        await host.close()
        print("\nAll connections closed.")


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

Run the Agent and Inspect the Output

Terminal
python multi_agent.py
Connecting to MCP servers... [doc-server] registered 3 tools: ['search_documents', 'calculate_similarity', 'save_note'] [calc-server] registered 2 tools: ['calculate', 'unit_convert'] Total tools: 5 [doc-server] search_documents [doc-server] calculate_similarity [doc-server] save_note [calc-server] calculate [calc-server] unit_convert Query: Search my documents for information about MCP transports... Running agent loop... Final answer: Regarding MCP transports: I found 1 document — "transport-docs" which states that "stdio is local; HTTP/SSE is for shared, remote servers." For the calculation: 42 × 3.14159 = 131.95 (rounded to 2 decimal places). All connections closed.
5

Checkpoint — Verify Tool Calls from Both Servers

Checkpoint
  • The output shows 5 total tools across 2 servers
  • The agent's final answer references document content (doc-server) AND a calculation result (calc-server)
  • Both server processes started and closed cleanly (no subprocess leak)
  • Try asking: "Convert 100 km to miles and also search my docs for fastmcp" — Claude should call unit_convert AND search_documents from different servers
Why This Matters — Real Production Numbers Internal Claude agents at Anthropic regularly maintain 4–6 simultaneous MCP server connections per session. The pattern you just built — MCPHost with a tool_map — scales directly to production. Adding a new MCP server is three lines: StdioServerParameters, one add_stdio_server() call, and your new tools appear automatically in the next agent invocation. No other code changes needed.

Knowledge Check

Six questions covering host/client architecture, transport differences, error handling, and capability negotiation.

1. In MCP terminology, what is the difference between a host and a client?

A
The host and client are the same thing — both refer to the application connecting to an MCP server.
B
The host is the application (e.g., your agent) that owns the LLM context and may have many clients; a client is one connection-scoped object managing exactly one server connection.
C
The client is the application and the host is the underlying transport layer (stdio or HTTP).
D
A host is an MCP server that can also act as a client for other servers; clients are always end-user applications.

2. What must you call before using any other method on a ClientSession?

A
session.list_tools() — to discover what tools are available
B
session.connect() — to open the transport connection
C
await session.initialize() — to run the three-step handshake and receive server capabilities
D
Nothing — ClientSession auto-initializes on first method call

3. Your MCPHost connects to two servers: "doc-server" with tools [search_documents, save_note] and "file-server" with tools [read_file, list_directory]. The LLM calls read_file. What does the host do?

A
Broadcasts the call to all connected servers and uses the first successful response
B
Looks up tool_map["read_file"]"file-server", then routes the call exclusively to the file-server ClientSession
C
Raises ToolNotFoundError because read_file is not in the LLM's tool list
D
Sends the call to doc-server first as a fallback, then tries file-server if it fails

4. After calling session.initialize(), you check init_result.capabilities and see that capabilities.prompts is None. What should you do?

A
Call session.list_prompts() anyway — the server might support prompts but forgot to declare it
B
Reconnect with a different protocol version that forces prompts support
C
Skip prompts/list entirely — calling it would return a -32601 "Method not found" error. Gracefully degrade to tools and resources only.
D
Throw an error — a server missing prompts support is non-compliant and cannot be used

5. In the RobustMCPClient, what triggers an InvalidParamsError rather than a generic MCPClientError?

A
The tool returns isError: true with error text matching /invalid|param/i
B
Any exception raised by the server subprocess
C
A JSON-RPC protocol error with code -32602
D
The tool name not being in self._known_tools

6. You're using asyncio.gather() to add two servers to MCPHost concurrently. What is the main benefit compared to adding them sequentially?

A
Concurrent startup allows both initialize handshakes to run in parallel, cutting total startup time roughly in half (or by 1/N for N servers)
B
Tool naming conflicts are resolved automatically when servers start simultaneously
C
Concurrent startup enables the servers to share a single stdio pipe, using less memory
D
It prevents tool_map collisions by serializing the registration step