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 server sends the client a question, the client puts it to the user, and the 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.
Ask with a form
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 chapter: 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 you already used for a tool's arguments in Tools.
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 the
ValidationError, your if never runs.
Ask before the tool runs
The booking tool above weaves the question into its own body. When the question is really a precondition - confirm before deleting, authenticate before acting - you can lift it out of the tool into a resolver and let the framework ask 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.
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 chapter.
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 form-mode server.py (the first one on this page) 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
await ctx.elicit(message, schema=Model)asks mid-call; your tool resumes with the answer.- 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_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.- 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.
A tool that can ask is good. A tool that says how far along it is (Progress) is next.