Skip to content

4. Clients and hosts

Intermediate · 14 min read

A server is useless until a host connects to it. This page covers both ways that happens: plugging your server into an existing host (Claude, Cursor, VS Code) with a config file, and writing your own host: an agent that discovers MCP tools, hands them to an LLM and runs the calls.

The Python blocks run in order like a notebook. They're marked "no-run" because they need pip install "mcp>=2"; the outputs are from a real run with mcp 2.3.

4.1 Plugging a server into existing hosts

Every host needs the same two things for a local server: the command to start it, and any environment variables. For a remote server it needs the URL.

# local (stdio) server: everything after -- is the command to run
claude mcp add orders -- python /abs/path/orders_server.py
# remote (Streamable HTTP) server
claude mcp add --transport http docs https://mcp.example.com/mcp
claude mcp list                        # check what's connected; /mcp inside a session shows status
// claude_desktop_config.json (Settings → Developer → Edit Config)
{
  "mcpServers": {
    "orders": {
      "command": "python",
      "args": ["C:/projects/orders/orders_server.py"],
      "env": {"ORDERS_DB_URL": "postgresql://localhost/orders"}
    }
  }
}
// .cursor/mcp.json in the project (or ~/.cursor/mcp.json for every project)
{
  "mcpServers": {
    "orders": {"command": "python", "args": ["orders_server.py"]},
    "docs": {"url": "https://mcp.example.com/mcp"}
  }
}
// .vscode/mcp.json — note the top-level key is "servers", not "mcpServers"
{
  "servers": {
    "orders": {"type": "stdio", "command": "python", "args": ["orders_server.py"]},
    "docs": {"type": "http", "url": "https://mcp.example.com/mcp"}
  }
}

Common mistakes when a server "doesn't show up":

