Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Building MCP Clients

Interview answer (say this first). An MCP client is the code that opens a connection to one MCP server, performs the initialize handshake to agree on a protocol version and capabilities, discovers the server’s tools, resources, and prompts, and calls them on behalf of a host. It also translates MCP tools into the tool schema a model understands, and it owns timeouts, errors, reconnection, and cleanup. Building a client is mostly lifecycle, translation, and failure handling.

Why this exists

A server is useless on its own. Consider a filesystem MCP server that exposes read_file. It sits there offering a capability, but the model cannot see it. Something has to:

  • start or reach the server,
  • agree with it on what both sides support,
  • ask “what tools do you have?”,
  • turn each answer into a tool definition the model can choose,
  • call the tool when the model asks, and
  • return the result back to the model.

That “something” is the client. The host (the app the user runs) may manage several clients, one per server. Each client owns exactly one connection.

Here is the failure without a client. A team writes a database MCP server and configures the host. The model still cannot answer “how many users signed up?” because nobody ever called list_tools. The server logs no traffic. The tools exist, but no code connected, negotiated, or listed them. The bug is not in the server or the model. It is the missing client.

server says: "I offer list_tables and run_query"
client says: nothing, because it was never written
model says: "I have no tools for that"

A second failure is subtler. A client connects but skips the handshake. It calls tools/list immediately. The session was never initialized, so the request is rejected; the SDK raises MCPError: Invalid request parameters. The connection looks alive, but every request fails.

Note:

The one-sentence purpose. An MCP client is the per-server adapter that connects, negotiates capabilities, exposes the server’s tools and resources to the host and model, and manages the whole lifecycle including failure.

Start from zero

Before going further, here are the words this topic keeps using.

WordPlain meaning
MCPModel Context Protocol. A standard way for AI apps to talk to external capability providers.
HostThe application the user runs (an IDE, a chat app, an agent runtime). It manages clients and enforces trust.
ClientOne connection to one server, owned by the host. This page is about writing it.
ServerA process or service that exposes tools, resources, and prompts.
TransportHow bytes move: a local process pipe (stdio) or a network connection (HTTP).
stdioStandard input/output. The client spawns the server as a child process and talks over its stdin/stdout.
Streamable HTTPThe remote transport. The client connects to a URL, and the server may keep a session.
JSON-RPCThe message format MCP uses: a request has a method and params, a response has a result or an error.
SessionThe stateful conversation between one client and one server, created by the handshake.
Initialize handshakeThe first exchange where both sides swap protocol version, names, and capabilities.
CapabilityA feature a side supports, such as tools, resources, prompts, sampling, or roots.
NegotiationAgreeing on a protocol version and which optional features are in play.
ToolA callable function the server exposes, with a name and an input schema.
ResourceRead-only data the server exposes by URI, such as file:///notes.txt.
PromptA reusable message template the server offers to the host.
Input schemaJSON Schema describing a tool’s arguments.
MCPErrorThe SDK’s exception type for protocol-level failures and timeouts.
Read timeoutHow long the client waits for a response before giving up.
RootsFilesystem locations the client tells the server it may work in. A client capability.
SamplingLetting a server ask the client’s model to generate text. A client capability.
ElicitationLetting a server ask the user for missing input. A client capability.

Three distinctions matter:

  • Host vs client. The host is the app; a client is one connection. A host with three servers has three clients.
  • Capabilities flow both ways. The client declares what it supports (roots, sampling, elicitation), and the server declares what it supports (tools, resources, prompts).
  • Transport is not the protocol. stdio and HTTP carry the same JSON-RPC messages. The same client logic works over both.

The core idea

Think of an embassy. The server is a foreign country with services to offer. The host is your government, deciding which countries you may contact. The client is the diplomat stationed at one embassy. The diplomat’s first job is to present credentials and agree on a common language (the handshake). Only then can they ask, “What services do you offer?” and request one.

The handshake is the part people skip and then debug for hours. It is a real, ordered exchange:

sequenceDiagram
    participant C as Client
    participant S as Server
    C->>S: initialize (protocolVersion, clientInfo, capabilities)
    S-->>C: initialize result (protocolVersion, serverInfo, capabilities)
    C->>S: notifications/initialized
    Note over C,S: session is now open
    C->>S: tools/list
    S-->>C: tools with inputSchema
    C->>S: tools/call (name, arguments)
    S-->>C: content + structured content or isError
    C->>S: resources/read (uri)
    S-->>C: resource contents

The client is responsible for four things at once:

ResponsibilityConcrete workFailure if skipped
ConnectSpawn the process or open the URLNothing is reachable
NegotiateVersion and capabilitiesRequests rejected; features assumed wrongly
DiscoverList tools, resources, promptsModel has no tools to call
Translate and callMap schema to model, execute, return resultModel invents arguments; results lost
GuardTimeouts, errors, reconnection, cleanupHung sessions and zombie processes

