Match ctx.elicit's message and response_type on Elicit

The declarative form describes the same request as the imperative one, so it
takes the same keywords. The difference is await versus return.
This commit is contained in:
Jeremiah Lowin 2026-07-28 16:38:23 -04:00
commit 1a49190d00
No known key found for this signature in database
3 changed files with 95 additions and 48 deletions

View file

@ -80,7 +80,7 @@ mcp = FastMCP("Booking Server")
def which_airport(destination: str) -> Elicit[str]:
return Elicit(f"Which airport in {destination} — CDG or ORY?", elicit_type=str)
return Elicit(f"Which airport in {destination} — CDG or ORY?", response_type=str)
@mcp.tool
@ -120,7 +120,7 @@ def current_profile() -> Profile:
def which_airport(destination: str, profile: Profile = Depends(current_profile)) -> str | Elicit[str]:
if profile.home_airport:
return profile.home_airport
return Elicit(f"Which airport in {destination}?", elicit_type=str)
return Elicit(f"Which airport in {destination}?", response_type=str)
@mcp.tool
@ -133,7 +133,16 @@ async def book_flight(
A returning traveller is never asked and never pays a round trip; a new one gets the question. The tool body is identical either way, and so is the annotation — only the resolver knows the difference.
State the type with `elicit_type` when you build an `Elicit` inside a resolver. The parameter's annotation is two functions away at that point, and repeating it locally is worth more than the brevity of leaving it out. When a resolver also declares it — `-> str | Elicit[str]` — FastMCP checks the two agree at registration.
An `Elicit` takes the same `message` and `response_type` as [`ctx.elicit()`](#requesting-input-on-handshake-connections), because it describes the same thing. The only difference is where it goes — you `await` the imperative one and `return` this one:
```python
result = await ctx.elicit(message="Which airport?", response_type=Airport) # imperative
return Elicit(message="Which airport?", response_type=Airport) # declarative
```
State `response_type` when you build an `Elicit` inside a resolver. The parameter's annotation is two functions away at that point, and repeating it locally is worth more than the brevity of leaving it out; omit it and the parameter's annotation is used. When a resolver also declares the type in its return — `-> str | Elicit[str]` — FastMCP checks the two agree at registration.
A question that has already been answered is not asked again. Resolvers re-run on every round, so a three-round call re-forms all its earlier questions, but each one is satisfied by the answer recorded against it rather than put to the user a second time. That holds for every `Elicit`, whether it came from a literal or a resolver — as long as the question still renders the same way, which is what the [digest](#repeated-questions) checks.
Because a question can quote a tool argument, it can also quote something the model supplied — which is exactly what you want for `f"Which airport in {destination}?"`, and a good reason to treat the wording as untrusted display text rather than as an instruction to the user.
@ -149,7 +158,7 @@ mcp = FastMCP("Booking Server")
def confirm(destination: str, date: str) -> Elicit[bool]:
return Elicit(f"Book a flight to {destination} on {date}?", elicit_type=bool)
return Elicit(f"Book a flight to {destination} on {date}?", response_type=bool)
@mcp.tool
@ -222,7 +231,7 @@ def search_flights(destination: str, date: str) -> list[str]:
def which_flight(destination: str, date: str) -> Elicit[str]:
options = search_flights(destination, date)
return Elicit(f"Which flight? {', '.join(options)}", elicit_type=str)
return Elicit(f"Which flight? {', '.join(options)}", response_type=str)
@mcp.tool

View file

@ -113,7 +113,7 @@ class Elicit(Generic[T]):
def which_airport(destination: str, profile: Profile = Depends(get_profile)) -> Airport | Elicit[Airport]:
if profile.home_airport:
return profile.home_airport
return Elicit(f"Which airport in {destination}?", elicit_type=Airport)
return Elicit(f"Which airport in {destination}?", response_type=Airport)
airport: Annotated[Airport, Elicit(which_airport)]
@ -124,14 +124,15 @@ class Elicit(Generic[T]):
and declining it fails the call.
Args:
question: The text to show the user, or a resolver that decides. A
message: The text to show the user, or a resolver that decides. A
resolver's parameters are filled by name from the call's own
arguments and from other elicited parameters, which is also what
orders the asks; it may declare its own `Depends(...)` parameters,
and it may be sync or async.
elicit_type: The type to ask for. State it when constructing an `Elicit`
inside a resolver, where the parameter's annotation is not in view.
Omitted, the parameter's own annotation is used.
response_type: The type to ask for, exactly as `ctx.elicit()` takes it.
State it when constructing an `Elicit` inside a resolver, where the
parameter's annotation is not in view; omitted, the parameter's own
annotation is used.
title: Optional label for the wrapped `value` field, for the scalar and
shorthand forms. Same scope rules as `ctx.elicit()`.
description: Optional description for the wrapped `value` field.
@ -139,14 +140,14 @@ class Elicit(Generic[T]):
def __init__(
self,
question: str | Callable[..., Any],
message: str | Callable[..., Any],
*,
elicit_type: Any = None,
response_type: Any = None,
title: str | None = None,
description: str | None = None,
) -> None:
self.question = question
self.elicit_type = elicit_type
self.message = message
self.response_type = response_type
self.title = title
self.description = description
@ -192,10 +193,10 @@ class ElicitParam:
A resolver may also declare its own `Depends(...)` parameters, which
resolve the ordinary way.
"""
if isinstance(self.marker.question, str):
if isinstance(self.marker.message, str):
return self.marker
bound = {name: values[name] for name in self.depends_on}
async with resolved_dependencies(self.marker.question, bound) as injected:
async with resolved_dependencies(self.marker.message, bound) as injected:
for param_name, value in injected.items():
# The DI engine reports a dependency it could not build as a
# sentinel rather than raising, which would otherwise reach the
@ -207,24 +208,24 @@ class ElicitParam:
f"The resolver for {self.name!r} depends on {param_name!r}, "
"which could not be resolved"
) from value.error
outcome = self.marker.question(**bound, **injected)
outcome = self.marker.message(**bound, **injected)
return await outcome if inspect.isawaitable(outcome) else outcome
def elicit_type(self, request: Elicit[Any]) -> Any:
def type_for(self, request: Elicit[Any]) -> Any:
"""The type one question asks for.
Taken from the `Elicit` when it states one a resolver naming
`elicit_type` where the parameter's annotation is out of view — and from
`response_type` where the parameter's annotation is out of view — and from
the parameter's own annotation otherwise.
"""
if request.elicit_type is not None:
return request.elicit_type
if request.response_type is not None:
return request.response_type
return self.response_type
def config(self, request: Elicit[Any]) -> ElicitConfig:
"""Schema and response handling for one question's answer."""
return parse_elicit_response_type(
self.elicit_type(request),
self.type_for(request),
response_title=request.title,
response_description=request.description,
)
@ -323,7 +324,7 @@ def find_elicit_parameters(fn: Callable[..., Any]) -> dict[str, ElicitParam]:
response_type=_response_type(annotation),
has_default=has_default,
default=parameter.default if has_default else None,
depends_on=_question_parameters(marker, name, fn),
depends_on=_message_parameters(marker, name, fn),
)
if not found:
@ -343,7 +344,7 @@ def find_elicit_parameters(fn: Callable[..., Any]) -> dict[str, ElicitParam]:
return _in_resolution_order(found, _fn_name(fn))
def _declared_elicit_type(fn: Callable[..., Any]) -> Any | None:
def _declared_response_type(fn: Callable[..., Any]) -> Any | None:
"""The `T` a resolver declares in an `Elicit[T]` return arm, if it declares one.
A resolver annotated `-> Airport | Elicit[Airport]` states the type it asks
@ -379,9 +380,9 @@ def _check_declared_type(spec: ElicitParam, fn_name: str) -> None:
Raises:
TypeError: If the two types disagree.
"""
if isinstance(spec.marker.question, str):
if isinstance(spec.marker.message, str):
return
declared = _declared_elicit_type(spec.marker.question)
declared = _declared_response_type(spec.marker.message)
if declared is None or declared == spec.response_type:
return
raise TypeError(
@ -391,7 +392,7 @@ def _check_declared_type(spec: ElicitParam, fn_name: str) -> None:
)
def _question_parameters(
def _message_parameters(
marker: Elicit, name: str, fn: Callable[..., Any]
) -> tuple[str, ...]:
"""Names a resolver needs filled by name; empty for a literal question.
@ -400,16 +401,16 @@ def _question_parameters(
by the DI engine when the resolver runs, not matched against the call's
arguments.
"""
if isinstance(marker.question, str):
if isinstance(marker.message, str):
return ()
try:
question_signature = inspect.signature(marker.question)
question_signature = inspect.signature(marker.message)
except (TypeError, ValueError) as e:
raise TypeError(
f"The question for parameter {name!r} of {_fn_name(fn)!r} is a callable "
"whose signature could not be read"
) from e
injected = get_dependency_parameters(marker.question)
injected = get_dependency_parameters(marker.message)
return tuple(p for p in question_signature.parameters if p not in injected)
@ -573,8 +574,8 @@ async def _resolve_in_process(
resolved[spec.name] = request
continue
outcome = await context.elicit(
_question_text(request, spec),
response_type=spec.elicit_type(request),
_message_text(request, spec),
response_type=spec.type_for(request),
response_title=request.title,
response_description=request.description,
)
@ -591,15 +592,15 @@ async def _resolve_in_process(
return resolved
def _question_text(request: Elicit[Any], spec: ElicitParam) -> str:
def _message_text(request: Elicit[Any], spec: ElicitParam) -> str:
"""The text an `Elicit` shows the user.
Raises:
ToolError: If a resolver built an `Elicit` around another callable, which
has no meaning a resolver has already decided what to ask.
"""
if isinstance(request.question, str):
return request.question
if isinstance(request.message, str):
return request.message
raise ToolError(
f"The resolver for {spec.name!r} returned an Elicit wrapping a callable; "
"return Elicit(<the question text>) instead"
@ -640,7 +641,7 @@ async def _resolve_across_rounds(
continue
config = spec.config(decision)
request = _build_request(_question_text(decision, spec), config)
request = _build_request(_message_text(decision, spec), config)
question = _digest(request)
answer = _recall(state, spec.name, question)

View file

@ -183,7 +183,7 @@ class TestModernProtocol:
mcp.add_middleware(recorder)
def which_airport(destination: str) -> Elicit[str]:
return Elicit(f"Which airport in {destination}?", elicit_type=str)
return Elicit(f"Which airport in {destination}?", response_type=str)
@mcp.tool
async def book(
@ -204,7 +204,7 @@ class TestModernProtocol:
mcp = FastMCP("x")
def which_airport(destination: str) -> Elicit[str]:
return Elicit(f"Which airport in {destination}?", elicit_type=str)
return Elicit(f"Which airport in {destination}?", response_type=str)
@mcp.tool
async def book(
@ -227,7 +227,7 @@ class TestModernProtocol:
mcp = FastMCP("x")
def which_airport(destination: str) -> Elicit[str]:
return Elicit(f"Which airport in {destination}?", elicit_type=str)
return Elicit(f"Which airport in {destination}?", response_type=str)
@mcp.tool
async def book(
@ -248,7 +248,7 @@ class TestModernProtocol:
mcp = FastMCP("x")
def follow_up(first: str) -> Elicit[str]:
return Elicit(f"After {first}, then?", elicit_type=str)
return Elicit(f"After {first}, then?", response_type=str)
@mcp.tool
async def chain(
@ -328,7 +328,7 @@ class TestHandshakeProtocol:
mcp = FastMCP("x")
def which_airport(destination: str) -> Elicit[str]:
return Elicit(f"Which airport in {destination}?", elicit_type=str)
return Elicit(f"Which airport in {destination}?", response_type=str)
@mcp.tool
async def book(
@ -438,7 +438,7 @@ class TestConditionalResolvers:
def which_airport(destination: str) -> str | Elicit[str]:
if destination == "London":
return "LHR" # only one option — no question
return Elicit(f"Which airport in {destination}?", elicit_type=str)
return Elicit(f"Which airport in {destination}?", response_type=str)
@mcp.tool
async def book(
@ -462,7 +462,7 @@ class TestConditionalResolvers:
def which_airport(destination: str) -> str | Elicit[str]:
if destination == "London":
return "LHR"
return Elicit(f"Which airport in {destination}?", elicit_type=str)
return Elicit(f"Which airport in {destination}?", response_type=str)
@mcp.tool
async def book(
@ -486,7 +486,7 @@ class TestConditionalResolvers:
def which_airport(destination: str) -> str | Elicit[str]:
if known:
return known[0] # learned between rounds
return Elicit(f"Which airport in {destination}?", elicit_type=str)
return Elicit(f"Which airport in {destination}?", response_type=str)
@mcp.tool
async def book(
@ -510,11 +510,11 @@ class TestConditionalResolvers:
# The client answered "CDG", but by the next round the resolver knew "LHR".
assert result.data == "Paris/LHR/2026-08-01"
async def test_explicit_elicit_type_wins_over_the_annotation(self):
async def test_explicit_response_type_wins_over_the_annotation(self):
mcp = FastMCP("x")
def pick(destination: str) -> Airport | Elicit[Airport]:
return Elicit(f"Which airport in {destination}?", elicit_type=Airport)
return Elicit(f"Which airport in {destination}?", response_type=Airport)
@mcp.tool
async def book(
@ -536,7 +536,7 @@ class TestConditionalResolvers:
mcp = FastMCP("x")
def pick(destination: str) -> Airport | Elicit[Airport]:
return Elicit("Which airport?", elicit_type=Airport)
return Elicit("Which airport?", response_type=Airport)
with pytest.raises(TypeError, match="declares it elicits"):
@ -548,6 +548,43 @@ class TestConditionalResolvers:
return airport
class TestAskedOnce:
"""An answer already given satisfies its question on later rounds."""
async def test_each_question_reaches_the_user_once(self):
"""Resolvers re-run every round, so without recall a three-round call
would put the first question six times."""
mcp = FastMCP("x")
def second(a: str) -> Elicit[str]:
return Elicit(f"second, given {a}?", response_type=str)
def third(b: str) -> Elicit[str]:
return Elicit(f"third, given {b}?", response_type=str)
@mcp.tool
async def chain(
a: Annotated[str, Elicit("first?")],
b: Annotated[str, Elicit(second)],
c: Annotated[str, Elicit(third)],
) -> str:
return f"{a}{b}{c}"
asked: list[str] = []
async def handler(message, response_type, params, ctx):
asked.append(message)
return ElicitResult(
action="accept", content=response_type(value=str(len(asked)))
)
async with Client(mcp, mode="auto", elicitation_handler=handler) as client:
result = await client.call_tool("chain", {})
assert result.data == "123"
assert asked == ["first?", "second, given 1?", "third, given 2?"]
class TestOrdering:
"""Where and when a question is asked both fall out of the annotations."""
@ -582,7 +619,7 @@ class TestOrdering:
def confirm(destination: str, date: str) -> Elicit[bool]:
return Elicit(
f"Book a flight to {destination} on {date}?", elicit_type=bool
f"Book a flight to {destination} on {date}?", response_type=bool
)
@mcp.tool