# Client callbacks

Nearly every request in MCP goes one way: client to server.

A server can also ask the **client** for things: to put a question to the user, to sample the user's model, to list the user's workspace folders. You answer those requests by passing **callbacks** to `Client(...)`.

## A server that asks

Here is a server whose tool can't finish on its own:

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

from mcp.server import MCPServer
from mcp.server.mcpserver import Context

mcp = MCPServer("Library")


class CardHolder(BaseModel):
    name: str


@mcp.tool()
async def issue_card(ctx: Context) -> str:
    """Issue a new library card."""
    answer = await ctx.elicit("What name should go on the card?", schema=CardHolder)
    if answer.action == "accept":
        return f"Card issued to {answer.data.name}."
    return "No card issued."
```

* `ctx.elicit(...)` sends an `elicitation/create` request **to the client** and waits.
* The tool doesn't return until somebody (a person in a form, or your code) supplies a `name`.

That is the server half, and the **[Elicitation](https://py.sdk.modelcontextprotocol.io/handlers/elicitation/index.md)** page owns it. This page is the other end of the wire.

## The elicitation callback

```python title="client.py" hl_lines="6-10 16-17"
# docs_src/client_callbacks/tutorial002.py
from mcp import Client
from mcp.client import ClientRequestContext
from mcp.types import ElicitRequestParams, ElicitResult


async def handle_elicitation(
    context: ClientRequestContext,
    params: ElicitRequestParams,
) -> ElicitResult:
    return ElicitResult(action="accept", content={"name": "Ada Lovelace"})


async def main() -> None:
    async with Client(
        "http://127.0.0.1:8000/mcp",
        mode="legacy",
        elicitation_callback=handle_elicitation,
    ) as client:
        result = await client.call_tool("issue_card")
        print(result.content)
```

* An elicitation callback is `async (context, params) -> ElicitResult`.
* `params.message` is the question. `params.requested_schema` is the JSON Schema of the answer the server wants. A real client renders a form from it; this one auto-fills.
* You return `ElicitResult(action="accept", content={...})`, or `action="decline"`, or `action="cancel"`. The only other option is `ErrorData(...)`, which refuses the request and fails the whole call.
* `context` is a `ClientRequestContext`: the live `session`, the server's `request_id`, and any `meta` it attached.

!!! tip
    `params` is a union of the two elicitation modes. Here `params.mode` is `"form"`; a `"url"` request
    carries `params.url` instead of a schema. One callback handles both; branch on `params.mode`.
    **[Elicitation](https://py.sdk.modelcontextprotocol.io/handlers/elicitation/index.md)** shows the full pattern.

### Try it

Call `issue_card` and watch both ends.

Your callback receives the server's question, already parsed:

```python
params.mode              # 'form'
params.message           # 'What name should go on the card?'
params.requested_schema  # {'properties': {'name': {'title': 'Name', 'type': 'string'}},
                         #  'required': ['name'], 'title': 'CardHolder', 'type': 'object'}
```

It answers, `ctx.elicit(...)` resumes inside the tool, and the tool finishes:

```python
result.content  # [TextContent(type='text', text='Card issued to Ada Lovelace.')]
```

One `tools/call` from you, one `elicitation/create` back from the server, answered by your function, all inside a single tool call.

!!! info
    `mode="legacy"` on the `Client(...)` call is doing real work. By default `Client(...)` negotiates the modern
    protocol path, and that path has no back-channel for server-to-client requests: `ctx.elicit`
    fails before your callback ever runs. The transport doesn't decide that; the negotiated
    protocol does, in-memory and over a URL alike. Pin `mode="legacy"` whenever your client has
    to answer one; every test behind this page does. **[Protocol versions](https://py.sdk.modelcontextprotocol.io/protocol-versions/index.md)** has the whole story.

    On a 2026-07-28 session the callback isn't dead, it's fed differently: when a tool returns an
    `InputRequiredResult` carrying an `ElicitRequest`, `Client` dispatches that entry to the same
    `elicitation_callback` and retries the call for you. That flow is **[Multi-round-trip requests](https://py.sdk.modelcontextprotocol.io/handlers/multi-round-trip/index.md)**.

## A callback is a capability

You never told the server that your client can answer elicitation requests. The SDK did.

When a client connects it declares its `capabilities`, the mirror image of the server's. You don't write that object. **Registering a callback is the declaration.**

| you pass | the client declares |
| --- | --- |
| `elicitation_callback=` | `"elicitation": {"form": {}, "url": {}}` |
| `sampling_callback=` | `"sampling": {}` |
| `list_roots_callback=` | `"roots": {"listChanged": true}` |
| none of them | `{}` |

Sampling sub-capabilities are the one refinement: pass `sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability())` alongside `sampling_callback` when your sampler handles the `tools` / `tool_choice` parameters. Servers must see `sampling.tools` declared before they can send them.

`logging_callback` and `message_handler` are not in the table. They handle notifications, and notifications need no capability.

The server reads the declaration back with `ctx.session.check_client_capability(...)`. Add a tool that does:

```python title="server.py" hl_lines="23-31"
# docs_src/client_callbacks/tutorial003.py
from pydantic import BaseModel

from mcp.server import MCPServer
from mcp.server.mcpserver import Context
from mcp.types import ClientCapabilities, ElicitationCapability, RootsCapability, SamplingCapability

mcp = MCPServer("Library")


class CardHolder(BaseModel):
    name: str


