Model Context ProtocolMCP Python SDK 2.2 · LangChain 1.4 · Python 3.10+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
27 small wins to finish your pathNext lesson →

Elicitation

Elicitation is a server pausing a tool to ask the person using the application a question, and carrying on with their answer.

Last updated: 29 Sep, 2026 · MCP 2.2

This lesson's server goes in a file of its own, approval_demo.py, and shop.py stays as it is. The MCP project lesson brings the same approval into shop.py.

Exampleapproval_demo.py, part 1
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: bool
Exampleapproval_demo.py, part 2
async 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.

Exampleapproval_demo.py, part 3
@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:

Example
async def approver(context, params):
    print("asked:", params.message)
    return ElicitResult(action="accept", content={"approve": True})
Example
import asyncio

from mcp import Client
from mcp.types import ElicitResult
from approval_demo 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 with no question, because the resolver approves refunds of 20 or less on its own.
  • 48.00 asked first, and the refund happened only after the answer came back from the callback.

A client that cannot ask

Example
import asyncio

from mcp import Client, MCPError
from approval_demo 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.

On older connections
The SDK also has await ctx.elicit(...) inside a tool. 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.

A resolver vs a plain argument for approval

A plain argumentA resolver
Who fills itThe modelThe resolver, and the person it asks
In the input schemaYesNo
Can the model approve itselfYesNo

When to ask a person

  • An action that moves money or cannot be undone, like a refund.
  • A step that needs a human decision the model should not make alone.
  • A confirmation you want enforced by the server, whatever host connects.
Watch out
A client with no elicitation_callback cannot answer, so a refund over the limit fails as a request rather than going through. Treat that failure as the safe default.
Try it yourself
  • Make approver return ElicitResult(action="decline").
  • Return {"approve": False} with action="accept".
  • Print params.requested_schema inside approver.

Little by little, you're building something great.