Index Ai Academy
Phase 9 · Model Context Protocol · Clients and hosts

Build a client

Metadata on every request, version fallback, paging and caching.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. Two layers
  2. Reading results carefully
  3. Follow the cursor to the end
  4. A minimal client against the practice server
  5. Why a client needs bookkeeping
  6. 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)
It runs right here in your browser, with nothing to install.
Quick check
What does this print?
next_id = 0
ids = []
for _ in range(3):
    next_id += 1
    ids.append(next_id)
print(ids)
  1. [3]
  2. [0, 1, 2]
  3. [1, 1, 1]
  4. [1, 2, 3]
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.

Try it now

A client is mostly careful bookkeeping.

Quick check
A client receives a reply with an id it isn't waiting for. What's the right move?
  1. Crash, because the server is broken
  2. Ignore it (and log it): it doesn't answer any pending request
  3. Resend every pending request
  4. Treat it as the answer to the oldest pending request
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 no resultType at 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.

Worked example

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)
In the lesson you can change this and run it.

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.

Quick check
tools/list comes back with a nextCursor. What should the client do?
  1. Ask the user to scroll
  2. Guess the next page number
  3. Stop: the first page is all the tools
  4. Call tools/list again with that cursor, until a page has no nextCursor
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.

Worked example

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])
In the lesson you can change this and run it.

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.

Quick check
A client's cached tools list has ttlMs: 300000 and was fetched 2 minutes ago. Should it ask the server again?
  1. No, cached lists never expire
  2. Yes, because ttlMs is in seconds
  3. No, it's still fresh for about 3 more minutes
  4. Yes, always ask before each use
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

Create a free account

Try a quiz question

Why split a client into a transport and a session layer?

  1. The same MCP logic then works over stdio, HTTP or an in-memory test transport
  2. Because MCP requires two processes: one to hold the connection, and another to run the logic
  3. To make messages smaller, since the session layer can remove the fields the transport adds
  4. So the server can choose the transport, by sending the session layer a request to switch over

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson