technique
Building a client
The other side of the wire in Python: connecting to a server, discovering what it offers, calling tools, handling input_required, and caching list results.
Before this
This page assumes you are comfortable with:
- techniqueBuilding a server in PythonA complete MCP server with the official Python SDK: registering tools, resources, and prompts on MCPServer, schemas from type hints, and running it over stdio.
- techniqueTransports: stdio and Streamable HTTPThe two ways messages travel: a local child process over stdin and stdout, or a remote endpoint over HTTP POST with optional streamed responses, plus long-lived subscriptions.
- techniqueMulti round-trip requestsHow a stateless server asks for more input mid-call: returning input_required, the client gathering answers through elicitation, and the retry that completes the request.
Why you need this
A server does nothing until a client talks to it. Every host, whether a chat app, a code editor, or a script you write yourself, holds one client per server and uses it to find out what the server offers and to call it. Writing a client without a model in the loop is also the cheapest way to check a server: the same calls in the same order, every time, for free. This is stage 3 of a server's life, "Build and connect".
The idea
A client is the host's connection to one server. The host is the app a person uses; the model is the language model inside it. This page leaves the model out, so the client's caller is a plain Python script.
In the official Python SDK (version 2; every sample here ran on 2.2.0) the client is one class, Client, imported with from mcp import Client. You hand it a description of the server and use it inside async with:
You pass Client(...) |
It connects by |
|---|---|
StdioServerParameters(command=..., args=[...]) |
Launching the server as a child process and speaking newline-delimited JSON over its stdin and stdout |
| A URL string ending in the server's MCP endpoint | Streamable HTTP: one POST per request |
An MCPServer object |
Calling it in the same process, with no transport (handy in tests) |
Entering the async with block does the connecting. Leaving it shuts the connection down and, for stdio, ends the child process.
What happens on entry: server/discover
In the 2026-07-28 revision there is no handshake and no session. Every request carries the protocol version and the client's capabilities in _meta, and server/discover is the one request every server must answer. It returns the versions the server supports, its capabilities, and its name.
By default (mode="auto") the SDK's Client sends server/discover as soon as you enter the block, proposing the newest version it knows. Then:
- If the server answers, the client picks the newest version both sides list and stamps it on every later request. You read the outcome from
client.protocol_version,client.server_capabilities, andclient.server_info. - If the server answers with error
-32022(UnsupportedProtocolVersionError), its error data lists the versions it does support. The client retries once at the newest shared one. - If the server answers with any other error, it is treated as an older server, and the client falls back to the
initializehandshake those servers expect. Older servers (2025-11-25 and earlier) required that handshake before any other request.
You can also pin a version with mode="2026-07-28", which skips the probe.
The calls
Method on Client |
Wire method | Returns |
|---|---|---|
list_tools() |
tools/list |
.tools, each with .name, .description, .input_schema, .output_schema |
call_tool(name, arguments) |
tools/call |
.content, .structured_content, .is_error |
read_resource(uri) |
resources/read |
.contents, .ttl_ms, .cache_scope |
get_prompt(name, arguments) |
prompts/get |
.messages |
list_resources(), list_prompts() |
resources/list, prompts/list |
lists like list_tools() |
Attribute names are snake_case in Python (structured_content, ttl_ms) and camelCase on the wire (structuredContent, ttlMs).
A failed tool call does not raise. It returns a result with is_error set to True and the server's message in content, so your code (or later the model) can read it. Protocol errors, such as an unsupported version, do raise, as MCPError with .code and .error.data.
Caching and ttlMs
tools/list, the other list methods, server/discover, and resources/read carry two hints: ttlMs, how many milliseconds the result stays fresh, and cacheScope, "public" (safe to share between users) or "private" (only for this caller's authorization). The Caching section of the specification says a client should treat the result as fresh while now < t_received + ttlMs, treat 0 as "stale at once", and drop a cached list as soon as a list-changed notification arrives. The SDK's Client does this for you with an in-memory cache. Pass cache_mode="bypass" to a list call to force a fresh request, or cache=None when constructing the client to turn caching off.
input_required and the retry
A server that needs more information mid-call, such as a confirmation, does not send its own request. It returns a result whose resultType is "input_required", holding inputRequests (questions keyed by name) and usually requestState (an opaque string). The client collects answers, then sends the original request again, with a new JSON-RPC id, plus inputResponses and the exact requestState. The multi round-trip requests page has the wire details.
The SDK runs that loop inside call_tool, get_prompt, and read_resource: it hands each elicitation question to the elicitation_callback you gave Client, retries, and returns only the final result. It gives up after 10 rounds (input_required_max_rounds). To see the intermediate result yourself, call the lower-level client.session.call_tool(..., allow_input_required=True).
Worked example
This script drives the unit-converter server from Building a server in Python, saved as unit_converter.py in the same folder. It discovers, lists, calls the tool twice, reads the resource, gets the prompt, and finally asks for a protocol version that does not exist.
"""A scripted MCP client that drives unit_converter.py over stdio. No model involved."""
import asyncio
import sys
from pathlib import Path
from mcp import Client, StdioServerParameters
from mcp.shared.exceptions import MCPError
SERVER = Path(__file__).with_name("unit_converter.py")
async def main() -> None:
params = StdioServerParameters(command=sys.executable, args=[str(SERVER)])
async with Client(params) as client:
# 1. What did server/discover tell us? (Client ran it on entry.)
print("version:", client.protocol_version)
print("server:", client.server_info.name)
caps = client.server_capabilities
print("offers tools/resources/prompts:",
caps.tools is not None, caps.resources is not None, caps.prompts is not None)
# 2. List tools.
listing = await client.list_tools()
for tool in listing.tools:
print("tool:", tool.name, "| args:", list(tool.input_schema["properties"]),
"| ttl_ms:", listing.ttl_ms)
# 3. Call a tool that succeeds, then one that fails.
ok = await client.call_tool("convert", {"value": 5, "from_unit": "mi", "to_unit": "km"})
print("5 mi -> km:", ok.structured_content["result"], "| is_error:", ok.is_error)
bad = await client.call_tool("convert", {"value": 1, "from_unit": "kg", "to_unit": "m"})
print("1 kg -> m:", bad.content[0].text, "| is_error:", bad.is_error)
# 4. Read a resource and look at its caching hints.
res = await client.read_resource("units://all")
print("units://all ttl_ms:", res.ttl_ms, "cache_scope:", res.cache_scope)
print(res.contents[0].text)
# 5. Get a prompt: the server fills in the template, nothing runs.
prompt = await client.get_prompt("recipe_to_metric", {"recipe": "2 lb flour"})
print("prompt role:", prompt.messages[0].role)
print(prompt.messages[0].content.text)
# 6. Ask for a protocol version the server does not speak.
try:
await client.session.send_discover("2099-01-01")
except MCPError as e:
print("version error:", e.code, e.error.message, e.error.data)
asyncio.run(main())
Run it with python drive_converter.py. Output:
Tool 'convert' failed: 'Error executing tool convert: cannot convert mass (kg) to length (m)'
version: 2026-07-28
server: unit-converter
offers tools/resources/prompts: True True True
tool: convert | args: ['value', 'from_unit', 'to_unit'] | ttl_ms: 0
5 mi -> km: 8.04672 | is_error: False
1 kg -> m: Error executing tool convert: cannot convert mass (kg) to length (m) | is_error: True
units://all ttl_ms: 0 cache_scope: private
length: mm, cm, m, km, in, ft, mi
mass: g, kg, oz, lb
prompt role: user
Rewrite every quantity in this recipe in metric units, using the convert tool:
2 lb flour
version error: -32022 Unsupported protocol version {'supported': ['2026-07-28'], 'requested': '2099-01-01'}
Line by line:
- The first line is the server's log, written to its stderr, which the child process shares with your terminal. Where it lands among the script's own lines depends on output buffering; here the script's stdout was captured to a file, so the server's line arrived first.
versionandservercame from theserver/discoverthe client sent on entry.- 5 miles is km, returned as structured content.
- The kilograms-to-meters call came back as a tool error, not an exception.
ttl_ms: 0means the client may not reuse that answer; a server whose data rarely changes would set a larger number.- The last line is the error a version mismatch produces. Its
supportedlist is what a client uses to retry.
Over HTTP
The same server, started with server.run("streamable-http", host="127.0.0.1", port=8000), is reached by URL:
import asyncio
from mcp import Client
async def main() -> None:
async with Client("http://127.0.0.1:8000/mcp") as client:
print("version:", client.protocol_version)
ok = await client.call_tool("convert", {"value": 12, "from_unit": "in", "to_unit": "cm"})
print("12 in -> cm:", ok.structured_content["result"])
asyncio.run(main())
version: 2026-07-28
12 in -> cm: 30.48
Nothing else in the client changed. That is the point of a transport: the methods are the same.
Handling input_required
The unit converter never asks for input, so this second, self-contained illustration uses a tiny server whose delete_file tool needs a yes first. The server side uses the SDK's Resolve and Elicit markers; the client side is what matters here.
"""input_required from the client's side: a tool that needs a yes before it runs."""
import asyncio
from typing import Annotated
from mcp import Client
from mcp.server import MCPServer
from mcp.server.mcpserver.resolve import Elicit, Resolve
from mcp_types import ElicitResult
from pydantic import BaseModel
server = MCPServer("confirm-demo")
class Confirm(BaseModel):
ok: bool
def ask(path: str) -> Elicit[Confirm]:
return Elicit(f"Delete {path}?", Confirm)
@server.tool()
def delete_file(path: str, answer: Annotated[Confirm, Resolve(ask)]) -> str:
"""Delete a file after the person confirms."""
return f"deleted {path}" if answer.ok else f"kept {path}"
async def say_yes(context, params) -> ElicitResult:
print("asked:", params.message, "| schema:", params.requested_schema["properties"])
return ElicitResult(action="accept", content={"ok": True})
async def main() -> None:
async with Client(server, elicitation_callback=say_yes) as client:
# By hand: see the input_required result, then retry with the answer.
first = await client.session.call_tool(
"delete_file", {"path": "notes.txt"}, allow_input_required=True)
print("result_type:", first.result_type, "| keys:", list(first.input_requests))
key = next(iter(first.input_requests))
final = await client.session.call_tool(
"delete_file", {"path": "notes.txt"},
input_responses={key: ElicitResult(action="accept", content={"ok": True})},
request_state=first.request_state, allow_input_required=True)
print("retry result_type:", final.result_type, "|", final.content[0].text)
# The easy way: Client.call_tool runs the same loop through say_yes.
auto = await client.call_tool("delete_file", {"path": "draft.txt"})
print("auto:", auto.content[0].text)
asyncio.run(main())
result_type: input_required | keys: ['__main__:ask']
retry result_type: complete | deleted notes.txt
asked: Delete draft.txt? | schema: {'ok': {'title': 'Ok', 'type': 'boolean'}}
auto: deleted draft.txt
A real host would show the question to the person in say_yes instead of answering yes itself.
In a server's life
- Build and connect (stage 3): this client is the piece a host wraps around a model; The host loop adds the model.
- Speak the protocol (stage 1): the
server/discoverprobe and the-32022retry are version negotiation seen from the client. - Test and ship (stage 5): the same calls inside pytest are the protocol-level tests on Testing MCP servers.
Common mistakes
- Treating
is_errorresults as success. Symptom: your script prints an error message as if it were a converted value. Checkresult.is_errorbefore readingstructured_content, which is absent on errors. - Caching past
ttlMs, or ignoring list-changed. Symptom: the host offers a tool the server removed, and every call to it fails. Re-fetch when the hint expires or a notification arrives. - Reusing or editing
requestState. Symptom: the server rejects the retry or asks again. Echo it byte for byte, only on the retry of the request that produced it. - No
elicitation_callback. Symptom: a tool that asks a question raisesMCPError: Client did not declare the form elicitation capability.... Without the callback the client does not declare theelicitationcapability, and the server refuses rather than ask. Provide the callback, and let it decline when the person says no. - Swallowing
-32022. Symptom: "cannot connect" with no detail. Read thesupportedlist in the error data and report it.
Cost
A stdio client costs one child process per server plus one round trip per call, usually a few milliseconds on one machine. Over HTTP, each call is one POST, so latency is the network round trip plus the tool's own work. server/discover adds one round trip per connection, and caching list results within ttlMs saves one per later list. Engineering time is small because the SDK owns negotiation, caching, and the input_required loop; most of your code is deciding what to do with results.
Going further
client.listen(tools_list_changed=True)and thesubscriptions/listenstream for change notifications.- Persisting
request_stateacross process restarts withclient.session.call_tool(..., allow_input_required=True). - Connecting to a server that requires OAuth; see Authorization for remote servers.
- The MCP Inspector, which is a ready-made client with the raw messages visible.
Leads to
- techniqueTesting MCP serversHow to know a server works before a model touches it: unit tests for handlers, an in-process client for protocol tests, schema contract tests, and an interactive inspector.
- techniqueThe host loopHow a host turns MCP tools into a working assistant: translating tool definitions for a model, running the call-and-answer loop, and keeping a person in control.