@mcp.tool()
async def issue_card(ctx: Context) -> str:
    """Issue a new library card."""
    answer = await ctx.elicit("What name should go on the card?", schema=CardHolder)
    if answer.action == "accept":
        return f"Card issued to {answer.data.name}."
    return "No card issued."


@mcp.tool()
def client_features(ctx: Context) -> list[str]:
    """Which optional features the connected client declared."""
    declared = {
        "elicitation": ClientCapabilities(elicitation=ElicitationCapability()),
        "sampling": ClientCapabilities(sampling=SamplingCapability()),
        "roots": ClientCapabilities(roots=RootsCapability()),
    }
    return [name for name, capability in declared.items() if ctx.session.check_client_capability(capability)]
```

Connect with only `elicitation_callback` and call it:

```python
result.structured_content  # {'result': ['elicitation']}
```

Pass all three callbacks and you get `['elicitation', 'sampling', 'roots']`. Pass none and you get `[]`.

!!! check
    Now do the wrong thing: connect **without** `elicitation_callback` and call `issue_card` anyway.

    The server's `elicitation/create` request still reaches your client, and the SDK answers it for
    you, with an error, because you never said you could handle it. That error sinks the whole call.
    `call_tool` doesn't return an `is_error` result; it raises:

    ```text
    MCPError: Elicitation not supported
    ```

    That is a protocol error (`-32600`, *invalid request*), not a tool error: there is nothing for
    the model to read and retry. It's why `client_features` is worth having: a well-behaved server
    checks before it asks.

## The deprecated pair

`sampling_callback` answers `sampling/createMessage`: the server asking *your* model to complete something. `list_roots_callback` answers `roots/list`: the server asking which directories it may work in.

Both work. Both follow the rule above. And both serve RPCs the **2026-07-28 spec removes**: a modern server doesn't call back into your client mid-request, it hands the request back to you as part of the tool result (**[Multi-round-trip requests](https://py.sdk.modelcontextprotocol.io/handlers/multi-round-trip/index.md)**). The callbacks themselves are not dead. When an `InputRequiredResult` carries a `CreateMessageRequest` or a `ListRootsRequest`, `Client`'s auto-loop dispatches it to the same `sampling_callback` or `list_roots_callback` you registered here. The whole list is in **[Deprecated features](https://py.sdk.modelcontextprotocol.io/deprecated/index.md)**.

You still need the callbacks to talk to servers that haven't moved. The signatures:

```python title="client.py"
# docs_src/client_callbacks/tutorial004.py
from pydantic import FileUrl

from mcp.client import ClientRequestContext
from mcp.types import CreateMessageRequestParams, CreateMessageResult, ListRootsResult, Root, TextContent


async def handle_sampling(
    context: ClientRequestContext,
    params: CreateMessageRequestParams,
) -> CreateMessageResult:
    return CreateMessageResult(
        role="assistant",
        content=TextContent(type="text", text="The answer is 42."),
        model="my-llm",
    )


async def handle_list_roots(context: ClientRequestContext) -> ListRootsResult:
    return ListRootsResult(roots=[Root(uri=FileUrl("file:///home/ada/notebooks"), name="notebooks")])
```

* A sampling callback receives the full `CreateMessageRequestParams` (`messages`, `model_preferences`, `max_tokens`) and returns a `CreateMessageResult`. *You* run the model, however you like; the SDK only carries the request.
* A roots callback takes no params at all and returns a `ListRootsResult`.
* Either one may return `ErrorData(...)` instead, to refuse.

Pass them to `Client(...)` exactly like `elicitation_callback`.

## The notification callbacks

Two more. Neither declares anything.

`logging_callback` receives the `notifications/message` a server sends, as `LoggingMessageNotificationParams` (`level`, `logger`, `data`). Protocol logging is itself deprecated by the 2026-07-28 spec (**[Logging](https://py.sdk.modelcontextprotocol.io/handlers/logging/index.md)** has what to do instead), so this callback exists for the servers that still emit it. On a 2026-era connection the callback alone gets you nothing, because 2026 servers send log messages only to requests that opt in: pass `log_level="info"` (or another level) to `Client(...)` to stamp that opt-in on every request and receive that level and above. Pre-2026 servers ignore it and keep their `logging/setLevel` behavior.

`message_handler` is the catch-all: every server notification the session surfaces reaches it (as well as its specific callback), and on a stream-backed transport so does every transport-level `Exception`. Two never do: `notifications/cancelled` is applied by the SDK rather than surfaced, and a subscription acknowledgment for a live `listen()` stream is consumed by that stream. Annotate the parameter with `IncomingMessage` (`ServerNotification | Exception`, exported from `mcp.client`). The one pattern worth knowing is `if isinstance(message, Exception): raise message`, so a broken connection fails loudly instead of vanishing.

## Recap

* A server can send requests to the client. You answer them with callbacks passed to `Client(...)`.
* The elicitation callback is the current one: `async (context, params) -> ElicitResult`, one function for both form and URL mode.
* **Registering a callback is declaring the capability.** Without it, the SDK refuses the server's request on your behalf and the whole call fails with `MCPError`.
* A server finds out before asking with `ctx.session.check_client_capability(...)`.
* `sampling_callback` and `list_roots_callback` work the same way but serve deprecated features; modern servers use multi-round-trip requests instead.
* `logging_callback` and `message_handler` receive notifications. They declare nothing.

The first argument to `Client(...)` is a transport object. **[Client transports](https://py.sdk.modelcontextprotocol.io/client/transports/index.md)** covers every kind.