Symptom Usual cause
Server fails to start Relative path or wrong python (the host doesn't use your activated venv). Use absolute paths, or uv run --with mcp ...
Connects, then dies Something printed to stdout (a print() or a library banner). Log to stderr
Tools missing Host needs a restart or a reload after a config change
Works in the terminal, not in the host Missing environment variables: hosts don't inherit your shell's .env

With the SDK's CLI, mcp install orders_server.py writes the Claude Desktop entry for you.

4.2 The Python client

mcp.Client takes one argument that says how to reach the server:

You pass Transport Use it for
An MCPServer object In-process, no I/O Unit tests
StdioServerParameters(command=..., args=[...]) stdio: starts the command as a child process Local servers
A URL string "https://.../mcp" Streamable HTTP Remote servers

Here's a real stdio connection. The block writes a small server file, and the client starts it as a subprocess:

# no-run — pip install "mcp>=2"
import asyncio
import json
import sys
import textwrap
from mcp import Client, StdioServerParameters

with open("orders_server.py", "w") as f:
    f.write(textwrap.dedent('''
        from mcp.server.mcpserver import MCPServer
        from mcp.server.mcpserver.exceptions import ToolError

        mcp = MCPServer("orders")
        ORDERS = {"A100": {"status": "shipped", "eta": "Friday"},
                  "A101": {"status": "processing", "eta": "Monday"}}

        @mcp.tool()
        def get_order_status(order_id: str) -> dict:
            """Shipping status of an order. Use when the user gives an order ID like A100."""
            if order_id not in ORDERS:
                raise ToolError(f"order {order_id} not found: IDs look like A100")
            return {"order_id": order_id, **ORDERS[order_id]}

        @mcp.tool()
        def refund_order(order_id: str, amount_inr: int) -> str:
            """Refund an order. Use only after confirming the order exists."""
            return f"refund rf_{order_id} of Rs {amount_inr} created"

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

ORDERS_SERVER = StdioServerParameters(command=sys.executable, args=["orders_server.py"])

async def main():
    async with Client(ORDERS_SERVER) as client:
        print("connected, protocol", client.protocol_version)
        print("tools:", [t.name for t in (await client.list_tools()).tools])
        result = await client.call_tool("get_order_status", {"order_id": "A100"})
        print("result:", json.loads(result.content[0].text))

asyncio.run(main())
Output
connected, protocol 2026-07-28
tools: ['get_order_status', 'refund_order']
result: {'order_id': 'A100', 'status': 'shipped', 'eta': 'Friday'}

The client found the tools at runtime. Nothing about get_order_status is hard-coded in the client, which is the whole point.

4.3 From MCP tools to LLM tools

LLM APIs don't speak MCP. They take a list of tool definitions. The host's job is to translate between the two, and the formats are very close:

# no-run
def to_anthropic(tool) -> dict:
    return {"name": tool.name, "description": tool.description or "", "input_schema": tool.input_schema}

def to_openai(tool) -> dict:
    return {"type": "function", "function": {"name": tool.name, "description": tool.description or "",
                                             "parameters": tool.input_schema}}

async def main():
    async with Client(ORDERS_SERVER) as client:
        tool = (await client.list_tools()).tools[0]
        print(json.dumps(to_anthropic(tool), indent=1))

asyncio.run(main())
Output
{
 "name": "get_order_status",
 "description": "Shipping status of an order. Use when the user gives an order ID like A100.",
 "input_schema": {
  "type": "object",
  "properties": {
   "order_id": {
    "title": "Order Id",
    "type": "string"
   }
  },
  "required": [
   "order_id"
  ],
  "title": "get_order_statusArguments"
 }
}

4.4 A complete agent loop over MCP

Now the whole host: list tools, give them to the model, run every tool call through MCP, feed results back, stop when the model answers. The "LLM" here is scripted so it runs offline. It asks for a status, then answers. Swap fake_llm for a real API call and nothing else changes.

# no-run
SCRIPT = iter([
    {"tool_calls": [{"id": "call_1", "name": "get_order_status", "arguments": {"order_id": "A100"}}]},
    {"tool_calls": [{"id": "call_2", "name": "get_order_status", "arguments": {"order_id": "B7"}}]},
    {"text": "Order A100 has shipped and arrives Friday. I couldn't find B7: could you check the ID?"},
])

def fake_llm(messages, tools):
    return next(SCRIPT)

async def run_agent(question: str, max_steps: int = 5) -> str:
    async with Client(ORDERS_SERVER) as client:
        tools = [to_openai(t) for t in (await client.list_tools()).tools]
        messages = [{"role": "user", "content": question}]
        for step in range(1, max_steps + 1):
            reply = fake_llm(messages, tools)
            if "text" in reply:
                return reply["text"]
            messages.append({"role": "assistant", "tool_calls": reply["tool_calls"]})
            for call in reply["tool_calls"]:
                result = await client.call_tool(call["name"], call["arguments"])
                text = "\n".join(c.text for c in result.content if c.type == "text")
                print(f"step {step}: {call['name']}({call['arguments']}) -> isError={result.is_error}: {' '.join(text.split())[:70]}")
                messages.append({"role": "tool", "tool_call_id": call["id"], "content": text})
        return "Stopped: too many steps."

print(asyncio.run(run_agent("Where are my orders A100 and B7?")))
Output
step 1: get_order_status({'order_id': 'A100'}) -> isError=False: { "order_id": "A100", "status": "shipped", "eta": "Friday" }
step 2: get_order_status({'order_id': 'B7'}) -> isError=True: Error executing tool get_order_status: order B7 not found: IDs look li
Order A100 has shipped and arrives Friday. I couldn't find B7: could you check the ID?

The failed lookup came back as isError=True with a readable message, went into the conversation, and the model used it in its answer. That's the loop from ReAct from scratch, with MCP as the tool layer.

With Claude, the real call is the standard tool-use loop:

# no-run — needs ANTHROPIC_API_KEY; pip install anthropic
import anthropic
claude = anthropic.Anthropic()

response = claude.messages.create(
    model="claude-opus-5-5", max_tokens=16000,
    tools=[to_anthropic(t) for t in mcp_tools], messages=messages,
)
for block in response.content:
    if block.type == "tool_use":
        result = await client.call_tool(block.name, block.input)
        tool_results.append({"type": "tool_result", "tool_use_id": block.id, "is_error": result.is_error,
                             "content": "\n".join(c.text for c in result.content if c.type == "text")})
# append response.content as the assistant turn, all tool_results in ONE user turn, and loop
# until stop_reason != "tool_use"

The Anthropic SDK also ships helpers that convert MCP tools for its tool runner (pip install "anthropic[mcp]"), and the Claude API can connect to remote MCP servers itself (page 6).

4.5 Several servers at once

Real hosts connect to many servers. Two servers can both have a search tool, so prefix every tool name with its server name and route calls back by that prefix:

# no-run
from mcp.server.mcpserver import MCPServer

docs = MCPServer("docs")

@docs.tool()
def search(query: str) -> str:
    """Search the help-centre articles."""
    return f"3 articles about {query!r}"

tickets = MCPServer("tickets")

@tickets.tool()
def search(query: str) -> str:
    """Search open support tickets."""
    return f"12 tickets about {query!r}"

from contextlib import AsyncExitStack

class MultiServerHost:
    def __init__(self, servers: dict):
        self.servers, self.clients, self.routes = servers, {}, {}
        self.stack = AsyncExitStack()                   # closes every connection, in reverse order

    async def __aenter__(self):
        for name, server in self.servers.items():
            self.clients[name] = await self.stack.enter_async_context(Client(server))
            for tool in (await self.clients[name].list_tools()).tools:
                self.routes[f"{name}__{tool.name}"] = (name, tool.name)
        return self

    async def __aexit__(self, *exc):
        await self.stack.aclose()

    async def call(self, prefixed_name: str, arguments: dict) -> str:
        server, tool = self.routes[prefixed_name]
        result = await self.clients[server].call_tool(tool, arguments)
        return result.content[0].text

async def main():
    async with MultiServerHost({"docs": docs, "tickets": tickets}) as host:
        print("tools the LLM sees:", sorted(host.routes))
        print(await host.call("docs__search", {"query": "refunds"}))
        print(await host.call("tickets__search", {"query": "refunds"}))

asyncio.run(main())
Output
tools the LLM sees: ['docs__search', 'tickets__search']
3 articles about 'refunds'
12 tickets about 'refunds'

Keep the prefix short: tool names count against the model's context on every call, and some APIs limit name length (64 characters is common).

4.6 Caching the tool list

Listing tools on every turn wastes a round trip, and a changing tool list breaks the LLM's prompt cache (tools come first in the prompt). In the 2026-07-28 spec, list results carry ttlMs (how long you may cache) and cacheScope (public or private), and the Python Client caches them for you by default. Servers should return tools in a stable order for the same reason.

When a server's tools really change, it sends notifications/tools/list_changed (over subscriptions/listen in the new spec) and the client re-fetches.

Interview questions

What does an MCP host do that a server doesn't?

The host owns the LLM and the user. It starts or connects to servers, lists their tools, converts them into the LLM's tool format, decides which calls to run (and which need approval), executes them through MCP clients, puts results back into the conversation, and shows elicitation forms to the user.

How do you connect an agent to several MCP servers with overlapping tool names?

One client per server, then prefix each tool name with its server (docs__search, tickets__search) and keep a routing table from prefixed name to (server, tool). Libraries such as langchain-mcp-adapters offer the same option (tool_name_prefix=True).

Your MCP server works in the terminal but not in Claude Desktop. What do you check?

That the config uses absolute paths and the right Python (the host doesn't use your venv); that the server prints nothing to stdout; that required environment variables are in the config's env block; and the host's MCP log file for the actual error. Then reproduce with the MCP Inspector.

Why do tool-list caching and stable ordering matter?

Tool definitions sit at the start of the prompt. If the list is re-fetched and reordered or changed on every turn, the LLM provider's prompt cache misses, which costs money and latency. Caching with the server's ttlMs hint, deterministic ordering and re-fetching only on list_changed keeps the prefix stable.

Practice

  • Add a third scripted step in 4.4 that calls refund_order, and print the result.
  • In 4.5, add a tickets__close tool and route a call to it.
  • Connect the orders_server.py from 4.2 to Claude Code or Cursor and ask it about order A101.

Next: 5. Remote servers and auth Back to: MCP overview · Notes overview