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.
This commit is contained in:
Jeremiah Lowin 2026-07-28 16:20:38 -04:00
commit 1f379b1218
No known key found for this signature in database
2 changed files with 370 additions and 73 deletions

View file

@ -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(<the question text>) 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)

View file

@ -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"