Elicitation II: the resolver pattern
You do not write the loop
The protocol from the last lesson is what travels. You almost never implement it by hand. The SDK turns "this argument may need a question" into a parameter type.
from typing import Annotated, Literal
from pydantic import BaseModel, Field
from mcp.server.mcpserver import (AcceptedElicitation, CancelledElicitation, DeclinedElicitation,
Elicit, ElicitationResult, Resolve)
class RoomChoice(BaseModel):
"""Which of the three rooms the booking is for."""
room_id: Literal["atrium", "studio-b", "nook"] = Field(
description="The room to book. The Atrium seats 24, Studio B 12, The Nook 4."
)
def resolve_room(room_hint: str | None = None) -> RoomChoice | Elicit[RoomChoice]:
"""Turn a loose room description into one room id, asking only when it is ambiguous."""
if room_hint in ROOMS:
return RoomChoice(room_id=room_hint)
return Elicit(f"{room_hint!r} matches more than one room. Which did you mean?", RoomChoice)A resolver returns one of two things: the value, when it can work it out, or Elicit(message, schema), when it needs to ask. It runs before the tool body.
The clerk who asks one question
A good front-desk clerk does not interrogate you. They take what you said, work out everything they can, and ask only about the part that is genuinely undecidable — once, specifically, with the options in front of you.
That is the resolver's job, and the framing matters because it rules out the two common mistakes. A beginner learns that the resolver is not a validator — it is not there to check your input, it is there to finish it. An engineer sees that this is a nullable-to-non-nullable narrowing with a human as the fallback, and that the design goal is to minimise the number of times the fallback fires.
Note what the resolver above does when the hint is already a room id: it returns immediately, with no round trip at all. Asking is the exception, not the flow.
Wiring it to the tool
@mcp.tool(title="Book a room")
def book_room(
day: Annotated[str, Field(description="The day to book, as YYYY-MM-DD.")],
hour: Annotated[int, Field(ge=8, le=17, description="Start hour, 24h clock.")],
room: Annotated[ElicitationResult[RoomChoice], Resolve(resolve_room)],
room_hint: Annotated[str | None, Field(description="What the user called the room, e.g. 'the big room'.")] = None,
minutes: Literal[30, 60, 120] = 60,
ctx: Context[AtriumContext] = None,
) -> Booking:
"""Book a room. If the room is ambiguous the user is asked which one they meant."""
match room:
case AcceptedElicitation(data=RoomChoice(room_id=picked)):
store = ctx.request_context.lifespan_context.bookings
if hour in store.taken_hours(picked, day):
raise ValueError(f"{ROOMS[picked]['name']} is already booked at {hour:02d}:00 on {day}.")
booking_id = store.book(picked, day, hour, minutes)
return Booking(booking_id=booking_id, room_id=picked, start=f"{day}T{hour:02d}:00", minutes=minutes)
case DeclinedElicitation():
raise ValueError("No room chosen, so nothing was booked.")
case CancelledElicitation():
raise ValueError("Booking cancelled before a room was chosen.")Two signatures are doing work here, and the relationship between them is the whole pattern. room_hint is what the model fills in — a loose phrase from the user. room is what the resolver produces — a settled answer.
Three traps, all of which will catch you
These are not hypothetical; each produces a specific failure.
*A resolver's parameters must name other tool arguments, never its own.* Name the resolver's parameter room — the same as the argument it resolves — and the server refuses to start:
mcp.server.mcpserver.exceptions.InvalidSignature: Resolver 'resolve_room' parameter 'room'
cannot be resolved: expected a Context, an Annotated[_, Resolve(...)], or a tool argument by nameThat is why the hint has a different name from the resolved value. The resolver reads room_hint; it produces room.
Elicit takes a type, not a bare Literal. Elicit("...", Literal["atrium", "studio-b", "nook"]) looks reasonable and fails at call time with an unhelpful model_json_schema error. The schema must be a class — wrap the Literal in a BaseModel field, as RoomChoice does. This is what "elicitation schemas support only primitive types" actually means: a flat model whose fields are str, int, float, bool or Literal. Nested models raise a TypeError before anything is sent.
The resolved parameter never appears in the input schema. Here is the schema the model actually sees:
{'type': 'object',
'properties': {'day': {...}, 'hour': {...}, 'room_hint': {...}, 'minutes': {...}},
'required': ['day', 'hour'],
'title': 'book_roomArguments'}No room. Like Context, it is invisible — the model supplies a hint and the resolver decides whether that is enough. This is the single most elegant thing about the pattern, and it means the model cannot bypass the question by inventing an answer.
Three outcomes, and conflating two of them is a bug
ElicitationResult is one of three things, and match is the natural way to handle it.
AcceptedElicitation— the user answered.dataholds the validated model.DeclinedElicitation— the user said no to this question. They were asked and refused.CancelledElicitation— the user dismissed the whole operation without answering.
Treating decline and cancel as the same thing is a real bug with a visible symptom. Declining is an answer: the user considered the question and said no, so "no room chosen, nothing booked" is the right response and offering an alternative is reasonable. Cancelling is a withdrawal: the user has left, and a follow-up question is an annoyance.
Watching it run
Unambiguous — the resolver returns immediately, no question:
-- book_room, room_hint="studio-b" --
-> {"booking_id": "bk_1", "room_id": "studio-b", "start": "2026-09-17T09:00", "minutes": 60}Ambiguous — the round trip fires:
-- book_room, room_hint="the big room" --
[client] 'the big room' matches more than one room. Which did you mean?
-> {"booking_id": "bk_2", "room_id": "studio-b", "start": "2026-09-17T10:00", "minutes": 60}One sentence from Lesson 1, answered in code. The server did not guess, did not fail, and did not ask when it did not need to.
What the client does
For completeness, the other half. A client declares the capability by supplying a callback:
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
print(params.message)
print("options:", params.requested_schema["properties"]["room_id"]["enum"])
return ElicitResult(action="accept", content={"room_id": "studio-b"})
async with Client(mcp, elicitation_callback=on_elicit) as client:
...A real host renders a form here instead of printing. Note requested_schema in snake_case — the wire field is requestedSchema, and SDK v2 renames it like everything else.
What to take into the next lesson
Annotated[ElicitationResult[T], Resolve(fn)] turns ambiguity into a parameter type: the resolver returns a value or an Elicit, the resolved argument is invisible to the model, and match handles accepted, declined and cancelled as the three distinct outcomes they are. Next: the questions you must never ask this way.