From 1a49190d0092f7d5e6e317fba42c4566bbf63f1b Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:38:23 -0400 Subject: [PATCH] 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. --- docs/servers/elicitation.mdx | 19 ++++-- .../fastmcp/server/_elicit_resolution.py | 63 ++++++++++--------- tests/server/test_elicit_resolution.py | 61 ++++++++++++++---- 3 files changed, 95 insertions(+), 48 deletions(-) diff --git a/docs/servers/elicitation.mdx b/docs/servers/elicitation.mdx index f88b65476..fe8a3390b 100644 --- a/docs/servers/elicitation.mdx +++ b/docs/servers/elicitation.mdx @@ -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 diff --git a/fastmcp_slim/fastmcp/server/_elicit_resolution.py b/fastmcp_slim/fastmcp/server/_elicit_resolution.py index ddb20e9fb..df9080d76 100644 --- a/fastmcp_slim/fastmcp/server/_elicit_resolution.py +++ b/fastmcp_slim/fastmcp/server/_elicit_resolution.py @@ -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() 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) diff --git a/tests/server/test_elicit_resolution.py b/tests/server/test_elicit_resolution.py index 7cf57e794..445a362e7 100644 --- a/tests/server/test_elicit_resolution.py +++ b/tests/server/test_elicit_resolution.py @@ -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