The developer-visible part of a client is small. The reliability of an agent that uses MCP depends on the last row.

How it works

  1. Build the server descriptor. For stdio, that is a command plus arguments. For HTTP, that is a URL.
  2. Open the transport. stdio_client spawns the process and returns a read stream and a write stream. streamable_http_client opens the network connection and returns the same two streams.
  3. Create a session on those streams. ClientSession wraps the streams with JSON-RPC request/response handling.
  4. Send initialize. The client sends its protocol version, its client name and version, and the capabilities it supports.
  5. Read the initialize result. The server replies with the protocol version it will use, its name and version, and its capabilities. The client should verify the version is one it knows.
  6. Send the initialized notification. This tells the server the handshake is complete. Now normal requests are allowed.
  7. Discover. Call tools/list, resources/list, resources/templates/list, and prompts/list. Results may be paginated with a cursor.
  8. Translate. Convert each MCP tool to the model provider’s tool format. The MCP inputSchema becomes the provider’s parameters or input_schema.
  9. Validate and call. When the model emits a tool call, check the name against the discovered catalog, validate arguments, then call tools/call.
  10. Interpret the result. A result carries content (text, images, or embedded resources), optional structuredContent, and an isError flag. isError is a tool failure, not a protocol failure.
  11. Handle protocol failures separately. Timeouts and transport errors raise MCPError; catch it, decide whether to retry, and surface a clear message to the model.
  12. Close gracefully. Exit the session and transport context managers so child processes and sockets are released.

The high-level Client does steps 1–6 for you. The low-level ClientSession makes them explicit, which is better when you teach or debug the handshake.

The syntax you will use

This SDK is mcp version 2.x. In version 2 the server class is MCPServer; version 1 called it FastMCP. To keep running version 1 code, pin mcp<2. Python attributes are snake_case while the JSON on the wire is camelCase.

Connect with the high-level client (stdio).

from mcp import Client, StdioServerParameters

params = StdioServerParameters(command="python", args=["server.py"])
async with Client(params) as client:      # connect + initialize automatically
    tools = await client.list_tools()

Connect with the low-level session (stdio). This exposes the handshake step by step.

from mcp import ClientSession, StdioServerParameters, stdio_client

params = StdioServerParameters(command="python", args=["server.py"])
async with stdio_client(params) as (read, write):
    async with ClientSession(read, write, read_timeout_seconds=10) as session:
        init = await session.initialize()

Choose a working directory and environment. Useful when the server needs credentials or a project folder.

params = StdioServerParameters(
    command="python",
    args=["server.py"],
    env={"DATABASE_URL": db_url},
    cwd="/srv/app",
)

Connect over Streamable HTTP.

from mcp.client.streamable_http import streamable_http_client

async with streamable_http_client("https://mcp.example.com/mcp") as (read, write):
    async with ClientSession(read, write) as session:
        await session.initialize()

Read the negotiated capabilities. Both sides publish what they support.

init = await session.initialize()
init.protocol_version   # "2025-11-25"
init.server_info        # Implementation(name='demo', version='')
init.capabilities.tools # ToolsCapability(...) or None

List tools, with a cursor for pagination.

result = await session.list_tools()
result.tools            # list[Tool], each with name, description, input_schema
result.next_cursor      # str | None; pass back as cursor for the next page

Call a tool and read the result.

result = await session.call_tool("add", {"a": 2, "b": 3})
result.content          # [TextContent(type='text', text='5')]
result.structured_content  # {'result': 5}
result.is_error         # False on success, True for a tool failure

Read a resource by URI.

res = await session.read_resource("demo://greeting")
res.contents[0].text    # "hello from the demo server"

List and render a prompt. Prompts are templates the host may offer to the user.

prompts = await session.list_prompts()
got = await session.get_prompt("summarize", {"text": "hello"})
got.messages[0].role        # "user"
got.messages[0].content.text

Surface a server tool to a model. The mapping is direct.

def to_openai_tool(tool):
    return {"type": "function", "function": {
        "name": tool.name,
        "description": tool.description or "",
        "parameters": tool.input_schema,   # already JSON Schema
    }}
def to_anthropic_tool(tool):
    return {"name": tool.name, "description": tool.description or "",
            "input_schema": tool.input_schema}

Catch protocol errors and timeouts. Tool failures come back as data; transport failures raise.

from mcp.shared.exceptions import MCPError

try:
    result = await session.call_tool("slow", {"seconds": 5})
except MCPError as exc:            # e.g. "Request 'tools/call' timed out"
    log.warning("mcp call failed: %s", exc)

Aggregate several servers in one group. The hook renames colliding components.

from mcp import ClientSessionGroup

