docs/client/callbacks.md
So far every request has gone 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(...).
Here is a server whose tool can't finish on its own:
--8<-- "docs_src/client_callbacks/tutorial001.py"
ctx.elicit(...) sends an elicitation/create request to the client and waits.name.That is the server half, and the Elicitation chapter owns it. This chapter is the other end of the wire.
--8<-- "docs_src/client_callbacks/tutorial002.py"
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.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 shows the full pattern.
Call issue_card and watch both ends.
Your callback receives the server's question, already parsed:
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:
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 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](../advanced/multi-round-trip.md)**.
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 | {} |
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:
--8<-- "docs_src/client_callbacks/tutorial003.py"
Connect with only elicitation_callback and call it:
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.
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). 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.
You still need the callbacks to talk to servers that haven't moved. The signatures:
--8<-- "docs_src/client_callbacks/tutorial004.py"
CreateMessageRequestParams (messages, model_preferences, max_tokens) and returns a CreateMessageResult. You run the model, however you like; the SDK only carries the request.ListRootsResult.ErrorData(...) instead, to refuse.Pass them to Client(...) exactly like elicitation_callback.
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 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.
Client(...).async (context, params) -> ElicitResult, one function for both form and URL mode.MCPError.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.Next: the first argument you've been passing to Client(...) all along, Client transports.