Build a client
Metadata on every request, version fallback, paging and caching.
Create a free account Try part of it here
Why it matters
Servers get most of the attention, but every host, agent and IDE plugin contains an MCP client, and so will the harness you build in Phase 10. The client is where interoperability is won or lost. It's the part that must cope with servers of different versions, slow servers, servers that page their results, and servers that return errors. A small, careful client makes every server it talks to easier to use.
What you'll be able to do
- Separate a client into a transport (moving bytes) and a session layer (MCP rules)
- Attach the required _meta to every request and match replies to requests by id
- Fall back to a supported protocol version when the server says UnsupportedProtocolVersion
- Page through list results and cache them for as long as ttlMs allows
What it covers
- Two layers
- Reading results carefully
- Follow the cursor to the end
- A minimal client against the practice server
- Why a client needs bookkeeping
- Treat every result as untrusted
Two layers
Think of a secretary and a mail carrier. The secretary knows how letters should be written, keeps copies and notices when an answer doesn't match. The carrier only moves envelopes, and it doesn't matter to the secretary whether that's by van or by bike. Split a client the same way:
- The transport moves one message and brings back the reply. Over HTTP that's a POST with the right headers. Over stdio it writes a line and waits for the line with the matching id. In tests it can be a plain function call into a server object, an in-memory transport.
- The session layer knows MCP's rules: build the message, add
_meta, check the reply, handle errors, page and cache.
The session layer never knows which transport it's using. That means one client works over stdio, HTTP or a test double, and you can test all its logic without a network.
Every request: new id, full metadata
Modern MCP is stateless, so the client attaches its identity on every request:
{"jsonrpc": "2.0", "id": 12, "method": "tools/list", "params": {
"cursor": "...", # method params, if any
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {"elicitation": {}},
"io.modelcontextprotocol/clientInfo": {"name": "shop-assistant", "version": "0.3.0"}}}}
Each request gets a fresh id. When the reply arrives, its id must match. On stdio, several requests can be in flight at once and replies can return in any order. So the client keeps a table of pending requests, keyed by id, like the rail of order tickets in a kitchen:
import json pending = {} # id -> what was asked next_id = 0 def send(method): global next_id next_id += 1 pending[next_id] = method return {"jsonrpc": "2.0", "id": next_id, "method": method} for m in ["tools/list", "prompts/list", "resources/list"]: send(m) # Replies arrive out of order; the id says which question each one answers. for reply in [{"id": 3, "result": {}}, {"id": 1, "result": {}}, {"id": 2, "result": {}}]: print(f"reply {reply['id']} answers {pending.pop(reply['id'])}") print("still waiting:", pending)
next_id = 0 ids = [] for _ in range(3): next_id += 1 ids.append(next_id) print(ids)
Show the answer
[1, 2, 3]
Each pass adds one to next_id and appends it, so ids ends as [1, 2, 3]. Each request gets a fresh id, so each reply can be matched to its request.
A client is mostly careful bookkeeping.
Show the answer
Ignore it (and log it): it doesn't answer any pending request
It may be a late reply to a canceled request. Only ids in the pending table mean anything.
Reading results carefully
- resultType.
"complete"is an ordinary result. Results from legacy servers have noresultTypeat all, and clients must treat that as"complete". A value the client doesn't recognize is invalid. ("input_required"is a request for more information, covered in a later lesson.) - Errors come back as JSON-RPC errors. Raise them as an exception carrying the code, message and data, so callers can react to specific codes.
Version fallback
A client usually prefers the newest version it knows. If the server answers UnsupportedProtocolVersion (-32022), error.data.supported lists what it speaks. The client picks the best version both sides know, remembers it for this server, and retries the request once. If there's nothing in common, it raises a clear error instead of looping.
In plain words: if you ask in French and they say "English or German", switch once and remember. Don't keep asking in French.
Paging and caching
tools/list, prompts/list, resources/list and resources/templates/list can be paged. A helper should follow nextCursor until it's absent, the way you turn pages until the book ends. It should also guard against a server that returns the same cursor twice, which would loop forever.
List results carry ttlMs, which works like a "best before" date. A client can reuse a cached list for that long instead of asking again, which saves round trips and keeps the model's tool list stable. Remember cacheScope too: a "private" result must never be shared with another user.
Follow the cursor to the end
A list can come back in pages. A client keeps asking, passing the cursor back untouched, until there isn't one.
TOOLS = [f"tool_{n}" for n in range(1, 8)] def tools_list(cursor=None, page=3): start = int(cursor) if cursor else 0 result = {"tools": TOOLS[start:start + page]} if start + page < len(TOOLS): result["nextCursor"] = str(start + page) return result tools, cursor, requests_made = [], None, 0 while True: reply = tools_list(cursor) requests_made += 1 tools += reply["tools"] cursor = reply.get("nextCursor") if cursor is None: break print(len(tools), "tools in", requests_made, "requests") print(tools)
Treat the cursor as opaque: never read it or build one yourself, since a server may change its format at any time. A client that stops after the first page silently loses tools, and that's a hard bug to spot.
tools/list comes back with a nextCursor. What should the client do?Show the answer
Call tools/list again with that cursor, until a page has no nextCursor
Cursors are opaque bookmarks. Keep asking with the latest one until the server has no more pages.
A minimal client against the practice server
An HTTP transport and a small session layer, talking to the live practice server.
import requests URL = "https://api.gradient.test/mcp" NAMED = {"tools/call": "name", "prompts/get": "name", "resources/read": "uri"} def http_transport(msg): params = msg["params"] headers = {"Accept": "application/json, text/event-stream", "MCP-Protocol-Version": params["_meta"]["io.modelcontextprotocol/protocolVersion"], "Mcp-Method": msg["method"]} if msg["method"] in NAMED: headers["Mcp-Name"] = str(params[NAMED[msg["method"]]]) return requests.post(URL, json=msg, headers=headers, timeout=10).json() class Session: def __init__(self, transport): self.transport, self.next_id = transport, 0 def request(self, method, params=None): self.next_id += 1 meta = {"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}, "io.modelcontextprotocol/clientInfo": {"name": "lesson", "version": "1.0"}} reply = self.transport({"jsonrpc": "2.0", "id": self.next_id, "method": method, "params": {**(params or {}), "_meta": meta}}) if "error" in reply: raise RuntimeError(f"{reply['error']['code']}: {reply['error']['message']}") return reply["result"] s = Session(http_transport) info = s.request("server/discover") print(info["_meta"]["io.modelcontextprotocol/serverInfo"], info["supportedVersions"]) print([t["name"] for t in s.request("tools/list")["tools"]]) print(s.request("resources/read", {"uri": "docs://help/doc_care"})["contents"][0]["text"][:60])
The transport knows about HTTP headers; the session knows about ids and _meta. Swap http_transport for a function that calls a server object directly, and the same Session works in tests with no network. That's the design the exercise builds out.
ttlMs: 300000 and was fetched 2 minutes ago. Should it ask the server again?Show the answer
No, it's still fresh for about 3 more minutes
300,000 milliseconds is 5 minutes. After 2 minutes, the cached list is still within its freshness window.
Why a client needs bookkeeping
A client looks simple until two requests overlap. Your code sends request 12 and then request 13. The reply to 13 arrives first. If your code assumes the next line is the reply to its last request, it hands request 12's caller the wrong answer. It's a waiter giving table 12 the food for table 13. This raises no error, and it only happens under load.
So keep a table of what's waiting, by id:
pending = {12: "list tools", 13: "read a file"}
for reply in [{"id": 13, "result": "file text"}, {"id": 12, "result": "tools"}]:
asked = pending.pop(reply["id"])
print(asked, "->", reply["result"])
print("still waiting:", pending)
If a reply arrives with an id you're not waiting for, log it and drop it. Add a timeout for each entry as well, so that a reply that never comes doesn't leave a caller stuck forever.
Version errors: try once, then stop
When a server says it doesn't support your version, it tells you which ones it does. Pick one you both know and retry once. If nothing matches, stop and report it clearly. A loop that keeps retrying can run forever, and the error never changes.
Don't fetch what you already have
Calling tools/list before each model call adds a slow network request to every turn. Replies can carry a ttlMs saying how long they stay good, so cache them until then. That also keeps your tool list stable, which helps prompt caching, as you'll see in the next phase.
Be careful what you share, though. A result marked private belongs to one user. Sharing it across users in one cache would show one customer's data to another. Include the user in the cache key, or don't cache it.
Treat every result as untrusted
Check a parcel before you carry it into the house. A client passes tool results to a model, and those results come from outside your system. They may be huge, oddly shaped, or carefully written to mislead the model. So check the structure before you use it, cap the size, and don't follow instructions found inside a result. Your code is the last place that can catch this before the model reads it, so it's well worth the few extra lines of code it takes.
Practice what you just read
- 5 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Why split a client into a transport and a session layer?