49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
150 lines
8.2 KiB
Markdown
150 lines
8.2 KiB
Markdown
# 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"
|
|
--8<-- "docs_src/client_callbacks/tutorial001.py"
|
|
```
|
|
|
|
* `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](../handlers/elicitation.md)** page owns it. This page is the other end of the wire.
|
|
|
|
## The elicitation callback
|
|
|
|
```python title="client.py" hl_lines="7-11 17-18"
|
|
--8<-- "docs_src/client_callbacks/tutorial002.py"
|
|
```
|
|
|
|
* 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](../handlers/elicitation.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 line 17 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](../protocol-versions.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](../handlers/multi-round-trip.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"
|
|
--8<-- "docs_src/client_callbacks/tutorial003.py"
|
|
```
|
|
|
|
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](../handlers/multi-round-trip.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](../deprecated.md)**.
|
|
|
|
You still need the callbacks to talk to servers that haven't moved. The signatures:
|
|
|
|
```python title="client.py"
|
|
--8<-- "docs_src/client_callbacks/tutorial004.py"
|
|
```
|
|
|
|
* 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 every `notifications/message` a server sends, as `LoggingMessageNotificationParams` (`level`, `logger`, `data`). Protocol logging is itself deprecated by the 2026-07-28 spec (**[Logging](../handlers/logging.md)** has what to do instead), so this callback exists for the servers that still emit it.
|
|
|
|
`message_handler` is the catch-all: every server notification reaches it (as well as its specific callback), and on a stream-backed transport so does every transport-level `Exception`. 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](transports.md)** covers every kind.
|