Skip to content

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

client.py
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 with is the lifecycle. Entering it connects and negotiates; leaving it disconnects. There is no connect() / close() pair, and a Client cannot 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-level Server) 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 as stdio_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.name here is "Bookshop", server_info.version is whatever the server reports.
  • client.server_capabilities: what the server can do (tools, resources, prompts, completions, ...). A capability the server doesn't have is None.
  • client.protocol_version: the protocol version the two sides agreed on. Here it is "2026-07-28".
  • client.instructions: the server's instructions= string, or None if 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

client.py
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.

client.py
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.

client.py
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 plain str URI 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

client.py
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.

client.py
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)
  • ref says which prompt or template you're filling in: a PromptReference or a ResourceTemplateReference.
  • argument is {"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.

client.py
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 with is the whole lifecycle. Inside it, server_info, server_capabilities, protocol_version and instructions are already populated.
  • list_tools() gives you each tool's name, title, description and input_schema.
  • call_tool() returns content for the model, structured_content for your code, and is_error. A raising tool is a result, not an exception.
  • content is a union of block types; narrow with isinstance before reading.
  • list_resources / list_resource_templates / read_resource, list_prompts / get_prompt, and complete round out the verbs.
  • Every list_* takes cursor=; loop until next_cursor is None.

Next: the things a server can ask the client for, and how you answer, in Client callbacks.