Skip to content

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:

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

The elicitation callback

client.py
from mcp_types import ElicitRequestParams, ElicitResult

from mcp import Client
from mcp.client import ClientRequestContext


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 shows the full pattern.

Try it

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.

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 {}

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:

server.py
from mcp_types import ClientCapabilities, ElicitationCapability, RootsCapability, SamplingCapability
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."


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

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:

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:

client.py
from mcp_types import CreateMessageRequestParams, CreateMessageResult, ListRootsResult, Root, TextContent
from pydantic import FileUrl

from mcp.client import ClientRequestContext


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