From 1f379b121880a531ec6d3659f05698feb5a3d3cf Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:20:38 -0400 Subject: [PATCH] Let a resolver decide whether to ask at all Elicit becomes generic and a resolver returns `T | Elicit[T]`: a value fills the parameter without asking, an Elicit performs the ask. elicit_type states the type where the annotation is out of view, and a resolver's declared Elicit[T] is checked against its parameter at registration. Also fixes 3.10, where get_type_hints still applies implicit-Optional, so a `= None` parameter arrived wrapped in a Union and the buried-marker guard misfired on the legitimate optional form. --- .../fastmcp/server/_elicit_resolution.py | 235 ++++++++++++++---- tests/server/test_elicit_resolution.py | 208 ++++++++++++++-- 2 files changed, 370 insertions(+), 73 deletions(-) diff --git a/fastmcp_slim/fastmcp/server/_elicit_resolution.py b/fastmcp_slim/fastmcp/server/_elicit_resolution.py index 46bee2202..ddb20e9fb 100644 --- a/fastmcp_slim/fastmcp/server/_elicit_resolution.py +++ b/fastmcp_slim/fastmcp/server/_elicit_resolution.py @@ -43,10 +43,19 @@ import hashlib import inspect import json import typing -from collections.abc import Awaitable, Callable, Mapping +from collections.abc import Callable, Mapping from dataclasses import dataclass from types import UnionType -from typing import TYPE_CHECKING, Annotated, Any, Literal, get_args, get_origin +from typing import ( + TYPE_CHECKING, + Annotated, + Any, + Generic, + Literal, + TypeVar, + get_args, + get_origin, +) import mcp_types from pydantic import BaseModel, ValidationError @@ -74,28 +83,55 @@ __all__ = [ logger = get_logger(__name__) +T = TypeVar("T") + #: Bumped when the shape of the `request_state` payload changes. A payload from #: another version is treated as "no progress yet" — during a rolling upgrade an #: in-flight call re-asks rather than misreading an older layout. _STATE_VERSION = 1 -class Elicit: - """Marker for `Annotated[T, Elicit(...)]`: fill this parameter by asking the client. +class Elicit(Generic[T]): + """A request for the user to supply a value. - The annotated type is the schema for the answer, so scalars, `Literal`s, - enums, and models all work exactly as they do with `ctx.elicit()`. + Used in two positions, meaning the same thing in both. + + As parameter metadata it says the parameter is filled by asking rather than + by the model, and the annotated type is the schema for the answer — scalars, + `Literal`s, enums, dataclasses, and models all behave as they do with + `ctx.elicit()`: + + ```python + destination: Annotated[str, Elicit("Where would you like to fly?")] + ``` + + Returned from a resolver it is the question that resolver decided to ask. + A resolver returns `T | Elicit[T]`, so returning a value instead skips the + question entirely: + + ```python + 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) + + + airport: Annotated[Airport, Elicit(which_airport)] + ``` A parameter with a default is optional: declining or cancelling leaves the default in place and the call proceeds. A parameter without one is required, and declining it fails the call. Args: - message: The question to show the user. Pass a callable (sync or - async) to build the question from values that are already known — its - parameters are filled by name from the tool's own arguments or from - other elicited parameters, which also orders the asks. A callable may - declare its own `Depends(...)` parameters, which resolve normally. + question: 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. 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. @@ -103,12 +139,14 @@ class Elicit: def __init__( self, - message: str | Callable[..., str | Awaitable[str]], + question: str | Callable[..., Any], *, + elicit_type: Any = None, title: str | None = None, description: str | None = None, ) -> None: - self.message = message + self.question = question + self.elicit_type = elicit_type self.title = title self.description = description @@ -144,42 +182,73 @@ class ElicitParam: #: elicited parameters, or both. Empty for a plain string question. depends_on: tuple[str, ...] - async def render(self, values: Mapping[str, Any]) -> str: - """Build the question text from already-known values. + async def resolve(self, values: Mapping[str, Any]) -> Any: + """Decide what this parameter needs: a value, or a question to ask. - A question callable may also declare its own `Depends(...)` parameters, - which resolve the ordinary way — that is how a question and the tool body - share one piece of derived data. + Returns an `Elicit` when the user has to be asked, and anything else as + the resolved value. A literal-question marker always returns itself; a + resolver decides, and may skip the ask by returning a value. + + A resolver may also declare its own `Depends(...)` parameters, which + resolve the ordinary way. """ - if isinstance(self.marker.message, str): - return self.marker.message + if isinstance(self.marker.question, str): + return self.marker bound = {name: values[name] for name in self.depends_on} - async with resolved_dependencies(self.marker.message, bound) as injected: + async with resolved_dependencies(self.marker.question, 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 - # question as a nonsense value. The common cause is a dependency + # resolver as a nonsense value. The common cause is a dependency # that wants one of the call's arguments by name, which the # engine cannot supply. if isinstance(value, FailedDependency): raise ToolError( - f"The question for {self.name!r} depends on {param_name!r}, " + f"The resolver for {self.name!r} depends on {param_name!r}, " "which could not be resolved" ) from value.error - rendered = self.marker.message(**bound, **injected) - return rendered if isinstance(rendered, str) else await rendered + outcome = self.marker.question(**bound, **injected) + return await outcome if inspect.isawaitable(outcome) else outcome - def config(self) -> ElicitConfig: - """Schema and response handling for this parameter's answer.""" + def elicit_type(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 + the parameter's own annotation otherwise. + """ + if request.elicit_type is not None: + return request.elicit_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.response_type, - response_title=self.marker.title, - response_description=self.marker.description, + self.elicit_type(request), + response_title=request.title, + response_description=request.description, ) +def _unwrap_optional(annotation: Any) -> Any: + """Strip a `None` arm wrapped around an `Annotated`. + + Python 3.10's `get_type_hints` still applies implicit-Optional, so a + parameter defaulting to `None` comes back as `Optional[Annotated[...]]` + rather than the `Annotated[...]` that 3.11+ reports. Both spellings mean the + same optional parameter, so both resolve to the inner annotation. + """ + if get_origin(annotation) not in (typing.Union, UnionType): + return annotation + arms = [arm for arm in get_args(annotation) if arm is not type(None)] + if len(arms) == 1 and get_origin(arms[0]) is Annotated: + return arms[0] + return annotation + + def _elicit_marker(annotation: Any) -> Elicit | None: """The `Elicit` marker in an `Annotated[...]`, if there is one.""" + annotation = _unwrap_optional(annotation) if get_origin(annotation) is not Annotated: return None return next((m for m in get_args(annotation)[1:] if isinstance(m, Elicit)), None) @@ -200,7 +269,7 @@ def _response_type(annotation: Any) -> Any: `None` is what a decline leaves behind. Any other metadata in the `Annotated` is preserved so `Field(...)` constraints still shape the schema. """ - type_arg = get_args(annotation)[0] + type_arg = get_args(_unwrap_optional(annotation))[0] if get_origin(type_arg) in (typing.Union, UnionType): arms = [a for a in get_args(type_arg) if a is not type(None)] if len(arms) == 1: @@ -262,6 +331,7 @@ def find_elicit_parameters(fn: Callable[..., Any]) -> dict[str, ElicitParam]: available = set(signature.parameters) for spec in found.values(): + _check_declared_type(spec, _fn_name(fn)) for dependency in spec.depends_on: if dependency not in available: raise TypeError( @@ -273,25 +343,73 @@ 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: + """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 + for at its own definition, which is the type the parameter must accept. + Returns `None` when the resolver says nothing usable — an unannotated + resolver, or a bare `Elicit` with no parameter. + """ + try: + hints = typing.get_type_hints(fn, include_extras=True) + except (NameError, TypeError): + return None + returns = hints.get("return") + if returns is None: + return None + arms = ( + get_args(returns) + if get_origin(returns) in (typing.Union, UnionType) + else (returns,) + ) + for arm in arms: + if get_origin(arm) is Elicit: + args = get_args(arm) + return args[0] if args else None + return None + + +def _check_declared_type(spec: ElicitParam, fn_name: str) -> None: + """Reject a resolver whose declared `Elicit[T]` contradicts its parameter. + + Both are visible at registration, so a disagreement is caught at import + rather than surfacing as a validation failure on the answer. + + Raises: + TypeError: If the two types disagree. + """ + if isinstance(spec.marker.question, str): + return + declared = _declared_elicit_type(spec.marker.question) + if declared is None or declared == spec.response_type: + return + raise TypeError( + f"The resolver for parameter {spec.name!r} of {fn_name!r} declares it " + f"elicits {declared!r}, but the parameter is annotated " + f"{spec.response_type!r}. Make the two agree." + ) + + def _question_parameters( marker: Elicit, name: str, fn: Callable[..., Any] ) -> tuple[str, ...]: - """Names a callable question needs filled by name; empty for a literal string. + """Names a resolver needs filled by name; empty for a literal question. - A question's own `Depends(...)` parameters are left out: those are resolved - by the DI engine when the question is rendered, not matched against the - call's arguments. + A resolver's own `Depends(...)` parameters are left out: those are resolved + by the DI engine when the resolver runs, not matched against the call's + arguments. """ - if isinstance(marker.message, str): + if isinstance(marker.question, str): return () try: - question_signature = inspect.signature(marker.message) + question_signature = inspect.signature(marker.question) 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.message) + injected = get_dependency_parameters(marker.question) return tuple(p for p in question_signature.parameters if p not in injected) @@ -449,12 +567,16 @@ async def _resolve_in_process( """Handshake-era path: ask over the back-channel while the call is open.""" resolved: dict[str, Any] = {} for spec in specs.values(): - message = await spec.render({**arguments, **resolved}) + request = await spec.resolve({**arguments, **resolved}) + if not isinstance(request, Elicit): + # The resolver already knew the answer, so nobody is asked. + resolved[spec.name] = request + continue outcome = await context.elicit( - message, - response_type=spec.response_type, - response_title=spec.marker.title, - response_description=spec.marker.description, + _question_text(request, spec), + response_type=spec.elicit_type(request), + response_title=request.title, + response_description=request.description, ) if outcome.action == "accept": resolved[spec.name] = outcome.data @@ -469,6 +591,21 @@ async def _resolve_in_process( return resolved +def _question_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 + raise ToolError( + f"The resolver for {spec.name!r} returned an Elicit wrapping a callable; " + "return Elicit() instead" + ) + + async def _resolve_across_rounds( specs: Mapping[str, ElicitParam], arguments: Mapping[str, Any], @@ -495,9 +632,15 @@ async def _resolve_across_rounds( waiting.add(spec.name) continue - message = await spec.render({**arguments, **resolved}) - config = spec.config() - request = _build_request(message, config) + decision = await spec.resolve({**arguments, **resolved}) + if not isinstance(decision, Elicit): + # The resolver already knew the answer, so nothing is asked and + # nothing is carried forward — it decides again next round. + resolved[spec.name] = decision + continue + + config = spec.config(decision) + request = _build_request(_question_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 affc1f7cd..7cf57e794 100644 --- a/tests/server/test_elicit_resolution.py +++ b/tests/server/test_elicit_resolution.py @@ -182,8 +182,8 @@ class TestModernProtocol: recorder = RecordAsks() mcp.add_middleware(recorder) - def which_airport(destination: str) -> str: - return f"Which airport in {destination}?" + def which_airport(destination: str) -> Elicit[str]: + return Elicit(f"Which airport in {destination}?", elicit_type=str) @mcp.tool async def book( @@ -203,8 +203,8 @@ class TestModernProtocol: asked: list[str] = [] mcp = FastMCP("x") - def which_airport(destination: str) -> str: - return f"Which airport in {destination}?" + def which_airport(destination: str) -> Elicit[str]: + return Elicit(f"Which airport in {destination}?", elicit_type=str) @mcp.tool async def book( @@ -226,8 +226,8 @@ class TestModernProtocol: asked: list[str] = [] mcp = FastMCP("x") - def which_airport(destination: str) -> str: - return f"Which airport in {destination}?" + def which_airport(destination: str) -> Elicit[str]: + return Elicit(f"Which airport in {destination}?", elicit_type=str) @mcp.tool async def book( @@ -247,8 +247,8 @@ class TestModernProtocol: """An answer from round one is still there after round two asks again.""" mcp = FastMCP("x") - def follow_up(first: str) -> str: - return f"After {first}, then?" + def follow_up(first: str) -> Elicit[str]: + return Elicit(f"After {first}, then?", elicit_type=str) @mcp.tool async def chain( @@ -327,8 +327,8 @@ class TestHandshakeProtocol: asked: list[str] = [] mcp = FastMCP("x") - def which_airport(destination: str) -> str: - return f"Which airport in {destination}?" + def which_airport(destination: str) -> Elicit[str]: + return Elicit(f"Which airport in {destination}?", elicit_type=str) @mcp.tool async def book( @@ -423,6 +423,131 @@ class TestInterop: assert result.data == "x:Paris!" +class Airport(BaseModel): + code: str + + +class TestConditionalResolvers: + """A resolver returns `T | Elicit[T]` — a value means nobody is asked.""" + + async def test_returning_a_value_asks_nothing(self): + mcp = FastMCP("x") + recorder = RecordAsks() + mcp.add_middleware(recorder) + + 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) + + @mcp.tool + async def book( + destination: str, + airport: Annotated[str, Elicit(which_airport)], + ) -> str: + return f"{destination}/{airport}" + + async def never(message, response_type, params, ctx): + raise AssertionError(f"should not have asked: {message}") + + async with Client(mcp, mode="auto", elicitation_handler=never) as client: + result = await client.call_tool("book", {"destination": "London"}) + + assert result.data == "London/LHR" + assert recorder.asks == 0 + + async def test_the_same_resolver_still_asks_when_it_must(self): + mcp = FastMCP("x") + + def which_airport(destination: str) -> str | Elicit[str]: + if destination == "London": + return "LHR" + return Elicit(f"Which airport in {destination}?", elicit_type=str) + + @mcp.tool + async def book( + destination: str, + airport: Annotated[str, Elicit(which_airport)], + ) -> str: + return f"{destination}/{airport}" + + handler = accept_by_message({"Which airport": "CDG"}) + async with Client(mcp, mode="auto", elicitation_handler=handler) as client: + result = await client.call_tool("book", {"destination": "Paris"}) + + assert result.data == "Paris/CDG" + + async def test_resolver_beats_a_stale_answer_on_a_later_round(self): + """The resolver re-runs every round, so a value it computes on round two + wins over whatever the client echoed back.""" + mcp = FastMCP("x") + known: list[str] = [] + + 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) + + @mcp.tool + async def book( + destination: str, + date: Annotated[str, Elicit("When?")], + airport: Annotated[str, Elicit(which_airport)], + ) -> str: + return f"{destination}/{airport}/{date}" + + async def handler(message, response_type, params, ctx): + if "Which airport" in message: + known.append("LHR") # the profile gains one mid-conversation + return ElicitResult(action="accept", content=response_type(value="CDG")) + return ElicitResult( + action="accept", content=response_type(value="2026-08-01") + ) + + async with Client(mcp, mode="auto", elicitation_handler=handler) as client: + result = await client.call_tool("book", {"destination": "Paris"}) + + # 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): + mcp = FastMCP("x") + + def pick(destination: str) -> Airport | Elicit[Airport]: + return Elicit(f"Which airport in {destination}?", elicit_type=Airport) + + @mcp.tool + async def book( + destination: str, + airport: Annotated[Airport, Elicit(pick)], + ) -> str: + return airport.code + + async with Client( + mcp, mode="auto", elicitation_handler=accept(code="CDG") + ) as client: + result = await client.call_tool("book", {"destination": "Paris"}) + + assert result.data == "CDG" + + def test_declared_type_must_match_the_parameter(self): + """Both types are visible at registration, so a disagreement is caught + at import rather than as a validation failure on the answer.""" + mcp = FastMCP("x") + + def pick(destination: str) -> Airport | Elicit[Airport]: + return Elicit("Which airport?", elicit_type=Airport) + + with pytest.raises(TypeError, match="declares it elicits"): + + @mcp.tool + async def book( + destination: str, + airport: Annotated[str, Elicit(pick)], + ) -> str: + return airport + + class TestOrdering: """Where and when a question is asked both fall out of the annotations.""" @@ -455,8 +580,10 @@ class TestOrdering: recorder = RecordAsks() mcp.add_middleware(recorder) - def confirm(destination: str, date: str) -> str: - return f"Book a flight to {destination} on {date}?" + def confirm(destination: str, date: str) -> Elicit[bool]: + return Elicit( + f"Book a flight to {destination} on {date}?", elicit_type=bool + ) @mcp.tool async def book( @@ -497,8 +624,8 @@ class TestQuestionDependencies: def house_prefix() -> str: return "[ACME]" - def styled(prefix: str = Depends(house_prefix)) -> str: - return f"{prefix} Window or aisle?" + def styled(prefix: str = Depends(house_prefix)) -> Elicit[str]: + return Elicit(f"{prefix} Window or aisle?") @mcp.tool async def seat(choice: Annotated[str, Elicit(styled)]) -> str: @@ -515,8 +642,8 @@ class TestQuestionDependencies: async def test_async_question(self): mcp = FastMCP("x") - async def ask_later(destination: str) -> str: - return f"Which airport in {destination}?" + async def ask_later(destination: str) -> Elicit[str]: + return Elicit(f"Which airport in {destination}?") @mcp.tool async def book( @@ -539,8 +666,8 @@ class TestQuestionDependencies: def needs_a_tool_argument(destination: str) -> str: return destination - def styled(place: str = Depends(needs_a_tool_argument)) -> str: - return f"Where in {place}?" + def styled(place: str = Depends(needs_a_tool_argument)) -> Elicit[str]: + return Elicit(f"Where in {place}?") @mcp.tool async def book( @@ -636,8 +763,8 @@ class TestRegistrationErrors: def test_question_asks_for_an_unknown_name(self): mcp = FastMCP("x") - def question(nonexistent: str) -> str: - return nonexistent + def question(nonexistent: str) -> Elicit[str]: + return Elicit(nonexistent) with pytest.raises(TypeError, match="not a parameter of the function"): @@ -650,11 +777,11 @@ class TestRegistrationErrors: def test_cyclic_questions(self): mcp = FastMCP("x") - def needs_b(b: str) -> str: - return b + def needs_b(b: str) -> Elicit[str]: + return Elicit(b) - def needs_a(a: str) -> str: - return a + def needs_a(a: str) -> Elicit[str]: + return Elicit(a) with pytest.raises(TypeError, match="form a cycle"): @@ -679,13 +806,40 @@ class TestRegistrationErrors: ) -> str | mcp_types.InputRequiredResult: return destination - def test_marker_buried_in_a_union(self): + def test_marker_buried_out_of_reach(self): + """A marker somewhere the framework cannot honour it fails loudly.""" mcp = FastMCP("x") with pytest.raises(TypeError, match="wraps Elicit"): @mcp.tool async def book( - destination: Annotated[str, Elicit("Where?")] | None = None, + destinations: list[Annotated[str, Elicit("Where?")]], ) -> str: - return destination or "" + return ",".join(destinations) + + @pytest.mark.parametrize( + "annotation", + [ + Annotated[str | None, Elicit("Window or aisle?")], + Annotated[str, Elicit("Window or aisle?")] | None, + ], + ids=["none-inside", "none-outside"], + ) + async def test_optional_spellings_are_equivalent(self, annotation): + """Python 3.10 applies implicit-Optional to a `= None` parameter, so the + two spellings are indistinguishable there and must behave alike.""" + mcp = FastMCP("x") + + @mcp.tool + async def book(seat: annotation = None) -> str: + return seat or "no preference" + + tool = await mcp.get_tool("book") + assert tool is not None + assert tool.parameters.get("properties", {}) == {} + + async with Client(mcp, mode="auto", elicitation_handler=refuse()) as client: + result = await client.call_tool("book", {}) + + assert result.data == "no preference"