The Client
A Client is how a Python program talks to an MCP server.
It is one object with one lifecycle: construct it, enter async with, call methods. Every protocol verb (list the tools, call one, read a resource, render a prompt) is an async method on it that returns a typed result.
Your first client
from mcp import Client
from mcp.server import MCPServer
mcp = MCPServer("Bookshop", instructions="Search the catalog before recommending a book.")
@mcp.tool()
def search_books(query: str) -> str:
"""Search the catalog by title or author."""
return f"Found 3 books matching {query!r}."
async def main() -> None:
async with Client(mcp) as client:
print(client.server_info)
print(client.server_capabilities)
print(client.protocol_version)
print(client.instructions)
The server at the top is only there so you have something to connect to. The client is the five highlighted lines.
Client(mcp)is given the server object itself. That is the in-memory transport: no subprocess, no port, no HTTP. It is how every example in this chapter, and every test you write, connects.async withis the lifecycle. Entering it connects and negotiates; leaving it disconnects. There is noconnect()/close()pair, and aClientcannot be reused after the block ends.- Inside the block the connection facts are already there as plain properties.
What you can pass to Client
Client takes one positional argument and resolves the transport from its type:
- An
MCPServer(or low-levelServer) instance: connected in-process. - A URL string (
Client("http://localhost:8000/mcp")): Streamable HTTP, the production path. - A transport: anything you can
async with ... as (read, write), such asstdio_client(...)wrapping a subprocess.
Everything else on this page is identical across all three. Headers, subprocesses, timeouts, and the Transport protocol get their own chapter: Client transports.
What's on a connected client
Four read-only properties, populated the moment you enter the block:
client.server_info: the server's identity.server_info.namehere is"Bookshop",server_info.versionis whatever the server reports.client.server_capabilities: what the server can do (tools,resources,prompts,completions, ...). A capability the server doesn't have isNone.client.protocol_version: the protocol version the two sides agreed on. Here it is"2026-07-28".client.instructions: the server'sinstructions=string, orNoneif it didn't set one.
You never picked a protocol version. By default the Client probes the server and falls back to the classic handshake on older ones, so one client works against any era of server. When you need to control that, Protocol versions has the whole story.
Tip
client.session is the underlying ClientSession, the low-level escape hatch.
You won't need it for anything on this page.
Listing tools
from mcp import Client
from mcp.server import MCPServer
mcp = MCPServer("Bookshop")
@mcp.tool(title="Search the catalog")
def search_books(query: str, limit: int = 10) -> str:
"""Search the catalog by title or author."""
return f"Found 3 books matching {query!r} (showing up to {limit})."
async def main() -> None:
async with Client(mcp) as client:
result = await client.list_tools()
for tool in result.tools:
print(tool.name)
print(tool.title)
print(tool.description)
print(tool.input_schema)
list_tools() returns a ListToolsResult; the tools are in .tools. Each one is the complete definition a host would hand to a model:
tool.name # 'search_books'
tool.title # 'Search the catalog'
tool.description # 'Search the catalog by title or author.'
and tool.input_schema is the JSON Schema the server derived from the function's type hints:
{
"type": "object",
"properties": {
"query": {"title": "Query", "type": "string"},
"limit": {"default": 10, "title": "Limit", "type": "integer"}
},
"required": ["query"],
"title": "search_booksArguments"
}
That schema is everything a UI needs to render an argument form, and everything a model needs to produce valid arguments.
Tip
title is optional, so a UI showing tools to a human has to pick: the title if there is one,
the name if not. from mcp.shared.metadata_utils import get_display_name does exactly that,
for tools, resources, resource templates and prompts.
Calling a tool
call_tool(name, arguments) runs the tool and gives you back a CallToolResult.
from mcp_types import TextContent
from pydantic import BaseModel
from mcp import Client
from mcp.server import MCPServer
mcp = MCPServer("Bookshop")
class Book(BaseModel):
title: str
author: str
year: int
@mcp.tool()
def lookup_book(title: str) -> Book:
"""Look up a book by its exact title."""
if title != "Dune":
raise ValueError(f"No book titled {title!r} in the catalog.")
return Book(title="Dune", author="Frank Herbert", year=1965)
async def main() -> None:
async with Client(mcp) as client:
result = await client.call_tool("lookup_book", {"title": "Dune"})
for block in result.content:
if isinstance(block, TextContent):
print(block.text)
print(result.structured_content)
print(result.is_error)
The server's lookup_book returns a Pydantic Book. Here is what the client sees:
result.content # [TextContent(type='text', text='{\n "title": "Dune",\n "author": "Frank Herbert",\n "year": 1965\n}')]
result.structured_content # {'title': 'Dune', 'author': 'Frank Herbert', 'year': 1965}
result.is_error # False
One return value, three things to read. Each has a different consumer.
content: what the model reads
content is a list of content blocks, and a content block is a union: TextContent, ImageContent, AudioContent, ResourceLink, or EmbeddedResource. A tool can return several, of different kinds.
That is why main narrows with isinstance(block, TextContent) before touching block.text. Notice there is no .text outside the isinstance: the type checker won't allow it, because ImageContent has .data, not .text. The union is honest about what a tool is allowed to send you; your code should be too.
structured_content: what your application reads
structured_content is the tool's return value as JSON, matching the tool's declared output_schema. No string parsing, no guessing.
When both are present they say the same thing twice on purpose: content is for a model, structured_content is for code. Where the structured half comes from, and how to control it, is the Structured Output chapter.
is_error: whether the tool failed
A tool that raises does not raise in your client. It comes back as an ordinary result with is_error=True.
Check
Ask lookup_book for "Solaris" (a title that isn't in the catalog) and the function raises
ValueError. The call still returns normally:
result.is_error # True
result.content # [TextContent(type='text', text="Error executing tool lookup_book: No book titled 'Solaris' in the catalog.")]
result.structured_content # None
The exception's message landed in content, where the model can read it and try again. That
is deliberate: a tool error is part of the conversation, not a crash. Always look at is_error
before you trust structured_content.
Warning
is_error=True covers more than your own raise. Ask for a tool the server doesn't even have
(call_tool("does_not_exist", {})) and nothing raises. You get the same shape back,
is_error=True with Unknown tool: does_not_exist in content. A Client method raises
MCPError only when the server answers with a JSON-RPC error instead of a result, and
Handling errors covers when a server produces which.
Resources
The resource verbs come in pairs: two ways to list, one way to read.
from mcp_types import TextResourceContents
from mcp import Client
from mcp.server import MCPServer
mcp = MCPServer("Bookshop")
@mcp.resource("catalog://genres")
def genres() -> list[str]:
"""The genres the catalog is organised by."""
return ["fiction", "non-fiction", "poetry"]
@mcp.resource("catalog://genres/{genre}")
def books_in_genre(genre: str) -> str:
"""Every title we stock in one genre."""
return f"3 books filed under {genre}."
async def main() -> None:
async with Client(mcp) as client:
listed = await client.list_resources()
print([resource.uri for resource in listed.resources])
templates = await client.list_resource_templates()
print([template.uri_template for template in templates.resource_templates])
result = await client.read_resource("catalog://genres/poetry")
for contents in result.contents:
if isinstance(contents, TextResourceContents):
print(contents.text)
list_resources()returns the concrete resources, the ones with a fixed URI. Here:['catalog://genres'].list_resource_templates()returns the parameterised ones. Here:['catalog://genres/{genre}']. They are two different lists because a template isn't readable until you fill it in.read_resource(uri)takes a plainstrURI and works on both: pass"catalog://genres/poetry"and the server matches it to the template.
read_resource returns contents, a list of TextResourceContents or BlobResourceContents. Same idea as tool content: narrow with isinstance, then read .text (or .blob).
A client can also subscribe to a resource and be told when it changes: subscribe_resource(uri) and unsubscribe_resource(uri), same shape as everything else here. MCPServer doesn't implement that half. It says so up front (server_capabilities.resources.subscribe is False) and answers the request with an MCPError: -32601, Method not found. A server that does support subscriptions is built on the low-level Server (The low-level Server).
Prompts
from mcp import Client
from mcp.server import MCPServer
mcp = MCPServer("Bookshop")
@mcp.prompt(title="Recommend a book")
def recommend(genre: str) -> str:
"""Ask for a recommendation in a genre."""
return f"Recommend one {genre} book from the catalog and say why."
async def main() -> None:
async with Client(mcp) as client:
listed = await client.list_prompts()
print(listed.prompts)
result = await client.get_prompt("recommend", {"genre": "poetry"})
for message in result.messages:
print(message.role, message.content)
list_prompts() tells you what the server offers and what each prompt needs:
prompt.name # 'recommend'
prompt.title # 'Recommend a book'
prompt.arguments # [PromptArgument(name='genre', required=True)]
get_prompt(name, arguments) renders it. The arguments dict is str -> str: prompt arguments are always strings. The result is messages, a list of PromptMessage, each with a role and a content block:
message.role # 'user'
message.content # TextContent(type='text', text='Recommend one poetry book from the catalog and say why.')
A host hands those messages straight to the model. That is the whole feature.
Completions
A server with a completion handler can autocomplete prompt and resource-template arguments as the user types.
from mcp_types import Completion, CompletionArgument, CompletionContext, PromptReference, ResourceTemplateReference
from mcp import Client
from mcp.server import MCPServer
mcp = MCPServer("Bookshop")
GENRES = ["fiction", "non-fiction", "poetry"]
@mcp.prompt()
def recommend(genre: str) -> str:
"""Ask for a recommendation in a genre."""
return f"Recommend one {genre} book from the catalog and say why."
@mcp.completion()
async def complete_genre(
ref: PromptReference | ResourceTemplateReference,
argument: CompletionArgument,
context: CompletionContext | None,
) -> Completion | None:
return Completion(values=[genre for genre in GENRES if genre.startswith(argument.value)])
async def main() -> None:
async with Client(mcp) as client:
result = await client.complete(
ref=PromptReference(type="ref/prompt", name="recommend"),
argument={"name": "genre", "value": "p"},
)
print(result.completion.values)
refsays which prompt or template you're filling in: aPromptReferenceor aResourceTemplateReference.argumentis{"name": ..., "value": ...}: the argument and what the user has typed so far.
The answer is in result.completion.values. Type "p" and the server comes back with ['poetry']. The server side, and how a handler uses the other already-filled arguments to narrow its suggestions, is the Completions chapter.
Pagination
Every list_* method takes a cursor= keyword and every result carries a next_cursor. When next_cursor is None, you have everything.
from mcp_types import Tool
from mcp import Client
from mcp.server import MCPServer
mcp = MCPServer("Bookshop")
@mcp.tool()
def search_books(query: str) -> str:
"""Search the catalog by title or author."""
return f"Found 3 books matching {query!r}."
@mcp.tool()
def reserve_book(title: str) -> str:
"""Put a book on hold."""
return f"Reserved {title!r}."
async def main() -> None:
async with Client(mcp) as client:
tools: list[Tool] = []
cursor: str | None = None
while True:
page = await client.list_tools(cursor=cursor)
tools.extend(page.tools)
if page.next_cursor is None:
break
cursor = page.next_cursor
print([tool.name for tool in tools])
This loop is correct against every server. MCPServer returns everything in one page, so next_cursor is None and the loop runs once, which is why most code never writes it. Servers that genuinely page, and the rules cursors obey, are in Pagination.
In tests
Client(mcp) with no process and no port is already a test harness for your server.
There is one constructor flag built for that: Client(mcp, raise_exceptions=True). It only has an effect on in-memory connections, and Testing is the chapter that explains it and builds the whole pattern around it.
Recap
Client(x)connects in-memory to a server object, over Streamable HTTP to a URL string, and over anything else via a transport.async withis the whole lifecycle. Inside it,server_info,server_capabilities,protocol_versionandinstructionsare already populated.list_tools()gives you each tool'sname,title,descriptionandinput_schema.call_tool()returnscontentfor the model,structured_contentfor your code, andis_error. A raising tool is a result, not an exception.contentis a union of block types; narrow withisinstancebefore reading.list_resources/list_resource_templates/read_resource,list_prompts/get_prompt, andcompleteround out the verbs.- Every
list_*takescursor=; loop untilnext_cursorisNone.
Next: the things a server can ask the client for, and how you answer, in Client callbacks.