def namespace(name, server_info):
    return f"{server_info.name}.{name}"

async with ClientSessionGroup(component_name_hook=namespace) as group:
    await group.connect_to_server(stdio_params_a)
    await group.connect_to_server(stdio_params_b)
    group.tools              # {"a.add": Tool, "b.search": Tool, ...}
    await group.call_tool("a.add", {"a": 1, "b": 2})

Examples: simple to real

Example 1 — the smallest useful client. This is the whole loop: connect, list, call, print.

import asyncio, sys
from mcp import Client, StdioServerParameters

async def main() -> None:
    params = StdioServerParameters(command=sys.executable, args=["demo_server.py"])
    async with Client(params, read_timeout_seconds=10) as client:
        tools = await client.list_tools()
        print([t.name for t in tools.tools])          # ['add']
        result = await client.call_tool("add", {"a": 2, "b": 3})
        print(result.content[0].text)                 # 5

asyncio.run(main())

Verified output: ['add'] then 5. The high-level client hid the handshake, but it still happened.

Example 2 — do the handshake yourself. This is what you debug when a session misbehaves.

async with stdio_client(params) as (read, write):
    async with ClientSession(read, write, read_timeout_seconds=10) as session:
        init = await session.initialize()
        print(init.protocol_version)     # 2025-11-25
        print(init.server_info.name)     # demo
        print(init.capabilities.tools)   # ToolsCapability(list_changed=False)

Verified: the server advertises tools, resources, and prompts. A client should compare protocol_version against the versions it knows and refuse to continue if there is no overlap.

Example 3 — translate the catalog for a model. Discovery is only useful once the model can see it.

openai_tools = [to_openai_tool(t) for t in (await client.list_tools()).tools]
# [{'type': 'function', 'function': {'name': 'add', 'description': 'Add two integers.',
#   'parameters': {'type': 'object', 'properties': {...}, 'required': ['a', 'b']}}}]

Verified: tool.input_schema is already a Python dict, so it drops straight into the provider request with no conversion.

Example 4 — a bad call is data, not an exception. The server validated the arguments and returned an error result.

result = await client.call_tool("add", {"a": "x", "b": 3})
print(result.is_error)            # True
print(result.content[0].text)     # Error executing tool add: ... validation error ...

Verified. The server also logged Tool 'add' rejected arguments: ['a'] to stderr. Return is_error text to the model so it can correct itself.

Example 5 — a timeout is an exception. Different failure, different handling.

try:
    await session.call_tool("slow", {"seconds": 5})
except MCPError as exc:
    print(str(exc))   # Request 'tools/call' timed out

Verified with a 2-second read timeout. Treat this as a transport problem: retry with backoff, or fail the turn.

Example 6 — several servers, namespaced. The group refuses duplicates unless the hook makes names unique.

print(sorted(group.tools))       # ['demo.add', 'demo2.add']
print(sorted(group.resources))   # ['demo.greeting', 'demo2.greeting']
await group.call_tool("demo.add", {"a": 1, "b": 2})   # 3

Verified: two servers with the same server name raise MCPError on the second connect, because the namespaced components collide. Give each instance a unique name in the hook.

In production

  • Always initialize before other calls. The high-level Client does it; hand-written session code often forgets and gets rejected requests.
  • Pin the protocol version you support. The SDK knows 2024-11-05 through 2026-07-28; if the server offers an unknown version, fail loudly instead of guessing.
  • Own the read timeout. read_timeout_seconds at the session or client is the difference between a slow tool and a hung agent. A tool with no server-side limit can block a turn forever.
  • Treat isError and MCPError differently. isError is a tool-level failure to show the model; MCPError is a transport failure to retry or surface to the operator.
  • Sanitize before showing errors to the model. Server stack traces can leak schema, paths, or credentials. The SDK already hides the traceback from the client, but your own messages still need care.
  • Translate schemas, do not hand-write them. Reusing input_schema keeps the model’s view identical to the server’s contract. Hand-copied schemas drift.
  • Validate the model’s arguments yourself. The server validates too, but catching a bad name or type before the network saves a round trip and gives a better error.
  • Do not trust server-supplied text as instructions. Tool descriptions and results are untrusted input. This is the MCP tool-poisoning risk: a compromised server can try to steer the model.
  • Reconnect deliberately. Streamable HTTP reconnects the event stream (default delay 1000 ms, max 2 attempts) but does not replay lost tool calls. stdio has no reconnection at all; a dead child process must be restarted.
  • Clean up child processes. Exit the async context managers. Leaked stdio servers accumulate as orphan processes and hold database connections.
  • One session per server per task, or pool them. Sessions are stateful; sharing one across unrelated tasks mixes notifications and ordering.
  • Log the tool name, arguments, duration, and result status. When an agent misbehaves, the first question is always “which tool did it call, with what, and what came back?”

