Back to Python Sdk

Client callbacks

docs/client/callbacks.md

2.0.0b17.9 KB
Original Source

Client callbacks

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(...).

A server that asks

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

python
--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 chapter owns it. This chapter is the other end of the wire.

The elicitation callback

python
--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 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 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)**.

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 passthe 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:

python
--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). 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:

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

Next: the first argument you've been passing to Client(...) all along, Client transports.