TAI BUI
Back to curriculum
BuildPython

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.

TIME: ~75 minutesPREREQUISITES: Phase 13 · 07 (building an MCP server)

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:

  1. Spawn each server.
  2. Handshake each one independently.
  3. Call tools/list on each and flatten the results.
  4. When the model emits notes_search, look it up in the merged namespace and route it to the right server.
  5. Handle notifications from any server (tools/list_changed) without blocking.
  6. 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.

Warm-up0/3
1. A stdio MCP server like the one you built in Phase 13 · 07 is running. How does a client reach it?Warm-up
A client box on the left and a box labelled stdio MCP server, already running, on the right, joined by a dashed line with a question mark in the middle.
Setup only. The server is already running; the link between the two is what the question asks about.
2. On a fresh connection to an MCP server, what must happen before the client can call `tools/list`?Warm-up
A client and a server drawn as two lifelines. A fresh connection opens at the top, a dashed box with a question mark fills the middle, and a tools/list request goes from client to server at the bottom.
Setup only. The dashed box stands for whatever has to happen before the first tools/list.
3. A program calls a blocking readline on a child process's output pipe, and the child prints nothing for a minute. What happens to the calling thread?Warm-up
A time axis from 0 s to 1 min. The child process row prints nothing the whole time. The calling thread row starts with a readline() call and ends in a box with a question mark at 1 min.
Setup only: one blocking readline on the child's output pipe, and a silent child.
Check: The Problem0/1
1. What is an MCP client in a host such as Claude Desktop or Cursor?Define
A host application box labelled Claude Desktop, Cursor containing a box called MCP client. Outside the host, unconnected, are three server boxes: notes, files and github.
Setup only.

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 at initialize.
  • tools: the last tools/list result.
  • 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:

  1. Prefix by server name. notes/search, files/search. Clear but ugly.
  2. Silent first-come. The later server's search overrides the earlier one. Risky, because it hides the collision.
  3. 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:

  1. Holds further requests to that server until the sample resolves, or pipelines them if its implementation supports concurrency.
  2. Calls its LLM provider.
  3. 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.

Check: The Concept0/20
1. A server sends `notifications/tools/list_changed`. What should the client do?Define
A notes server with tools search and create sends a notification message with method notifications/tools/list_changed to a client box. A dashed box asks what the client does.
Setup only. Server tools are from the lesson's main.py; the JSON line is typed.
2. How does a stdio session differ from a Streamable HTTP session when it comes to session identity?Define
A two-column table comparing Stdio and Streamable HTTP. The first row names the server kind: a stdio server, or an HTTP server. The session identity row has a question mark in each column.
Setup only. The session identity cells are left open on purpose.
3. According to The Concept, which fields does a `Session` hold for each server?Define
A client box holding three Session boxes, one each for notes, files and github. Inside every Session box is a large dashed question mark where the fields would be.
Setup only. One Session object per server.
4. A server that declared the `sampling` capability sends `sampling/createMessage`. What does the client do?Define
A server box that declared the sampling capability sends a sampling/createMessage request to a client box. A dashed box asks what the client does.
Setup only.
5. How does the client decide which server receives a given `tools/call`?Define
A model box emits a tools/call for open_pr into a client box. On the right three server boxes, notes, files and github, are separated from the client by a dashed question mark.
Setup only. The tool name comes from main.py's github server.
6. What is a merged namespace?Define
Three server boxes with their tools: notes has search and create, files has read and search, github has list_issues, open_pr and search. Below them, a box titled Merged namespace holds a dashed question mark. A note says the client has called tools/list on all three servers.
Setup only. The tool lists are real, from main.py.
7. What is a namespace collision in a multi-server client?Define
Two tools/list results side by side. The notes server lists search (Search notes) and create (Create a note). The files server lists read (Read a file) and search (Search files). Arrows from both lead to a Client registry box holding a dashed question mark.
Setup only. Names and descriptions are real, from main.py.
8. What is a background reader in an MCP client?Define
A server's stdout shows three JSON messages in order: a result for id 1, a tools/list_changed notification, and a result for id 2. An arrow leads to a client box that contains a main thread and a background reader marked with a question mark.
Setup only. The JSON lines are typed and abbreviated.
9. What does a reconnection policy decide?Define
A client box with a reconnection policy field marked by a question mark, joined by a line labelled stdio pipe to a files server box.
Setup only.
10. Which Python call does The Concept recommend for spawning each MCP server?Explain
A server list with notes, files and github rows, each with placeholder command and args, and an arrow to a For each server box that shows server equals a question mark call on command plus args.
Setup only. Commands are placeholders, not from the lesson.
11. Which collision policy does the lesson call the safest for a security-sensitive host?Explain
A security-sensitive host holding two servers: notes with search and create, and files with read and search. A dashed box asks which collision policy to use.
Setup only. Tool lists are from main.py.
12. Why does The Concept recommend a background reader thread that feeds a queue?Explain
Four boxes left to right: server stdout, reader thread, queue and main thread, joined by arrows. A dashed box asks why a reader thread feeding a queue helps.
Setup only.
13. Why does each Session keep a `pending` map from request id to a future?Explain
Two callers each have a tools/call, one to notes with id 1 and one to files with id 2. Both feed a Session.pending map with entries 1 to future and 2 to future.
Setup only. Ids are illustrative.
14. Why are keepalive pings optional for a stdio session?Explain
A client and a stdio server joined by stdin and stdout arrows. A dotted line labelled idle for a long stretch runs below them, with a keepalive ping tag under it.
Setup only.
15. Why does the client store each server's capabilities from `initialize` on its Session?Explain
The files server's initialize result lists capabilities tools and resources. An arrow leads to the Session for files box, where the capabilities field is a question mark.
Setup only.
16. How does a stdio client learn that a server's session is over?Explain
A client box and a greyed stdio server box marked session is over, joined by a dotted line. A dashed box asks how the client learns this.
Setup only.
17. A server adds a tool at runtime and sends `notifications/tools/list_changed`. The model never sees the tool, even many turns later. Which client bug is most likely?Analyze
A notes server whose tools now include search, create and a tool added at runtime sends a notifications/tools/list_changed message. The client's tool list shown to the model still has only search and create. A dashed box asks which client bug explains it.
Setup only. Tool names are from main.py; the new tool is unnamed in the stem.
18. A client starts a slow tool on server A. A notification from server B is only processed after that call finishes. What design flaw explains this?Analyze
A time axis with three rows. Server A has a slow tools/call in progress. Server B sends a notification part-way through it. The client processes the notification only after the call ends.
Setup only. Positions on the time axis are illustrative.
19. A server process is killed by the OS. The client keeps sending it `tools/call` messages, and each call waits forever. Which piece is missing?Analyze
A client sends three tools/call messages to a server process marked killed by the OS. The text says each call waits and nothing comes back. A dashed box asks which piece is missing.
Setup only. The number of calls is illustrative.
20. A stdio server dies partway through a user's long-running session. Which reconnection policy fits, per Reconnection?Design
A timeline of a user's long-running session with five earlier calls shown as dots, then a red cross where the stdio server dies, then a dashed continuation with a question mark.
Setup only. The number of earlier calls is illustrative.

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/list results merged into a 7-tool namespace.
  • Routing decisions based on the tool name.
  • Collisions on search resolved by prefixing.
  • A call to a dead session returning an error result.