Interview questions

1. What is an MCP client, and how is it different from a host?

Answer. The host is the application the user runs; it owns trust, configuration, and the model loop. A client is one connection to one server, managed by the host. A host with three servers runs three clients. The client does the handshake, discovery, and calls.

Follow-up: “Can one client talk to many servers?” Not in the usual design. Each connection is a separate session with its own lifecycle and capabilities. The SDK offers ClientSessionGroup to manage several, but they are still distinct sessions.

Trap. Saying the client is the model or the agent. The client is plumbing; the model only sees the tool definitions the client surfaces.

2. What happens during the initialize handshake?

Answer. The client sends its protocol version, name, version, and capabilities. The server replies with the version it will use, its name and version, and its capabilities. The client then sends an initialized notification. Only after that are normal requests allowed.

Follow-up: “Why negotiate capabilities at all?” So each side knows which optional features exist. If the server does not advertise resources, the client should not call resources/read; if the client does not advertise roots, the server should not ask for them.

Trap. Thinking initialization is optional setup. Requests sent before the handshake completes are protocol errors.

3. How do you expose an MCP server’s tools to a model?

Answer. List the tools, then map each one into the provider’s tool format. The MCP name, description, and input_schema become the provider’s tool name, description, and parameter schema (parameters for OpenAI, input_schema for Anthropic). The schema is already JSON Schema, so no rewriting is needed.

Follow-up: “Anything else to forward?” Tool annotations, such as read-only or destructive hints, are useful for routing and safety, but they are advisory. Do not rely on them for enforcement.

Trap. Hand-writing the model’s tool list instead of deriving it from discovery. The two drift the moment the server changes.

4. How does a client handle a tool that fails?

Answer. A tool failure comes back as a normal result with isError true and error content. That is data, not an exception. Pass it to the model so it can correct course, and log it. A protocol or transport failure raises MCPError; that is a different path, handled with retry or a failed turn.

Follow-up: “Which failures should the model see?” Recoverable, informative ones: bad arguments, missing records, permission denied. Do not feed it raw stack traces or internal paths.

Trap. Catching every exception and returning “error” with no text. The model cannot recover from a message it cannot read.

5. How do timeouts and reconnection work?

Answer. Set a read timeout on the session or client. If no response arrives, the call raises MCPError. Streamable HTTP reconnects the event stream automatically with a short backoff and a small attempt cap, but it does not replay a lost call. stdio does not reconnect; if the child process dies, the client must restart it.

Follow-up: “Where should the real timeout live?” Both places. The client bounds the wait; the server bounds the work. A client timeout without a server-side limit leaves work running.

Trap. Assuming reconnection means the tool call is retried. A retried write can duplicate a side effect unless the tool is idempotent or keyed.

6. How do you manage connections to multiple servers?

Answer. Give each server its own session and keep them in a registry keyed by a stable instance name. Namespace tool names, for example github.search_code and filesystem.read_file, so the model and your logs are unambiguous. ClientSessionGroup does the aggregation and calls a name hook, and it raises an error on duplicate component names.

Follow-up: “What breaks if two servers expose the same tool name?” Aggregation fails or one tool silently shadows the other. Namespacing is the fix, and it must be stable across restarts.

Trap. Prefixing with the server’s self-reported name when two instances share it. Use a unique instance ID from your config.

7. What client capabilities exist, and why would a client declare them?

Answer. The main ones are roots (which filesystem locations the server may use), sampling (let the server ask the client’s model to generate text), and elicitation (let the server ask the user for input). Declaring one is a promise the client will handle the corresponding server request.

Follow-up: “Why would you not declare sampling?” Because it lets the server spend your model budget and can create surprising nested generations. Declare only what you can govern.

Trap. Declaring capabilities you do not implement. The server will send a request nobody answers, and the call hangs.

8. What are the main security responsibilities of an MCP client?

Answer. Treat server output as untrusted. Enforce an allowlist of servers and tools, surface risky tools to the user, validate arguments, cap result sizes, and log every call. Do not pass server text into a system prompt as instructions, because a malicious or compromised server can attempt to redirect the model.

Follow-up: “Where does authorization happen?” Often in the host or a gateway, not the client. The client carries the credential and the connection; the policy decision should be central so every server is governed the same way.

Trap. Trusting the server’s tool annotations for safety. readOnlyHint is a hint from the same party you are trying to constrain.

Remember this

  • The client is the per-server adapter. Host owns trust; client owns one connection.
  • Handshake first. initialize, then the initialized notification, then calls.
  • Two failure channels. isError is tool data; MCPError is transport.
  • Derive the model’s tool list from discovery. Translate input_schema; never retype it.
  • Bound every wait and clean up every process. Timeouts and lifecycle are the client’s job.