Elicitation: asking a person before acting
A model should not refund 48 on its own judgement. Elicitation lets a tool stop and ask the person using the application, and carry on with their answer.
from typing import Annotated
from pydantic import BaseModel
from mcp.server import MCPServer
from mcp.server.mcpserver import (
AcceptedElicitation,
Elicit,
ElicitationResult,
Resolve,
)
mcp = MCPServer("Shop support")
class Confirm(BaseModel):
approve: boolasync def confirm_refund(order_id: str, amount: float) -> Confirm | Elicit[Confirm]:
"""Ask a person to approve refunds over 20; smaller ones go through."""
if amount <= 20:
return Confirm(approve=True)
return Elicit(f"Refund {amount:.2f} for order {order_id}?", Confirm)confirm_refund is a resolver: a function the SDK runs before the tool to fill in one of its parameters. It reads the tool's own order_id and amount by name. Small refunds are approved straight away; larger ones return Elicit, a question with a Pydantic model describing the answer.
@mcp.tool()
async def refund_order(
order_id: str,
amount: float,
confirm: Annotated[ElicitationResult[Confirm], Resolve(confirm_refund)],
) -> str:
"""Refund part or all of an order."""
if isinstance(confirm, AcceptedElicitation) and confirm.data.approve:
return f"Refunded {amount:.2f} for order {order_id}."
return f"Refund of {amount:.2f} for order {order_id} was not approved."Annotated[ElicitationResult[Confirm], Resolve(confirm_refund)] says: fill confirm by running the resolver, and pass me the whole outcome. The person may accept, decline or cancel, and only an accepted answer with approve set refunds anything. confirm never appears in the tool's input schema, so the model cannot fill it in and approve itself.
The application's side
The client answers questions with an elicitation_callback. A real application shows the message and a form built from the schema; this one approves everything and prints what it was asked:
async def approver(context, params):
print("asked:", params.message)
return ElicitResult(action="accept", content={"approve": True})import asyncio
from mcp import Client
from mcp.types import ElicitResult
from shop import mcp
async def approver(context, params):
print("asked:", params.message)
return ElicitResult(action="accept", content={"approve": True})
async def main():
async with Client(mcp, elicitation_callback=approver) as client:
for amount in (12.5, 48.0):
result = await client.call_tool("refund_order", {"order_id": "B42", "amount": amount})
print(result.content[0].text)
asyncio.run(main())12.50 went through without a question. 48.00 asked first, and the refund happened only after the answer came back.
A client that cannot ask
import asyncio
from mcp import Client, MCPError
from shop import mcp
async def main():
async with Client(mcp) as client:
try:
await client.call_tool("refund_order", {"order_id": "B42", "amount": 48.0})
except MCPError as error:
print(error)
asyncio.run(main())Passing a callback is how a client declares it can ask its user. A client without one cannot, so the large refund fails as a request instead of silently going ahead. That is the behaviour you want from anything that moves money.
await ctx.elicit(...) inside a tool. The SDK docs note that it only works for clients on protocol versions up to 2025-11-25, while a resolver works on every connection, which is why this lesson uses a resolver.- Make
approverreturnElicitResult(action="decline"). - Return
{"approve": False}withaction="accept". - Print
params.requested_schemainsideapprover.
This is what real progress feels like.