Lesson 7 of 12Track 3

Building MCP clients

Client architecture

Discovery, connection, and tool invocation.

Video lesson ~10 min

Video coming soon

What lives inside the host

The previous module wrote servers. This module is about what's on the other side of the wire: the client. A client is a small library inside the host (your agent application) that speaks the MCP protocol with one server. One client, one server, always.

Most of the time you don't write a client from scratch; the MCP SDK provides one for your language. But understanding what it does is what lets you reason about reliability, security, and multi-server orchestration. This lesson is the conceptual map.

What a client is responsible for

Three jobs:

1. Establishing the connection

The client knows how to start the conversation: spawn the subprocess (for stdio), open the HTTP connection (for streamable HTTP), do the protocol handshake, intersect capabilities, store what the server supports.

2. Translating between method calls and protocol messages

The host code calls something like client.call_tool("weather", {"city": "Tokyo"}). The client serializes that into a JSON-RPC tools/call message, sends it, waits for the response, deserializes the result, and returns it to the host.

3. Handling lifecycle events

Disconnects, errors, server-pushed notifications, reconnect logic. The client surfaces these to the host as language-native events (callbacks, async errors, etc.) so the host doesn't have to deal with the wire-level details.

A typical client API

The Python MCP SDK (and most others) exposes a small set of methods:

async with stdio_client("python", "server.py") as client:
    # 1. Connect (handshake happens here, behind the scenes)
    await client.initialize()
 
    # 2. List capabilities
    tools = await client.list_tools()
    resources = await client.list_resources()
    prompts = await client.list_prompts()
 
    # 3. Use them
    result = await client.call_tool("weather", {"city": "Tokyo"})
    content = await client.read_resource("doc://onboarding")
    template = await client.get_prompt("review_pr", {"pr_url": "..."})
 
    # 4. Cleanup happens automatically on context exit

The shape is similar across SDKs. The async with pattern handles connection lifecycle automatically; explicit connect/disconnect calls are also available for cases where you need them.

The host's job above the client

The client handles one server. The host handles all of them. Specifically:

Configuration

Where do servers live? What transports? What environment variables? This is usually a JSON config file (.mcp.json or similar) that the host reads on startup.

Aggregation

If you have five servers, you have five clients, each with its own list of tools. The host aggregates them into a unified view that the agent can reason about. Tool names need to be namespaced (more on that in the next lesson) to avoid collisions.

Routing

When the agent calls a tool, the host figures out which client owns that tool and dispatches accordingly. Same for resources (URI scheme determines the server) and prompts.

Lifecycle

Hosts handle "the server I depended on died, restart it." This includes backoff, deciding whether to fail the user-visible request or hide the failure, and surfacing the error in the right place.

Where the abstraction layers sit

User intent
   |
   v
Agent reasoning (model + system prompt + history)
   |
   v
Tool call decision
   |
   v
Host: aggregated tool registry, routing, namespacing
   |
   v
Client: protocol messages, transport, connection lifecycle
   |
   v
Wire (stdio / HTTP)
   |
   v
Server: capability declarations, dispatch, primitive implementations
   |
   v
Actual capability (filesystem, DB, API, ...)

The agent code rarely touches the client directly. It calls into the host's tool-call mechanism; the host figures out the rest. Layered well, swapping one server for another is a config change, not a code change.

State and connection pooling

Each client/server connection has its own state:

  • The negotiated capabilities.
  • Any subscriptions (for resource notifications).
  • Open in-flight requests with their correlation IDs.
  • Possibly a session token for HTTP-based servers.

For long-running hosts, clients are typically kept alive across many user requests. For short-lived hosts (a one-shot CLI tool), you spin up clients per invocation. The cost difference matters when servers have non-trivial startup time.

For HTTP-based servers, you don't necessarily have a 1:1 mapping between client objects and TCP connections. The HTTP library handles connection pooling under the hood; the MCP client just sends and receives.

Error surfaces

Three kinds of errors a client surfaces to the host:

Protocol-level

The connection died. The handshake failed. The server returned malformed JSON. These are usually fatal for the connection; the host has to either reconnect or give up.

Method-level

A tools/call came back with a JSON-RPC error. The arguments were invalid, the tool doesn't exist, or the server hit an internal error. These are non-fatal for the connection; the host can decide whether to retry, fall back, or surface to the agent.

Logical-level

A tools/call came back successfully but with a structured {"status": "error", "reason": "..."} payload. From the protocol's point of view this is a normal response; from the agent's point of view it's a tool failure. The agent gets to reason about it.

A well-designed host distinguishes the three and reports each at the right level. Conflating them is a common source of confusing logs.

Authentication

For stdio servers, the host's environment is the server's environment; auth tokens come in via env vars. The client doesn't really do "auth" in any meaningful sense.

For HTTP servers, the client holds an auth token (bearer, OAuth, mTLS cert) and includes it on each request. Hosts typically configure auth alongside the server URL:

{
  "mcpServers": {
    "github": {
      "url": "https://github-mcp.example.com",
      "auth": {"type": "bearer", "token_env": "GITHUB_MCP_TOKEN"}
    }
  }
}

The client reads GITHUB_MCP_TOKEN from the environment and sends it on every request. We come back to auth in Module 4 lesson 1.

Why this matters before "multi-server"

The next lesson manages multiple clients/servers from one host. Most of the complexity there comes from things you should already see at the single-client level: connection lifecycle, error surfacing, configuration. Multi-server adds aggregation and routing on top, but the per-client foundations stay the same.

The client API is your stable surface

SDKs evolve. Wire-protocol versions evolve. As a host author, you usually shouldn't depend on the wire format directly. The SDK's client API is the boundary you should code against. Same goes for SDK consumers: don't assume a particular protocol version; use the methods the client exposes and let the client handle protocol drift.

Key takeaway

A client is a small library inside the host that speaks MCP with one server: it handles the connection, translates host method calls to protocol messages, surfaces errors and notifications. The host sits above and aggregates many clients into one tool registry, routes calls, manages lifecycle. Three error surfaces (protocol, method, logical) need to be distinguished for clean reporting. Auth lives at the transport layer, not in MCP itself. The next lesson scales this up: many servers from one host, with namespacing and dynamic discovery.

Done with this lesson?