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:

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:

  1. 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, and client.server_info.
  2. 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.
  3. If the server answers with any other error, it is treated as an older server, and the client falls back to the initialize handshake 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.
  • version and server came from the server/discover the client sent on entry.
  • 5 miles is 5×1609.344/1000=8.046725 \times 1609.344 / 1000 = 8.04672 km, returned as structured content.
  • The kilograms-to-meters call came back as a tool error, not an exception.
  • ttl_ms: 0 means 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 supported list 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/discover probe and the -32022 retry 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_error results as success. Symptom: your script prints an error message as if it were a converted value. Check result.is_error before reading structured_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 raises MCPError: Client did not declare the form elicitation capability.... Without the callback the client does not declare the elicitation capability, 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 the supported list 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 the subscriptions/listen stream for change notifications.
  • Persisting request_state across process restarts with client.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

Back to Building and maintaining MCP servers