# 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

A client needs a server to talk to. This Bookshop is the one every snippet on this page connects to. Save it as `server.py` and leave it running over HTTP:

```python title="server.py"
# docs_src/client/tutorial001.py
from pydantic import BaseModel

from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import Completion, CompletionArgument, CompletionContext, PromptReference, ResourceTemplateReference

mcp = MCPServer("Bookshop", instructions="Search the catalog before recommending a book.")

GENRES = ["fiction", "non-fiction", "poetry"]


class Book(BaseModel):
    title: str
    author: str
    year: int


@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})."


@mcp.tool()
def lookup_book(title: str) -> Book:
    """Look up a book by its exact title."""
    if title != "Dune":
        raise ToolError(f"No book titled {title!r} in the catalog.")
    return Book(title="Dune", author="Frank Herbert", year=1965)


@mcp.resource("catalog://genres")
def genres() -> list[str]:
    """The genres the catalog is organised by."""
    return GENRES


@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}."


@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."


@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)])
```

```console
uv run mcp run server.py --transport streamable-http
```

That serves it at `http://localhost:8000/mcp`. The client is its own program. Save it as `client.py` and run `python client.py` in a second terminal:

```python title="client.py" hl_lines="7-11"
# docs_src/client/tutorial001_client.py
import anyio

from mcp import Client


async def main() -> None:
    async with Client("http://localhost:8000/mcp") as client:
        print(client.server_info)
        print(client.server_capabilities)
        print(client.protocol_version)
        print(client.instructions)


if __name__ == "__main__":
    anyio.run(main)
```

* `Client("http://localhost:8000/mcp")` is given a **URL**, so it connects over Streamable HTTP to the server you just started.
* `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:

* A URL string (`Client("http://localhost:8000/mcp")`): Streamable HTTP, the transport you deploy behind.
* A `StdioServerParameters`: the command to launch as a local **subprocess**, spoken to over its stdin and stdout.
* A **transport**: anything you can `async with ... as (read, write)`, such as `streamable_http_client(url, http_client=...)` around your own HTTP client.
* An `MCPServer` (or low-level `Server`) instance: connected **in-process**, with no subprocess and no port. That one is for tests, and **[Testing](https://py.sdk.modelcontextprotocol.io/get-started/testing/index.md)** builds on it.

Everything else on this page is identical across all four. Headers, subprocesses, timeouts, and the `Transport` protocol get their own page: **[Client transports](https://py.sdk.modelcontextprotocol.io/client/transports/index.md)**.

### What's on a connected client

Four read-only properties, populated the moment you enter the block:

* `client.server_info`: the server's identity, or `None` for a 2026-era server that does not report one (python-sdk servers do by default). `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](https://py.sdk.modelcontextprotocol.io/protocol-versions/index.md)** 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

```python title="client.py" hl_lines="8-13"
# docs_src/client/tutorial002.py
import anyio

from mcp import Client


async def main() -> None:
    async with Client("http://localhost:8000/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)


if __name__ == "__main__":
    anyio.run(main)
```

`list_tools()` returns a `ListToolsResult`; the tools are in `.tools`. Each one is the complete definition a host would hand to a model. Here is the first:

```python
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:

```json
{
  "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.

The second tool, `lookup_book`, was registered without a `title=`, so its `tool.title` is `None`.

!!! 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`.

```python title="client.py" hl_lines="9-16"
# docs_src/client/tutorial003.py
import anyio

from mcp import Client
from mcp.types import TextContent


async def main() -> None:
    async with Client("http://localhost:8000/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)


if __name__ == "__main__":
    anyio.run(main)
```

The server's `lookup_book` returns a Pydantic `Book`. Here is what the client sees:

```python
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](https://py.sdk.modelcontextprotocol.io/servers/structured-output/index.md)** page.

### `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
    `ToolError`. The call still returns normally:

    ```python
    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 `ToolError`'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. (Had the tool crashed with
    some other exception, `content` would say only `Error executing tool lookup_book`.) 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](https://py.sdk.modelcontextprotocol.io/servers/handling-errors/index.md)** covers when a server produces which.

## Resources

The resource verbs come in pairs: two ways to list, one way to read.

```python title="client.py" hl_lines="9-18"
# docs_src/client/tutorial004.py
import anyio

from mcp import Client
from mcp.types import TextResourceContents


async def main() -> None:
    async with Client("http://localhost:8000/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)


if __name__ == "__main__":
    anyio.run(main)
```

* `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 be told when a resource changes. On 2025-era connections that is `subscribe_resource(uri)` / `unsubscribe_resource(uri)` - a method pair `MCPServer` doesn't implement, so on the 2026-07-28 wire (where those verbs no longer exist) the request answers `-32601`, *Method not found*. The 2026 replacement is a `subscriptions/listen` stream, which `MCPServer` *does* serve - `server_capabilities.resources.subscribe` is `True` there - and consuming it with `client.listen(...)` is this section's **[Subscriptions](https://py.sdk.modelcontextprotocol.io/client/subscriptions/index.md)** page.

## Prompts

```python title="client.py" hl_lines="8-13"
# docs_src/client/tutorial005.py
import anyio

from mcp import Client


async def main() -> None:
    async with Client("http://localhost:8000/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)


if __name__ == "__main__":
    anyio.run(main)
```

`list_prompts()` tells you what the server offers and what each prompt needs:

```python
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:

```python
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.

```python title="client.py" hl_lines="9-13"
# docs_src/client/tutorial006.py
import anyio

from mcp import Client
from mcp.types import PromptReference


async def main() -> None:
    async with Client("http://localhost:8000/mcp") as client:
        result = await client.complete(
            ref=PromptReference(type="ref/prompt", name="recommend"),
            argument={"name": "genre", "value": "p"},
        )
        print(result.completion.values)


if __name__ == "__main__":
    anyio.run(main)
```

* `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](https://py.sdk.modelcontextprotocol.io/servers/completions/index.md)** page.

## Pagination

Every `list_*` method takes a `cursor=` keyword and every result carries a `next_cursor`. When `next_cursor` is `None`, you have everything.

```python title="client.py" hl_lines="7-15"
# docs_src/client/tutorial007.py
import anyio

from mcp import Client
from mcp.types import Tool


async def list_all_tools(client: Client) -> list[Tool]:
    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:
            return tools
        cursor = page.next_cursor


async def main() -> None:
    async with Client("http://localhost:8000/mcp") as client:
        tools = await list_all_tools(client)
        print([tool.name for tool in tools])


if __name__ == "__main__":
    anyio.run(main)
```

`list_all_tools` 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](https://py.sdk.modelcontextprotocol.io/advanced/pagination/index.md)**.

## In tests

Every `client.py` on this page reached `server.py` over HTTP. In a test you skip the network and hand `Client` the server object itself: `from server import mcp`, then `Client(mcp)`. No process, no port, and every method above works the same.

There is one constructor flag built for that: `Client(mcp, raise_exceptions=True)`. It only has an effect on in-process connections, and **[Testing](https://py.sdk.modelcontextprotocol.io/get-started/testing/index.md)** is the page that explains it and builds the whole pattern around it.

## Recap

* `Client(x)` connects over Streamable HTTP to a URL string, launches a subprocess for a `StdioServerParameters`, enters a transport directly, and in tests takes the server object itself.
* `async with` is the whole lifecycle. Inside it, `server_capabilities` and `protocol_version` are already populated; `server_info` and `instructions` are too when the server provides them.
* `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`.

The things a server can ask the *client* for, and how you answer them, are **[Client callbacks](https://py.sdk.modelcontextprotocol.io/client/callbacks/index.md)**.
