Elicitation
A tool that is halfway through its job and missing one answer doesn't have to fail.
Elicitation lets it ask. In the middle of a tool call the user gets a question, and their answer comes back into the same function call.
There are two modes:
- Form mode: you need a value (a confirmation, a date, a quantity). You describe the fields, the client renders the form.
- URL mode: you need the user to go somewhere else (an OAuth consent screen, a payment page). Nothing they do there passes through the protocol.
And there are two ways to ask. The one to reach for is a resolver: you hang the question on a parameter, and the SDK asks - on any connection, whatever protocol era the client speaks. The direct way, await ctx.elicit(...), is a request from the server to the client, a channel that only exists for a client on a legacy connection (spec version 2025-11-25 or earlier). Both are on this page; start with the resolver.
Ask with a resolver
A question that gates the whole tool - are you sure? which of the three matching accounts? - can be lifted out of the tool body into a resolver, and the framework asks it for you.
A parameter annotated Annotated[T, Resolve(fn)] is filled by running fn before the tool body. The resolver returns the value directly when it already knows it, or returns Elicit(...) to have the framework ask:
from typing import Annotated
from pydantic import BaseModel
from mcp.server import MCPServer
from mcp.server.mcpserver import (
AcceptedElicitation,
CancelledElicitation,
DeclinedElicitation,
Elicit,
ElicitationResult,
Resolve,
)
mcp = MCPServer("Files")
_FOLDERS: dict[str, list[str]] = {"/tmp/empty": [], "/tmp/project": ["main.py", "README.md"]}
class Confirm(BaseModel):
ok: bool
async def confirm_delete(path: str) -> Confirm | Elicit[Confirm]:
"""Resolver: ask for confirmation only when the folder is not empty."""
file_count = len(_FOLDERS.get(path, []))
if file_count == 0:
return Confirm(ok=True) # nothing to confirm, no round-trip to the client
return Elicit(f"{path} has {file_count} file(s). Delete anyway?", Confirm)
@mcp.tool()
async def delete_folder(
path: str,
confirm: Annotated[ElicitationResult[Confirm], Resolve(confirm_delete)],
) -> str:
"""Delete a folder, asking for confirmation when it is not empty."""
match confirm:
case AcceptedElicitation(data=Confirm(ok=True)):
_FOLDERS.pop(path, None)
return f"deleted {path}"
case AcceptedElicitation():
return "kept the folder"
case DeclinedElicitation():
return "declined: folder not deleted"
case CancelledElicitation():
return "cancelled: folder not deleted"
confirm_deletereads the tool's ownpathargument by name, lists the folder, and only elicits when it must - an empty folder resolves toConfirm(ok=True)with no round-trip to the client.delete_folderannotatesElicitationResult[Confirm], so the framework injects the whole outcome and the toolmatches every case: accept-and-confirm, accept-but-keep (ok=False), decline, cancel.- The
confirmparameter never appears in the tool's input schema - the client suppliespath, the resolver suppliesconfirm.
Annotate the unwrapped model (Annotated[Confirm, Resolve(confirm_delete)]) instead when the tool doesn't need to branch: it receives the model on accept and the call aborts with an error on decline or cancel.
A resolver works on every connection. For a client on a legacy connection the SDK sends it the question directly; on a 2026-07-28 connection the SDK returns the question from the call, and the client's next attempt carries the answer. Your resolver never knows the difference; what happens underneath is Multi-round-trip requests.
Asking is only one thing a resolver can do. The general mechanism - dependencies that compute without asking, dependencies of dependencies, what the model can and cannot supply - is the Dependencies page.
Ask from inside the tool
A tool can also stop in the middle of its own body and ask.
Warning
ctx.elicit() and ctx.elicit_url() are requests from the server to the client - a
channel that only exists for a client on a legacy connection (spec version 2025-11-25
or earlier). On a 2026-07-28 connection there are no server-initiated requests, so
these calls fail. A resolver works on both. Protocol versions
has the whole story.
await ctx.elicit() takes a message and a Pydantic model:
from pydantic import BaseModel, Field
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
mcp = MCPServer("Bistro")
class AlternativeDate(BaseModel):
accept_alternative: bool = Field(description="Try another date?")
date: str = Field(default="2025-12-26", description="Alternative date (YYYY-MM-DD)")
@mcp.tool()
async def book_table(date: str, party_size: int, ctx: Context) -> str:
"""Book a table at the bistro."""
if date != "2025-12-25":
return f"Booked a table for {party_size} on {date}."
result = await ctx.elicit(
message=f"No tables for {party_size} on {date}. Would you like to try another date?",
schema=AlternativeDate,
)
if result.action == "accept" and result.data.accept_alternative:
return await book_table(result.data.date, party_size, ctx)
return "No booking made."
- The
Contextparameter is what gives youctx.elicit; any tool can take one. That object has its own page: The Context. AlternativeDateis the schema of the answer you want.- The tool is
async def. It has to be: it stops in the middle and waits for a person. - On any other date the tool returns straight away. It only asks when it has to.
- The date the user accepts goes back through
book_tableitself. An answer is input like any other: an alternative that is also fully booked gets asked about again, not confirmed blind.
What the client receives
The client gets your message and, next to it, a JSON Schema generated from the model:
{
"properties": {
"accept_alternative": {
"description": "Try another date?",
"title": "Accept Alternative",
"type": "boolean"
},
"date": {
"default": "2025-12-26",
"description": "Alternative date (YYYY-MM-DD)",
"title": "Date",
"type": "string"
}
},
"required": ["accept_alternative"],
"title": "AlternativeDate",
"type": "object"
}
That schema is the form. Field(description=...) is the label; a default pre-fills the input and makes the field optional. It's the same Pydantic-to-JSON-Schema machinery Tools describes for a tool's arguments.
Warning
An elicitation schema is not as expressive as a tool's input schema. Flat, primitive fields
only: str, int, float, bool, or a Literal of strings (it becomes an enum).
Put a model inside the model and ctx.elicit raises before anything is sent to the client:
TypeError: Elicitation schema field 'address' rendered as {'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition
You are interrupting a person mid-task. If the answer needs nesting, it should have been an argument to the tool.
The three answers
result.action tells you what the user did, and there are exactly three possibilities:
"accept": they submitted the form.result.datais anAlternativeDateinstance, already validated."decline": they said no."cancel": they dismissed the question without choosing.
result.data only exists on "accept", which is why the example checks result.action first. Your type checker enforces the order: after result.action == "accept", result.data is an AlternativeDate; before it, there is no .data at all.
A refusal is not an error. The tool decides what declining means (here, no booking) and answers the model normally.
Tip
The answer is validated against your model before your code sees it. A client that sends
"maybe" for a bool doesn't corrupt your booking: the call fails with a
schema-mismatch error, your if never runs.
Send the user to a URL
Some things must not go through the model or the client: credentials, card numbers, OAuth consent. For those you don't ask for data; you ask the user to go somewhere:
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
mcp = MCPServer("Bistro")
@mcp.tool()
async def pay_deposit(booking_id: str, ctx: Context) -> str:
"""Take the deposit that confirms a booking."""
result = await ctx.elicit_url(
message="A 20 EUR deposit confirms your booking.",
url=f"https://pay.example.com/deposit/{booking_id}",
elicitation_id=f"deposit-{booking_id}",
)
if result.action == "accept":
return "Complete the payment in your browser."
return "No deposit taken. The booking expires in one hour."
@mcp.tool()
async def confirm_deposit(booking_id: str, ctx: Context) -> str:
"""Record a payment reported by the payment provider."""
await ctx.session.send_elicit_complete(f"deposit-{booking_id}")
return f"Deposit received for booking {booking_id}."
ctx.elicit_url()takes the message, the URL to visit, and anelicitation_idyou choose: any string that identifies this elicitation within your server.- The result has an action and nothing else.
"accept"means the user agreed to open the URL, not that they finished what's on the other side. - The payment happens out of band, between the user's browser and your payment provider. No content ever comes back through MCP.
Look at the second tool. When your server learns the out-of-band flow finished (a webhook, a poll; here it's modelled as a second tool), ctx.session.send_elicit_complete(...) sends notifications/elicitation/complete with the same elicitation_id. That is how the client knows it can stop showing "waiting for payment...". Without it, the client can only guess.
The client side
Servers ask. Clients answer by passing an elicitation_callback to Client(...):
from mcp_types import ElicitRequestParams, ElicitRequestURLParams, ElicitResult
from mcp import Client
from mcp.client import ClientRequestContext
async def handle_elicitation(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
if isinstance(params, ElicitRequestURLParams):
print(f"Open this link to continue: {params.url}")
return ElicitResult(action="accept")
print(params.message)
return ElicitResult(action="accept", content={"accept_alternative": True, "date": "2025-12-27"})
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("book_table", {"date": "2025-12-25", "party_size": 2})
print(result.content)
- One callback handles both modes.
paramsis a union ofElicitRequestFormParamsandElicitRequestURLParams;isinstanceis the branch. - For a URL, you show
params.urlto the user and return the action they chose. Never anycontent. - For a form, a real application renders
params.requested_schemaand returns the user's input ascontent. This one always says yes with a canned answer, which is exactly the callback you want in a test. - Passing the callback is also the capability declaration: it's how the server learns this client can be asked. The other things a client can answer for a server live in Client callbacks.
Info
Elicitation is a request from the server to the client, and those only exist on a
classic-handshake session, which is why this client passes mode="legacy".
On a 2026-07-28 connection a tool asks by returning the question from the call
instead; that flow is Multi-round-trip requests.
Try it
Start the ctx.elicit form-mode server.py (the book_table one) on Streamable HTTP (Running your server has the one-liner), then run the client's main() and ask book_table for Christmas day.
The callback prints the question it was sent:
No tables for 2 on 2025-12-25. Would you like to try another date?
It answers with {"accept_alternative": True, "date": "2025-12-27"}, and the tool, which has been waiting inside await ctx.elicit(...) this whole time, finishes the booking:
Booked a table for 2 on 2025-12-27.
Now swap in the URL-mode server.py and point the same main() at pay_deposit: the same callback takes the other branch, prints the payment link, and the tool comes back with "Complete the payment in your browser." One round trip, mid-call, in both directions.
Check
Now remove elicitation_callback= from the Client and call book_table for Christmas day
again. The whole call fails with a protocol error:
Elicitation not supported
A client that registered no callback never declared the elicitation capability, so there is
nobody to ask. Your tool didn't get a "decline"; it got an exception. Design for it: every
elicitation needs a sensible answer to "what if I can't ask?".
Recap
- A parameter annotated
Annotated[T, Resolve(fn)]is filled by a resolver, which returnsElicit(...)when it has to ask. It works on every connection. - The schema is a flat Pydantic model: primitive fields only, validated on the way back.
result.actionis"accept","decline"or"cancel";result.dataexists only on accept.await ctx.elicit(message, schema=Model)asks from inside the tool body, andawait ctx.elicit_url(message, url, elicitation_id)is for everything that must not pass through the model (ctx.session.send_elicit_complete(elicitation_id)says the out-of-band part is done). Both are server-to-client requests: they need the client on a legacy connection.- The client answers with one
elicitation_callback, branching on the params type; registering it is what declares the capability. - On a 2026-07-28 connection the server returns the question instead of pushing it; the same callback is fed by Multi-round-trip requests.
Everything underneath that return (the retry loop, protecting requestState, driving it yourself) is Multi-round-trip requests.