Building an MCP Client — Discovery, Invocation, Session Management
Most MCP material covers servers and waves a hand at the client, but the client is where the orchestration gets hard: spawning processes, negotiating capabilities, merging tool lists across servers, handling sampling callbacks, reconnecting, and resolving name collisions. This lesson builds a multi-server client that lifts three MCP servers into one flat tool namespace for the model.
Learning Objectives
- Spawn an MCP server as a child process, complete `initialize`, and send a `notifications/initialized`.
- Maintain per-server session state (capabilities, tool list, last-seen notification ids).
- Merge tool lists across multiple servers into one namespace with collision handling.
- Route a tool call to the server that owns it and reassemble the response.
The Problem
A real agent host (Claude Desktop, Cursor, Goose, Gemini CLI) loads several MCP servers at once. A user might have a filesystem server, a Postgres server, and a GitHub server running side by side. The client's job:
- Spawn each server.
- Handshake each one independently.
- Call
tools/liston each and flatten the results. - When the model emits
notes_search, look it up in the merged namespace and route it to the right server. - Handle notifications from any server (
tools/list_changed) without blocking. - Reconnect on transport failure.
Hand-rolling all of that is what separates a toy from something serviceable. The official SDKs wrap it, but the mental model has to be yours.
The Concept
Child-process spawning
Use subprocess.Popen with stdin=PIPE, stdout=PIPE, stderr=PIPE. Set bufsize=1 and use text mode so you can read line by line. Each server is one process, and the client holds one Popen handle per server.
Per-server session state
One Session object per server holds:
process: the Popen handle.capabilities: what the server declared atinitialize.tools: the lasttools/listresult.pending: a map from request id to a promise or future waiting on the response.
Requests are async by nature. A tools/call sent to server A while server B is mid-call should not block on B, so use either threads with queues or asyncio.
Merged namespace
Once the client has the aggregate tool list, names can collide. Two servers might both expose search. The client has three options:
- Prefix by server name.
notes/search,files/search. Clear but ugly. - Silent first-come. The later server's
searchoverrides the earlier one. Risky, because it hides the collision. - Collision rejection. Refuse to load the second server and notify the user. The safest choice for security-sensitive hosts.
Claude Desktop uses prefix-by-server. Cursor uses collision rejection with a clear error. VS Code MCP also adopts prefix-by-server.
Routing
After merging, a dispatch table maps tool_name -> session. The model emits a call by name, the client finds the session, writes a tools/call message to that server's stdin, and awaits the response.
Sampling callback
If the server declared the sampling capability at initialize, it may send sampling/createMessage asking the client to run its LLM. The client then:
- Holds further requests to that server until the sample resolves, or pipelines them if its implementation supports concurrency.
- Calls its LLM provider.
- Sends the response back to the server.
Lesson 11 covers sampling end to end. This lesson leaves it out of the code.
Notification handling
notifications/tools/list_changed means re-call tools/list. notifications/resources/updated means re-read the resource if it is in use. Notifications get no response, so don't try to ack them.
A common client bug is blocking the read loop on tools/call while a notification sits in the stream. Run a background reader thread that pushes each message onto a queue, and let the main thread dequeue and dispatch.
Reconnection
The transport can fail: the server crashes, the OS kills the process, the stdio pipe breaks. The client detects EOF on stdout and treats the session as dead. From there you have two options:
- Restart the server silently and re-handshake. Fine for read-only servers.
- Surface the failure to the user. Fine for stateful servers with user-visible sessions.
Phase 13 · 09 covers Streamable HTTP reconnection semantics. Stdio is simpler.
Keepalive and session id
Streamable HTTP uses an Mcp-Session-Id header. Stdio has no session id: the process identity is the session. Keepalive pings are optional, since stdio pipes do not break under inactivity.
Use It
main.py simulates three MCP servers (notes, files, github) as plain Python functions inside one process. There is no subprocess and no real LLM, so the code stays focused on discovery, merging, and routing. The Session and dispatch shape match what a stdio client needs. To get a working client, swap each stub for a Popen handle and add the reader thread from The Concept.
Run it and you will see:
- Three initializations, each with its own capability set.
- Three
tools/listresults merged into a 7-tool namespace. - Routing decisions based on the tool name.
- Collisions on
searchresolved by prefixing. - A call to a dead session returning an error result.
What to look at:
- The
Sessiondataclass holds per-server state in one place. - The dispatch table is a plain
dict[str, MergedTool]that records the owning server and the tool's local name. - Collision handling is explicit. When a name is already registered,
merge()either renames the later tool to<server>/<name>or skips it, depending on the policy.
Ship It
This lesson produces outputs/skill-mcp-client-harness.md. Given a declarative list of MCP servers (name, command, args), the skill produces a harness that spawns them, merges their tool lists, and ships a routing function with collision resolution.
I'd run main.py and read the merged registry before touching the concept section. Three servers expose seven tools, and search shows up in all three of them. The registry lists it as search (owned by notes), files/search, and github/search. That is the prefix-on-collision policy at work, and it has a cost you can see right away: the first server added keeps the bare name, so which server gets it depends on registration order.
The call step makes that concrete. Calling search goes to notes, because the bare name was claimed first. Calling files/search goes to files. I would not ship the bare-name behavior to a model. A model that emits search has no way to know which server it will get, which is why the reject policy in merge() is worth trying on this same data.
Keep the toy in proportion. It has no transport, no reader thread, and no sampling, so the hard parts from the concept section are not exercised. alive is just a flag you flip by hand, and the dead-session call returns an error result because call() checks that flag. A real client sets it when it sees EOF on stdout. That check is the part of the dispatch path I'd keep when you swap the stubs for subprocesses.
Exercises
-
Run
main.pyand read the log from each step. Then replace one of the stub functions with a real subprocess, kill it with SIGTERM, and watch how the client detects the EOF and marks that session as dead. -
merge()already prefixes the later tool on a collision. Extend it so a call to the bare namesearchis refused with an ambiguity error instead of silently routing tonotes, and verify the prefixed names still route correctly. -
Add connection-pool-style backoff for server restarts: exponential backoff on consecutive failures, capped at 30 seconds, with a notification to the user after three failures.
-
Sketch a client that supports 100 concurrent MCP servers. What data structure replaces the simple dispatch dict? (Hint: a trie for prefix namespacing, plus a metric for tool count per server.)
-
Port the client to the official MCP Python SDK. The SDK wraps
stdio_clientandClientSession. The code should shrink from about 200 lines to about 40 while preserving multi-server routing.
Key Terms
| Term | What people say | What it actually means |
|---|---|---|
| MCP client | "The agent host" | Process that spawns servers and orchestrates tool calls |
| Session | "Per-server state" | Capabilities, tool list, and pending-request bookkeeping |
| Merged namespace | "One tool list" | Flat set of tool names across all active servers |
| Namespace collision | "Two servers same tool" | Client must prefix, reject, or first-come the duplicate |
| Routing | "Who gets this call?" | Dispatch from tool name to owning server |
| Background reader | "Non-blocking stdout" | Thread or task that drains server stdout into a queue |
| Sampling callback | "LLM-as-a-service" | Client handler for sampling/createMessage from server |
notifications/*_changed | "Primitive mutated" | Signal the client must re-discover or re-read |
| Reconnection policy | "When server dies" | Restart semantics when transport fails |
| Stdio session | "Process = session" | No session id; child process lifetime is the session |
Further Reading
- Model Context Protocol: Client spec: canonical client behavior
- MCP: Quickstart client guide: hello-world client tutorial with the Python SDK
- MCP Python SDK: client module: reference
ClientSessionandstdio_client - MCP TypeScript SDK: Client: TS parallel
- VS Code: MCP in extensions: how VS Code multiplexes multiple MCP servers in a single editor host
Code
- main.py (python, 179 lines)