What to look at:

  • The Session dataclass 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.
Check: Use It0/11
1. Why does each dispatch-table entry record the tool's local name as well as its owning server?Explain
The model calls files/search. A dispatch table entry shows canonical_name files/search, server_name files, local_name shown as a question mark, and description Search files. An arrow leads to the files server, which receives a tools/call.
Setup only. The entry is what main.py builds for files/search; local_name is left open.
2. You register `notes`, `files` and `github` in that order with prefix-on-collision, and all three expose `search`. The model emits a call to plain `search`. Where does it go, and what is the risk?Apply
Three server boxes numbered by registration order: 1 notes, 2 files, 3 github. Each lists its tools, and all three include search. Below, the model emits a call to the bare name search and a dashed box asks where it goes.
Setup only. Tool lists are real, from main.py; the order is the stem's.
3. In the demo registry built from `notes`, `files` and `github`, the model calls `read`. What does the client return?Apply
Three server boxes, notes, files and github, with their tools. Below, the model calls read and a dashed box asks what the client returns.
Setup only. Tool lists are real, from main.py.
4. The demo calls `github/search`. What does the client do?Apply
Three server boxes, notes, files and github, with their tools, merged with prefix-on-collision. Below, the demo calls github/search and a dashed box asks what the client does.
Setup only. Tool lists are real, from main.py.
5. Merging the same three servers with the `reject` policy instead of prefixing, how many tools end up in the registry?Apply
Three server boxes, notes, files and github, with their tools. Below, the call merge with policy reject is followed by a dashed box asking how many tools end up in the registry.
Setup only. Tool lists are real, from main.py.
6. The three servers are merged with the `reject` policy, and then the model calls `files/search`. What comes back?Apply
Three server boxes, notes, files and github, with their tools, merged with policy reject. Below, the model calls files/search and a dashed box asks what comes back.
Setup only. Tool lists are real, from main.py.
7. In the demo, `notes` is flagged dead, then the client calls `read` and then `search`. What comes back?Apply
Three server boxes with their tools. The notes box is outlined in red and marked alive = False. Below, two calls, read and then search, each with a dashed box asking what comes back.
Setup only. Tool lists are real, from main.py.
8. Suppose the demo registers `files` first, then `notes`, then `github`, with prefix-on-collision. Which server answers a call to bare `search`, and what happens to notes' tool?Apply
Three server boxes numbered by registration order: 1 files, 2 notes, 3 github, each with its tools. Below, a call to bare search with a dashed box for which server answers, and another for what happens to notes' tool.
Setup only. Tool lists are real, from main.py; the order is the stem's.
9. A client forwards the call `files/search` to the files server exactly as the model named it, and the server reports an unknown tool. What is wrong?Analyze
A model emits files/search, the client forwards a tools/call with name files/search unchanged to the files server, and the server answers unknown tool.
Setup only. The error reply is described in the stem; main.py's stub servers do not produce it.
10. You must turn the in-process demo into a working stdio client. Which set of changes does the lesson point to?Design
A box for the in-process demo main.py with three stub functions and the Session fields name, server_fn, capabilities, tools and alive. An arrow leads to a Working stdio client box that holds a dashed question mark for the changes needed.
Setup only. Names are from main.py.
11. You want the model's call to bare `search` refused as ambiguous, while `files/search` and `github/search` still route. Which design fits?Design
A table of three wanted calls: bare search is refused as ambiguous, files/search still routes to files, and github/search still routes to github. A dashed box asks which design achieves this.
Setup only.

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.

Author's Note

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

  1. Run main.py and 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.

  2. merge() already prefixes the later tool on a collision. Extend it so a call to the bare name search is refused with an ambiguity error instead of silently routing to notes, and verify the prefixed names still route correctly.

  3. 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.

  4. 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.)

  5. Port the client to the official MCP Python SDK. The SDK wraps stdio_client and ClientSession. The code should shrink from about 200 lines to about 40 while preserving multi-server routing.

Key Terms

TermWhat people sayWhat 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

Code

Ships With