Chapter 17: Custom Clients
The protocol layer of an MCP client takes an afternoon. What takes months is the product above it: which servers users can add, what an approval looks like, where tokens live, how errors read, and what survives an incident review. Every host in the last three chapters made those decisions for you. A custom client hands all of them back.
This chapter’s rule: build a client only when the interaction can’t be chat, and when you do, own approval, auth and trust as product features, because the SDK gives you none of them.
The running example is an internal support console. Support agents look up customer accounts, check order status and escalate problems, and the team wants an assistant inside the console that can reach three MCP servers: accounts (with lookup_account), orders (with get_order_status and issue_refund), and the book’s notes server, used here for case notes. The console already has a login, a role model and an audit log. The assistant has to fit them, not replace them.
Currency note. SDK tiers and protocol details in this chapter were checked against modelcontextprotocol.io on 2026-09-27, and the Python listings were run against
mcp2.2.0 on 2026-09-28. Both move as SDKs mature, so check the SDK page and the client matrix for current status.
When a Custom Client Actually Makes Sense
Before writing any client code, ask whether the experience already exists in a product that supports MCP. Claude, ChatGPT, VS Code, Cursor and a growing list of coding agents all act as MCP hosts, and each one already handles discovery, capability negotiation, approval, auth and error rendering. If the interaction you want is “chat with tools,” one of them is cheaper and better than anything you will build.
Build a custom client when the experience can’t be chat:
- A workflow of structured forms, tables or worklists, where the model assists and the UI drives.
- Approval and policy flows that must match internal compliance controls exactly: specific buttons, specific audit trails, specific escalation paths.
- Deep integration into an existing product, where tools should feel like native features rather than a chatbot sidebar.
- Pipelines with no human in the loop: batch processing, scheduled analysis, automated checks.
If what you have is data or actions to expose, you need a server, not a client. Sourcegraph is the model: it publishes an MCP server for code search and lets every existing host consume it. The support console qualifies on the second and third points. Refunds need a role check and an audit entry that match the finance team’s controls, and the assistant has to work inside a console agents already use all day.
The SDKs
The official SDKs are the starting point. At spec revision 2026-07-28 the project groups them into tiers: Tier 1 is TypeScript, Python, C#, Go and Rust; Tier 2 is Java and Ruby; Tier 3 is Swift, PHP and Kotlin. Tiers move, so check the SDK page for current assignments and what each tier commits to.
TypeScript and Python have the most mature client APIs and the most examples. C# is the natural choice for .NET shops, and Go suits backend services that act as clients programmatically. Swift and Kotlin are Tier 3, so a mobile client should expect to fill gaps itself.
Every SDK gives you the same core: a Client that manages the connection, transports for stdio and HTTP, and typed calls for tools/list, tools/call, resources/read, prompts/get and the rest. None of them gives you the UX, the policy engine, the approval flow or the trust model. Those are the rest of this chapter.
The Protocol Layer: Three Servers, Three Tokens
Here is the console’s connection code in Python (mcp 2.x). It connects to each server over Streamable HTTP with that server’s own token, lists the tools, and calls one:
import httpx2
from contextlib import AsyncExitStack
from mcp import Client
from mcp.client.streamable_http import streamable_http_client
SERVERS = {
"accounts": "https://accounts.internal.example.com/mcp",
"orders": "https://orders.internal.example.com/mcp",
"notes": "https://notes.example.com/mcp",
}
async def connect_all(stack: AsyncExitStack, agent_session) -> dict[str, Client]:
clients = {}
for name, url in SERVERS.items():
token = await exchange_for(agent_session, audience=url) # one token per server
http = httpx2.AsyncClient(headers={"Authorization": f"Bearer {token}"}, timeout=15.0)
await stack.enter_async_context(http)
transport = streamable_http_client(url, http_client=http)
clients[name] = await stack.enter_async_context(Client(transport))
return clients
async with AsyncExitStack() as stack:
clients = await connect_all(stack, agent_session)
for name, client in clients.items():
print(name, [t.name for t in (await client.list_tools()).tools])
result = await clients["orders"].call_tool("get_order_status", {"order_id": "A-1001"})
print(result.structured_content)
Three details in that listing matter more than the calls.
One token per server. exchange_for trades the agent’s console session for a short-lived token whose audience is that one server (RFC 8693 token exchange, Chapter 19). The console never forwards its own session token. Forwarding it would work, and it would also let any one of the three servers act as the agent against the other two. That is token passthrough, and Chapter 20 explains why it is forbidden.
A timeout on every request. The 15-second HTTP timeout is what keeps a hung server from freezing the agent’s screen.
Negotiation happens in Client(...). Entering the client tries server/discover on 2026-07-28 servers and falls back to the initialize handshake on older ones. What it learns there decides what the rest of the console may do.
The TypeScript SDK has the same shape (Client, a transport, connect, listTools, callTool), so the listing ports directly; the SDK page links both.
Capability Negotiation in Practice
Negotiation changed shape in the 2026-07-28 revision. Up to 2025-11-25, every connection began with initialize: the client sent its protocol version and capabilities, the server answered with its own, and the client confirmed with initialized. In 2026-07-28 there is no handshake and no session. Every request carries the protocol version and the client’s capabilities in its _meta, and servers implement server/discover, which returns their supported versions, capabilities and instructions. A server that doesn’t speak the requested version returns UnsupportedProtocolVersionError.
The capabilities object is not decorative. It is a promise about what the client will do when asked:
| Client capability | What it enables | Cost to implement |
|---|---|---|
elicitation | The server can ask the user for input through a form or URL | Medium: form UI |
io.modelcontextprotocol/tasks extension | The server can run long operations the client polls | Medium: polling and resume |
roots (deprecated 2026-07-28) | The server can ask for filesystem roots | Low |
sampling (deprecated 2026-07-28) | The server can ask the client’s LLM for a completion | High: an LLM integration |
Roots and sampling are deprecated; servers should take paths as parameters and call a model provider directly. In 2026-07-28 a server that needs input no longer sends its own request to the client. It returns an InputRequiredResult, and the client retries the original call with inputResponses, the multi round-trip (MRTR) pattern. A client that declares elicitation has to implement that retry.
Start with none of these and add each when a server you depend on needs it. The support console declares none: every question to the agent goes through the console’s own UI, and a server that needs more has to say so in a tool error. Declaring a capability you haven’t built is the one mistake here that fails at runtime, in production, on someone else’s release day.
Version mismatches. If a server supports only an older revision, work at that revision’s feature set. If you share no version at all, disconnect and say which versions each side offered. Don’t guess at a common subset; the failures that follow are hard to trace back to the version.
The Real Responsibilities of a Client
The SDK gives you the protocol layer. A production client also owns seven things.
Server configuration. How servers are added, validated and removed. In the console, an administrator maintains the server list; agents can’t add servers, which is the simplest trust model there is.
Capability presentation. A flat list works for five tools and fails at fifty. The Part V opener’s threshold applies: past ~20 tools across all servers, measure selection; past ~40, split or defer. Tool lists also change while the client is running. On 2026-07-28 servers, subscriptions/listen delivers change events, and the Python SDK exposes it directly:
async with client.listen(tools_list_changed=True) as sub:
async for event in sub:
catalog[name] = (await client.list_tools()).tools # refetch, then re-apply policy
There is no replay. If the stream drops, listen again and refetch, because an event may have been missed in between. Every refetch has to go back through the approval policy below, since a server that adds a tool has just added something the agent may be asked to approve.
Approval. The central design surface, and the subject of the next section.
Authentication and token storage. Remote servers need OAuth (authorization code with PKCE for user-facing clients) or a token exchange like exchange_for. Tokens need secure storage, refresh and recovery when they expire mid-session. The spec requires protected-resource metadata discovery (RFC 9728), authorization-server metadata discovery (RFC 8414 or OpenID Connect Discovery), and since 2026-07-28, validation of the iss parameter in authorization responses (RFC 9207). Chapter 19 walks through the flow.
Error rendering. A failed tool call comes back with isError: true and the server’s message. Show the tool name and that message in the conversation, where the agent can act on it. A raw JSON dump is not a user experience, and neither is swallowing the error.
Traceability. Server-side logging is deprecated in 2026-07-28 in favour of stderr for stdio servers and OpenTelemetry for observability. Either way, the client must be able to reconstruct, for any call, which server, which tool, which arguments, which approval and which result. The console writes that record to the audit log it already has.
Untrusted content. Tool results come from servers that may be buggy, compromised or relaying prompt injection from upstream data. Never render server output as executable code or HTML, never splice it into the system prompt, and never let it draw anything that looks like your own UI.
Approval Policy Is Code You Own
A general-purpose host has to trust annotations from any server the user adds. A custom client knows its servers and its users, so it can do better: take annotations into account, but decide with facts the server can’t forge. Here is the console’s policy:
from dataclasses import dataclass
from typing import Literal
Decision = Literal["allow", "confirm", "deny"]
TRUSTED_SERVERS = {"accounts", "orders", "notes"} # annotations honoured only from these
ALWAYS_CONFIRM = {("orders", "issue_refund")} # regardless of what the server says
ROLE_REQUIRED = {("orders", "issue_refund"): "refunds"} # the console's own role model
@dataclass
class Call:
server: str
tool: str
arguments: dict
read_only: bool | None # annotations.read_only_hint as listed
destructive: bool | None # annotations.destructive_hint as listed
def decide(call: Call, agent_roles: set[str]) -> Decision:
key = (call.server, call.tool)
if call.server not in TRUSTED_SERVERS:
return "deny"
if (role := ROLE_REQUIRED.get(key)) and role not in agent_roles:
return "deny"
if key in ALWAYS_CONFIRM:
return "confirm"
if call.read_only is True:
return "allow"
return "confirm" # writes, and tools with no hints at all
Three choices in it are deliberate:
- Unknown means confirm. Only a tool that declares
readOnlyHint: trueruns without a click. A server that forgot its annotations gets more prompts, not fewer, which matches the spec’s defaults (a tool is assumed to write, and a write is assumed destructive). The policy keepsdestructiveso the dialog can show it, not to relax anything. - Some tools are confirmed by name.
issue_refundneeds a click even if theordersteam one day marks it non-destructive. The console owns that decision because finance owns the control. - Roles come from the console, not the server. An agent without the
refundsrole never sees the confirm dialog for a refund; the call is denied before it is shown.
The dialog for confirm shows the server, the tool’s title, the arguments in readable form, and the annotation hints, with “allow once” as the default. Every decision, including “allow,” goes to the audit log with the call it approved. The orders server still checks the agent’s role on its side (Chapter 18), because the policy above is a client and clients can be bypassed.
Mobile and Constrained Environments
Mobile stress-tests MCP’s assumptions. The protocol was born in desktop tools with reliable processes, fast networks and generous compute. Mobile changes all three.
Transport. Stdio needs a local subprocess, which iOS doesn’t offer and Android tightly restricts. On mobile, remote servers over Streamable HTTP are effectively the only option, and every interaction crosses the network.
Network variability. Connections drop and switch between Wi-Fi and cellular. The client needs retries with exponential backoff, timeouts short enough not to hang the UI, and a degraded mode that shows the cached catalog and says what is unavailable rather than spinning.
Battery and radio. Every request wakes the radio, so polling loops are expensive. Prefer push where the protocol offers it: in 2026-07-28, subscriptions/listen replaced the old HTTP GET stream and resources/subscribe for change notifications.
Approval on small screens. Show the tool title and a one-line summary; put arguments behind an expandable section; never auto-approve a destructive call on mobile, even where the desktop client does.
Background execution. Mobile apps are suspended when backgrounded, and a thirty-second call dies with them. Keep calls short, use Tasks where the server supports the extension so the client can poll after resuming, and notify the user when background work completes.
SDK maturity. With Swift and Kotlin at Tier 3, consider writing the transport yourself. Streamable HTTP is JSON-RPC over HTTP POST with optional SSE responses, and since 2026-07-28 there is no session to manage.
Failure Modes
A server starts needing a capability you didn’t declare. A server update makes a tool ask the user for input via elicitation; your client never declared elicitation. A correct server fails the call with an error saying elicitation is required. A careless one returns an InputRequiredResult your client can’t satisfy, and the call fails without explanation. Fix: log what each server’s discovery result offers and what your client declared, alert when a server’s tools start failing on input requirements, and treat “declare elicitation” as a feature to build, not a flag to flip.
Transport fallback confusion. The client tries Streamable HTTP, gets a 404, falls back to the deprecated HTTP+SSE transport, and connects to an endpoint with different semantics. Fix: if you implement fallback, test both paths, and log which one each connection used.
Stale tool lists. A server adds a tool and your client never listened for the change, so agents see an outdated catalog, or a removed tool keeps being offered. Fix: listen for list changes on servers that support them, refetch on reconnect for those that don’t, and re-run the approval policy on every refetch.
Token expiry during long sessions. Two hours in, a token expires and the next call returns 401; the console shows “server error.” Fix: re-exchange the token for that server and retry once, at the HTTP layer. The SDK turns an HTTP 401 into a generic JSON-RPC error before your code sees it, so the place to catch it is an httpx2.Auth flow, passed as httpx2.AsyncClient(auth=...) in place of the fixed header in connect_all:
class ExchangedToken(httpx2.Auth):
"""Bearer auth that re-exchanges once on a 401, then gives up."""
def __init__(self, agent_session, audience: str):
self.agent_session, self.audience, self.token = agent_session, audience, None
async def async_auth_flow(self, request):
if self.token is None:
self.token = await exchange_for(self.agent_session, audience=self.audience)
request.headers["Authorization"] = f"Bearer {self.token}"
response = yield request
if response.status_code == 401: # expired or revoked
self.token = await exchange_for(self.agent_session, audience=self.audience)
request.headers["Authorization"] = f"Bearer {self.token}"
yield request # a second 401 surfaces as an error
Retry once per request, not in a loop. A token the server keeps rejecting is an auth misconfiguration, and a loop turns it into a flood of token exchanges. When the error does surface, ask the agent to sign in to the console again.
Tool-name collisions across servers. Two servers both expose search, and the model’s call is routed to the wrong one. Fix: present tools to the model with a server prefix (orders.search, notes.search) and route on the prefix, never on the bare name.
Mobile suspension mid-call. The OS suspends the app, the connection drops, and the server finishes work nobody receives. Fix: use Tasks for anything longer than a few seconds and poll for status on resume.
Rendering untrusted content as UI. A compromised server returns Markdown or HTML that draws a fake approval button. Fix: sanitize everything server-provided and render no interactive elements from server output.
Implementation Checklist
- The use case genuinely cannot be served by an existing host
- Every connection has a timeout, and every server gets its own audience-bound token
- The client declares only capabilities it has implemented (MRTR for
elicitation), and logs them beside each server’s discovery result - Shared-version failures disconnect with a message naming both sides’ versions
- Approval policy is code: trusted servers, by-name overrides, the client’s own roles, missing hints treated as destructive
- Every approval decision is written to the audit log with the call
- Errors render in context with the tool name and the server’s message
- When a server is unreachable, the rest of the client works and says what is missing
What Comes Next
A custom client makes every trust decision explicit: which servers it talks to, which token each one gets, which calls a person must approve. Part VI starts from the other side of the connection. Chapter 18 asks what kind of principal a server should recognise and which authentication pattern fits its trust boundary, which is the question exchange_for quietly answered for the support console.