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(...).
Here is a server whose tool can't finish on its own:
--8<-- "docs_src/client_callbacks/tutorial001.py"ctx.elicit(...)sends anelicitation/createrequest 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 page owns it. This page is the other end of the wire.
--8<-- "docs_src/client_callbacks/tutorial002.py"- An elicitation callback is
async (context, params) -> ElicitResult. params.messageis the question.params.requested_schemais 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={...}), oraction="decline", oraction="cancel". The only other option isErrorData(...), which refuses the request and fails the whole call. contextis aClientRequestContext: the livesession, the server'srequest_id, and anymetait 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](../handlers/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"- A sampling callback receives the full
CreateMessageRequestParams(messages,model_preferences,max_tokens) and returns aCreateMessageResult. 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.
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.
- 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_callbackandlist_roots_callbackwork the same way but serve deprecated features; modern servers use multi-round-trip requests instead.logging_callbackandmessage_handlerreceive notifications. They declare nothing.
The first argument to Client(...) is a transport object. Client transports covers every kind.