Building an MCP Client: Discovery, Routing, and Dual-Era Fallback
A modern MCP client repeats its contract on every request. Its hardest compatibility decision is knowing when an old server is truly old and when a modern server is reporting a correctable error.
Lesson 8 of 45Build~85 minutesPython
Before this: Lesson 7 of Phase 13
On this page
Learning Objectives
- Build every MCP
2026-07-28request with current metadata. - Probe stdio servers with
server/discoverand select a mutually supported version. - Authorize a bounded legacy probe only for explicitly allowlisted peers.
- Accept a legacy era only after validating a positive
initializeresult for a supported revision. - Merge deterministic tool lists without silently overwriting collisions.
- Route calls to the peer that owns each tool without inventing protocol sessions.
The Problem
An agent host usually talks to more than one MCP server. It must discover each server, merge tool catalogs, resolve duplicate names, route calls, and recover from transport failure.
The 2026-07-28 revision makes the steady state simpler because each request is self-contained. Compatibility makes startup more subtle. A client may encounter:
- a modern server that supports the preferred version;
- a modern server that returns a recognized version or header error;
- a legacy server that has never heard of
server/discover; - a legacy server that stays silent until it receives
initialize.
Treating every probe error as legacy is dangerous. A malformed modern request, an overloaded server, a dead process, and an old server can all produce the same timeout or connection close. Those signals are ambiguous. The client must combine explicit operator intent with positive protocol evidence before it chooses the legacy era.
The Concept
A peer, not a protocol session
Keep one transport peer record for each server process or endpoint:
- transport handle or send function;
- selected protocol era and version;
- last discovered server capabilities;
- last deterministic tool list;
- pending request ids for correlation;
- transport health.
This is client bookkeeping. It is not protocol session state. On modern MCP, the server still receives current version and capabilities on every request.
Build every modern request from scratch
def modern_request(request_id, method, params, version, capabilities):
return {
"jsonrpc": "2.0",
"id": request_id,
"method": method,
"params": {
**params,
"_meta": {
"io.modelcontextprotocol/protocolVersion": version,
"io.modelcontextprotocol/clientCapabilities": capabilities,
"io.modelcontextprotocol/clientInfo": CLIENT_INFO,
},
},
}
Do not attach metadata once to a connection object and assume it reached the wire. Stamp and inspect the final serialized request.
Modern discovery
server/discover returns supported versions, server capabilities, instructions, cache hints, and recommended server identity. A client chooses the highest mutually supported modern version.
Discovery is optional for a modern-only client, but it is recommended on stdio. Some legacy servers accept an operation before initialization, so sending tools/list first can produce an ambiguous success. server/discover creates a clean era boundary.
The stdio compatibility probe
A dual-era stdio client sends server/discover with its preferred modern metadata before any other request. There are three outcome classes:
- DiscoverResult. The server is modern. Select a mutually supported version and continue with per-request metadata.
- Recognized modern error. The server is modern. For
-32022, choose fromdata.supportedand retry with a new request id. For header or capability errors, correct the request. Do not sendinitialize. - Ambiguous signal. An unrecognized JSON-RPC error, timeout, connection close, or empty response does not identify an era. Fail closed unless that exact peer is configured for legacy compatibility.
Recognized modern protocol errors include:
-32020HeaderMismatch-32021MissingRequiredClientCapability-32022UnsupportedProtocolVersion
Recognized modern errors remain modern even when the peer is on the legacy allowlist. Once a server proves that it understands the modern error vocabulary, sending initialize would be a downgrade.
Do not treat -32601 as positive legacy evidence. It only makes an explicitly allowlisted peer eligible for one legacy probe. The same rule applies to a timeout, connection close, or empty response.
Allowlisting is operator intent, not evidence
Legacy compatibility must be an explicit property of one pinned peer configuration:
client.add_server("archive", archive_transport, allow_legacy=True)
Bind that choice to the configured command or endpoint. Do not use a wildcard that lets an arbitrary server opt itself into weaker semantics. A peer without allow_legacy=True fails after an ambiguous discovery outcome and never receives initialize.
The allowlist grants permission to probe. It does not select the era. The client sends one initialize under a transport-enforced deadline, then requires all of the following:
- a JSON-RPC
2.0response with the matching request id; - exactly one
resultand noerror; - a
protocolVersionin the client's configured legacy revision set; - an object-valued
capabilitiesfield; - a
serverInfoobject with non-empty stringnameandversionfields.
A timeout, connection close, error response, malformed result, mismatched id, or unsupported revision fails closed. Only a structurally valid positive result selects the legacy era. The code passes legacy_probe_timeout_ms to the transport adapter; a real stdio or HTTP adapter must enforce that deadline rather than merely record it.
Cache the selected era for the transport peer. Do not probe again before every call.
Legacy is a compatibility branch
Once the bounded probe returns valid positive legacy evidence, the client uses the selected legacy version exactly as defined by that revision:
- Verify the response envelope and correlation id.
- Verify the negotiated revision is in the configured legacy set.
- Record validated capabilities and server identity.
- Send
notifications/initializedonly after all checks pass. - Use legacy request shapes for that transport lifetime.
This branch exists for interoperability with known peers. It is not the default design for new servers or new requests. If the transport restarts or its endpoint changes, discard the peer-era cache and negotiate again.
Discovering and caching tools
For each active peer, call tools/list. A modern result includes resultType, ttlMs, and cacheScope. Honor the freshness hint within the correct authorization context. Re-fetch after expiry or a subscribed list-change event.
Clients must treat a missing resultType from a legacy server as "complete". Do not require modern cache fields on a response from an earlier negotiated era.
The server should return deterministic ordering. The client should also sort before merging so local registry order does not depend on process startup timing.
Collision-safe namespace merge
Two servers may both expose search. Choose a declared policy:
- Prefix on collision. Keep the first canonical name and expose later collisions as
<server>/<tool>. - Reject on collision. Do not load the duplicate and surface a clear configuration error.
- Silent overwrite. Never use this. It hides which server receives a model-selected action.
Store both canonical and local names. The model sees the canonical name. The outgoing tools/call uses the local name the owning server declared.
Routing a call
Routing is a pure lookup:
canonical tool name
-> peer name + local tool name
-> new JSON-RPC request id
-> modern request metadata or explicit legacy shape
-> matching response id
Do not send a call when its owning transport is unavailable. Reconnect or restart the transport, then re-run discovery and tools/list. Modern in-flight requests lost on a broken transport can be retried with a new JSON-RPC id when the operation's safety policy permits it.
Notifications and subscriptions
Modern list and resource changes arrive only on a client-opened subscriptions/listen stream. The client sends the notification filter, waits for notifications/subscriptions/acknowledged, and correlates events with the listen request id in notification metadata.
On disconnect, open a new listen request and refetch relevant lists or resources. Modern streams do not resume with Last-Event-ID.
No server-initiated requests
Modern servers do not call the client with independent JSON-RPC requests for sampling, elicitation, or roots. They return input_required, and the client retries the original request after fulfilling the embedded input requests.
Do not block the peer's response reader while fulfilling input. Preserve correlation and create a new JSON-RPC id for the retry.
tp-client-merge
Use It
code/main.py uses in-process peer functions so the protocol decisions stay visible. It connects to two modern peers and one intentionally allowlisted legacy peer, then merges and routes their tools. The transport callable receives a timeout budget so the compatibility branch cannot hide an unbounded probe.
cd code
python3 main.py
python3 -m unittest discover tests -v
The tests prove boundaries that normal demos miss:
- modern requests repeat metadata;
-32022retries modern discovery without initialization;- recognized modern errors never downgrade, even for an allowlisted peer;
- timeouts, connection closes, empty responses, and unrecognized errors do not trigger
initializewithout an allowlist; - an allowlisted peer becomes legacy only after a valid, supported
initializeresult; - malformed and unsupported legacy results leave the peer unavailable;
- a successfully selected era is cached for the transport lifetime.
Ship It
This lesson ships outputs/skill-mcp-client-harness.md. It scaffolds modern request stamping, stdio era negotiation, deterministic namespace merge, routing, and a fail-closed legacy compatibility branch.
Exercises
- Make a fake server return
-32022with no mutually supported version. Confirm the client fails instead of sendinginitialize. - Allowlist a fake legacy server, make its bounded
initializeprobe time out, and prove the peer staysunknownand unavailable. - Add
cacheScope: "private"tool lists for two authorization contexts. Confirm the client never shares one context's cached result with the other. - Change the collision policy to rejection and make startup fail with both peer names in the error.
- Add a finite
subscriptions/listensimulator. On stream loss, re-listen with a new request id and refetch tools.
Key Terms
| Term | Meaning |
|---|---|
| Peer | Client-side record for one server transport and its discovered data |
| Protocol era | Modern per-request metadata or legacy initialization semantics |
| Discovery probe | Initial server/discover used to identify the stdio era |
| Recognized modern error | Error that proves modern behavior and forbids legacy fallback |
| Legacy allowlist | Operator configuration permitting one bounded compatibility probe for a pinned peer |
| Positive legacy evidence | Valid, correlated initialize result for an explicitly supported legacy revision |
| Merged namespace | Canonical tool names across all active peers |
| Collision policy | Prefix or reject rule for duplicate tool names |
| Era cache | Selected modern or legacy behavior stored for one transport peer |
| Transport recovery | Restart or reconnect, rediscover, relist, and retry safely with a new id |
Further Reading
Check yourself
Quick quiz
What should a dual-era stdio client send first?
- Mcp-Session-Id
- server/discover
- initialize
- HTTP GET
Show the answer
B. server/discover The discovery probe creates a clear boundary between modern per-request metadata and legacy initialization.
A discovery probe returns -32022 with a mutually supported version. What should the client do?
- Retry modern discovery at the supported version
- Send initialize
- Open HTTP GET
- Ignore the server
Show the answer
A. Retry modern discovery at the supported version -32022 is recognized modern evidence, so the client stays modern and retries.
Which condition is sufficient to select the legacy era?
- Any -32601 error
- The peer is on a legacy allowlist
- The peer is allowlisted and a bounded initialize probe returns a valid supported legacy result
- A discovery timeout by itself
Show the answer
C. The peer is allowlisted and a bounded initialize probe returns a valid supported legacy result The allowlist permits a probe, but only a structurally valid positive initialize result for a configured legacy revision proves the legacy era.
How should a client handle two tools named search?
- Silently overwrite one
- Trust startup order
- Prefix or reject by declared policy
- Create a protocol session
Show the answer
C. Prefix or reject by declared policy Explicit prefix or rejection keeps routing visible and deterministic.
A legacy result omits resultType after the client negotiated a legacy era. How is it interpreted?
- As input_required
- As complete
- As a transport error
- As server/discover
Show the answer
B. As complete Clients treat absent resultType from earlier protocol versions as complete.
What must happen after a modern transport loss before routing resumes?
- Reconnect, rediscover, and relist
- Resume with Last-Event-ID
- Echo Mcp-Session-Id
- Reuse old connection metadata
Show the answer
A. Reconnect, rediscover, and relist The client rebuilds peer bookkeeping and retries only safe operations with new request ids.
Questions people ask
What happens when two MCP servers have tools with the same name?
A careful client does not let one silently replace the other. It flags the clash and keeps both apart, often by adding a server prefix, so a call meant for one system never lands on another by accident.
Can a new MCP client work with old MCP servers?
Yes, with care. The client first tries the modern discovery request. Only for servers it has been told are allowed, it can fall back to the older startup handshake, and it accepts the old mode only after a valid reply.
Lessons from AI Engineering from Scratch by Rohit Ghumare and contributors, MIT license. Notes for owners by Lozo. License