fastmcp/tests/server/test_elicit_resolution.py
Jeremiah Lowin ecc05fa504
Hand back the question when the client cannot answer it
No elicitation handler and a tool that asks now yields the question on
CallToolResult.input_required instead of an 'Elicitation not supported'
error. A handler still answers inline where one is registered, and legacy
still errors, because there the server is blocked mid-call and there is no
result to hand back. No opt-in flag: not passing a handler already says it.
2026-07-28 17:09:57 -04:00

998 lines
34 KiB
Python

"""Declarative elicitation: `Annotated[T, Elicit(...)]` parameters.
A parameter annotated this way is filled by asking the client rather than by
the model, and is hidden from the tool's input schema. The same annotated
function has to work on both protocol eras — batched into an
``InputRequiredResult`` on 2026-07-28 (where there is no back-channel), asked
in-process with ``ctx.elicit()`` on 2025-11-25 and earlier — because choosing
the transport is the whole reason for the declarative form.
The engine lives in the private ``fastmcp.server._elicit_resolution`` module,
which is expected to move into ``uncalled-for``; these tests exercise it
through the public ``fastmcp.elicitation.Elicit`` surface so the move stays
invisible.
"""
from typing import Annotated, Literal
import mcp_types
import pytest
from pydantic import BaseModel
from fastmcp import Client, Context, FastMCP
from fastmcp.client.elicitation import ElicitResult
from fastmcp.dependencies import Depends
from fastmcp.elicitation import Elicit
from fastmcp.exceptions import ToolError
from fastmcp.server._elicit_resolution import (
NeedsInput,
find_elicit_parameters,
resolve_elicitations,
)
from fastmcp.server.middleware.middleware import Middleware
from fastmcp.tools.base import InputRequiredToolResult
class RecordAsks(Middleware):
"""Records the questions asked on each leg of a call."""
def __init__(self) -> None:
self.rounds: list[list[str]] = []
@property
def asks(self) -> int:
return len(self.rounds)
async def on_call_tool(self, context, call_next):
result = await call_next(context)
if isinstance(result, InputRequiredToolResult):
self.rounds.append(list(result.input_required.input_requests))
return result
def accept(**fields):
"""An elicitation handler that accepts every question with fixed fields."""
async def handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content=response_type(**fields))
return handler
def accept_by_message(answers: dict[str, object], asked: list[str] | None = None):
"""Answer each question with the value whose key appears in the message."""
async def handler(message, response_type, params, ctx):
if asked is not None:
asked.append(message)
for marker, value in answers.items():
if marker in message:
return ElicitResult(action="accept", content=response_type(value=value))
raise AssertionError(f"unexpected question: {message}")
return handler
def refuse(action: Literal["decline", "cancel"] = "decline"):
async def handler(message, response_type, params, ctx):
return ElicitResult(action=action)
return handler
class TestSchema:
"""An elicited parameter is not something the model supplies."""
async def test_elicited_parameter_is_hidden(self):
mcp = FastMCP("x")
@mcp.tool
async def book(
seats: int,
destination: Annotated[str, Elicit("Where to?")],
) -> str:
return f"{destination} x{seats}"
tool = await mcp.get_tool("book")
assert tool is not None
assert list(tool.parameters["properties"]) == ["seats"]
assert tool.parameters["required"] == ["seats"]
async def test_optional_elicited_parameter_is_hidden(self):
"""A default makes the ask optional, not the parameter model-supplied."""
mcp = FastMCP("x")
@mcp.tool
async def book(
seat: Annotated[str | None, Elicit("Window or aisle?")] = None,
) -> str:
return seat or "none"
tool = await mcp.get_tool("book")
assert tool is not None
assert tool.parameters.get("properties", {}) == {}
class TestModernProtocol:
"""2026-07-28: no back-channel, so asks ride `InputRequiredResult`."""
async def test_single_question_completes(self):
mcp = FastMCP("x")
@mcp.tool
async def book(
destination: Annotated[str, Elicit("Where would you like to fly?")],
) -> str:
return f"Booked {destination}"
async with Client(
mcp, mode="auto", elicitation_handler=accept(value="Paris")
) as client:
assert client.protocol_version == "2026-07-28"
result = await client.call_tool("book", {})
assert result.data == "Booked Paris"
async def test_body_does_not_run_until_answered(self):
"""The first leg resolves to the question, not to a partial execution."""
runs: list[str] = []
mcp = FastMCP("x")
@mcp.tool
async def book(
destination: Annotated[str, Elicit("Where to?")],
) -> str:
runs.append(destination)
return destination
async with Client(
mcp, mode="auto", elicitation_handler=accept(value="Paris")
) as client:
await client.call_tool("book", {})
assert runs == ["Paris"]
async def test_independent_questions_share_one_round(self):
"""Two asks that do not depend on each other go out together.
This is the behavioural gain over a hand-written guard, which asks in
whatever order the author wrote and pays a round trip for each.
"""
mcp = FastMCP("x")
recorder = RecordAsks()
mcp.add_middleware(recorder)
@mcp.tool
async def book(
destination: Annotated[str, Elicit("Where to?")],
date: Annotated[str, Elicit("When?")],
) -> str:
return f"{destination} on {date}"
handler = accept_by_message({"Where": "Paris", "When": "2026-08-01"})
async with Client(mcp, mode="auto", elicitation_handler=handler) as client:
result = await client.call_tool("book", {})
assert result.data == "Paris on 2026-08-01"
assert recorder.asks == 1
async def test_dependent_questions_take_a_round_each(self):
"""A question that quotes an unanswered one has to wait for it."""
mcp = FastMCP("x")
recorder = RecordAsks()
mcp.add_middleware(recorder)
def which_airport(destination: str) -> Elicit[str]:
return Elicit(f"Which airport in {destination}?", response_type=str)
@mcp.tool
async def book(
destination: Annotated[str, Elicit("Where to?")],
airport: Annotated[str, Elicit(which_airport)],
) -> str:
return f"{destination}/{airport}"
handler = accept_by_message({"Where": "Paris", "Which airport": "CDG"})
async with Client(mcp, mode="auto", elicitation_handler=handler) as client:
result = await client.call_tool("book", {})
assert result.data == "Paris/CDG"
assert recorder.asks == 2
async def test_dependent_question_quotes_the_earlier_answer(self):
asked: list[str] = []
mcp = FastMCP("x")
def which_airport(destination: str) -> Elicit[str]:
return Elicit(f"Which airport in {destination}?", response_type=str)
@mcp.tool
async def book(
destination: Annotated[str, Elicit("Where to?")],
airport: Annotated[str, Elicit(which_airport)],
) -> str:
return f"{destination}/{airport}"
handler = accept_by_message(
{"Where": "Paris", "Which airport": "CDG"}, asked=asked
)
async with Client(mcp, mode="auto", elicitation_handler=handler) as client:
result = await client.call_tool("book", {})
assert asked == ["Where to?", "Which airport in Paris?"]
assert result.data == "Paris/CDG"
async def test_question_built_from_a_tool_argument(self):
asked: list[str] = []
mcp = FastMCP("x")
def which_airport(destination: str) -> Elicit[str]:
return Elicit(f"Which airport in {destination}?", response_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": "ORY"}, asked=asked)
async with Client(mcp, mode="auto", elicitation_handler=handler) as client:
result = await client.call_tool("book", {"destination": "Paris"})
assert asked == ["Which airport in Paris?"]
assert result.data == "Paris/ORY"
async def test_earlier_answers_survive_later_rounds(self):
"""An answer from round one is still there after round two asks again."""
mcp = FastMCP("x")
def follow_up(first: str) -> Elicit[str]:
return Elicit(f"After {first}, then?", response_type=str)
@mcp.tool
async def chain(
first: Annotated[str, Elicit("First?")],
second: Annotated[str, Elicit(follow_up)],
) -> str:
return f"{first}->{second}"
handler = accept_by_message({"First": "a", "After a": "b"})
async with Client(mcp, mode="auto", elicitation_handler=handler) as client:
result = await client.call_tool("chain", {})
assert result.data == "a->b"
class TestDeclining:
"""A default marks the ask optional; without one, a decline stops the call."""
@pytest.mark.parametrize("action", ["decline", "cancel"])
async def test_optional_falls_back_to_the_default(
self, action: Literal["decline", "cancel"]
):
mcp = FastMCP("x")
@mcp.tool
async def book(
seat: Annotated[str | None, Elicit("Window or aisle?")] = None,
) -> str:
return seat or "no preference"
async with Client(
mcp, mode="auto", elicitation_handler=refuse(action)
) as client:
result = await client.call_tool("book", {})
assert result.data == "no preference"
@pytest.mark.parametrize("action", ["decline", "cancel"])
async def test_required_fails_the_call(self, action: Literal["decline", "cancel"]):
mcp = FastMCP("x")
@mcp.tool
async def book(
destination: Annotated[str, Elicit("Where to?")],
) -> str:
return destination
async with Client(
mcp, mode="auto", elicitation_handler=refuse(action)
) as client:
with pytest.raises(ToolError, match="Cannot continue without"):
await client.call_tool("book", {})
class TestHandshakeProtocol:
"""<= 2025-11-25: the back-channel exists, so asks happen in-process."""
async def test_same_tool_works_unchanged(self):
mcp = FastMCP("x")
@mcp.tool
async def book(
destination: Annotated[str, Elicit("Where would you like to fly?")],
) -> str:
return f"Booked {destination}"
async with Client(
mcp, mode="legacy", elicitation_handler=accept(value="Paris")
) as client:
assert client.protocol_version != "2026-07-28"
result = await client.call_tool("book", {})
assert result.data == "Booked Paris"
async def test_dependent_questions_still_ordered(self):
asked: list[str] = []
mcp = FastMCP("x")
def which_airport(destination: str) -> Elicit[str]:
return Elicit(f"Which airport in {destination}?", response_type=str)
@mcp.tool
async def book(
destination: Annotated[str, Elicit("Where to?")],
airport: Annotated[str, Elicit(which_airport)],
) -> str:
return f"{destination}/{airport}"
handler = accept_by_message(
{"Where": "Paris", "Which airport": "CDG"}, asked=asked
)
async with Client(mcp, mode="legacy", elicitation_handler=handler) as client:
result = await client.call_tool("book", {})
assert asked == ["Where to?", "Which airport in Paris?"]
assert result.data == "Paris/CDG"
async def test_optional_falls_back_to_the_default(self):
mcp = FastMCP("x")
@mcp.tool
async def book(
seat: Annotated[str | None, Elicit("Window or aisle?")] = None,
) -> str:
return seat or "no preference"
async with Client(mcp, mode="legacy", elicitation_handler=refuse()) as client:
result = await client.call_tool("book", {})
assert result.data == "no preference"
class TestResponseTypes:
"""The annotated type is the schema, matching `ctx.elicit()`'s ergonomics."""
async def test_model(self):
class Airport(BaseModel):
code: str
mcp = FastMCP("x")
@mcp.tool
async def book(
airport: Annotated[Airport, Elicit("Which airport?")],
) -> str:
return airport.code
async with Client(
mcp, mode="auto", elicitation_handler=accept(code="CDG")
) as client:
result = await client.call_tool("book", {})
assert result.data == "CDG"
async def test_scalar_int(self):
mcp = FastMCP("x")
@mcp.tool
async def book(seats: Annotated[int, Elicit("How many seats?")]) -> int:
return seats * 2
async with Client(
mcp, mode="auto", elicitation_handler=accept(value=3)
) as client:
result = await client.call_tool("book", {})
assert result.data == 6
class TestInterop:
"""Elicited parameters sit alongside the other injected kinds."""
async def test_with_context_and_depends(self):
mcp = FastMCP("x")
def house_style() -> str:
return "!"
@mcp.tool
async def book(
ctx: Context,
destination: Annotated[str, Elicit("Where to?")],
style: str = Depends(house_style),
) -> str:
return f"{ctx.fastmcp.name}:{destination}{style}"
async with Client(
mcp, mode="auto", elicitation_handler=accept(value="Paris")
) as client:
result = await client.call_tool("book", {})
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}?", response_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}?", response_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}?", response_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_response_type_wins_over_the_annotation(self):
mcp = FastMCP("x")
def pick(destination: str) -> Airport | Elicit[Airport]:
return Elicit(f"Which airport in {destination}?", response_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?", response_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
def accepted(**values) -> mcp_types.InputResponses:
"""The `input_responses` map for one leg: an accepted answer per key."""
return {
key: mcp_types.ElicitResult(action="accept", content={"value": value})
for key, value in values.items()
}
def questions(result) -> dict[str, str]:
"""The message shown for each key on one leg of a call."""
leg = result.input_required
assert leg is not None, "expected an ask, got a terminal result"
assert leg.input_requests is not None
asked: dict[str, str] = {}
for key, request in leg.input_requests.items():
assert isinstance(request, mcp_types.ElicitRequest)
assert request.params is not None
asked[key] = request.params.message
return asked
def carried(result) -> str | None:
"""The opaque state to hand back on the next leg."""
assert result.input_required is not None
return result.input_required.request_state
class TestDrivingLegsByHand:
"""`` hands back each leg instead of resolving it,
so a test can assert on the wire shape a client would actually receive."""
async def test_each_leg_is_visible(self):
mcp = FastMCP("x")
def which_airport(destination: str) -> Elicit[str]:
return Elicit(f"Which airport in {destination}?", response_type=str)
@mcp.tool
async def book(
destination: Annotated[str, Elicit("Where would you like to fly?")],
airport: Annotated[str, Elicit(which_airport)],
) -> str:
return f"Booked {destination}/{airport}"
# No elicitation_handler — nothing drives the exchange but this test.
async with Client(mcp) as client:
first = await client.call_tool("book")
assert questions(first) == {"destination": "Where would you like to fly?"}
second = await client.call_tool(
"book",
input_responses=accepted(destination="Paris"),
request_state=carried(first),
)
# Only the airport — the destination is not asked again.
assert questions(second) == {"airport": "Which airport in Paris?"}
final = await client.call_tool(
"book",
input_responses=accepted(airport="CDG"),
request_state=carried(second),
)
assert final.input_required is None
assert final.data == "Booked Paris/CDG"
async def test_independent_questions_arrive_in_one_leg(self):
mcp = FastMCP("x")
@mcp.tool
async def book(
destination: Annotated[str, Elicit("Where to?")],
date: Annotated[str, Elicit("When?")],
) -> str:
return f"{destination} on {date}"
async with Client(mcp) as client:
first = await client.call_tool("book")
assert questions(first) == {
"destination": "Where to?",
"date": "When?",
}
final = await client.call_tool(
"book",
input_responses=accepted(destination="Paris", date="2026-08-01"),
request_state=carried(first),
)
assert final.data == "Paris on 2026-08-01"
async def test_a_resolver_that_knows_asks_nothing(self):
mcp = FastMCP("x")
def which_airport(destination: str) -> str | Elicit[str]:
return (
"LHR"
if destination == "London"
else Elicit("Which?", response_type=str)
)
@mcp.tool
async def book(
destination: str,
airport: Annotated[str, Elicit(which_airport)],
) -> str:
return f"{destination}/{airport}"
async with Client(mcp) as client:
result = await client.call_tool("book", {"destination": "London"})
# Terminal on the first leg — there was never anything to ask.
assert result.input_required is None
assert result.data == "London/LHR"
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."""
async def test_independent_questions_keep_signature_order(self):
"""Signature order is the lever for presentation order — there is no other."""
mcp = FastMCP("x")
recorder = RecordAsks()
mcp.add_middleware(recorder)
@mcp.tool
async def book(
destination: Annotated[str, Elicit("Where?")],
date: Annotated[str, Elicit("When?")],
seat: Annotated[str, Elicit("Window or aisle?")],
) -> str:
return f"{destination}/{date}/{seat}"
handler = accept_by_message(
{"Where": "Paris", "When": "2026-08-01", "Window": "window"}
)
async with Client(mcp, mode="auto", elicitation_handler=handler) as client:
await client.call_tool("book", {})
assert recorder.rounds == [["destination", "date", "seat"]]
async def test_confirmation_quoting_details_waits_for_them(self):
"""A confirmation that names what it confirms is ordered by saying so,
rather than by a parameter added to hold it back."""
mcp = FastMCP("x")
recorder = RecordAsks()
mcp.add_middleware(recorder)
def confirm(destination: str, date: str) -> Elicit[bool]:
return Elicit(
f"Book a flight to {destination} on {date}?", response_type=bool
)
@mcp.tool
async def book(
destination: Annotated[str, Elicit("Where?")],
date: Annotated[str, Elicit("When?")],
proceed: Annotated[bool, Elicit(confirm)],
) -> str:
return f"Booked {destination}" if proceed else "Cancelled"
asked: list[str] = []
async def handler(message, response_type, params, ctx):
asked.append(message)
if "Where" in message:
return ElicitResult(
action="accept", content=response_type(value="Paris")
)
if "When" in message:
return ElicitResult(
action="accept", content=response_type(value="2026-08-01")
)
return ElicitResult(action="accept", content=response_type(value=True))
async with Client(mcp, mode="auto", elicitation_handler=handler) as client:
result = await client.call_tool("book", {})
assert recorder.rounds == [["destination", "date"], ["proceed"]]
assert asked[-1] == "Book a flight to Paris on 2026-08-01?"
assert result.data == "Booked Paris"
class TestQuestionDependencies:
"""A question is an ordinary function: it can declare its own dependencies."""
async def test_question_resolves_its_own_depends(self):
mcp = FastMCP("x")
def house_prefix() -> str:
return "[ACME]"
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:
return choice
asked: list[str] = []
handler = accept_by_message({"Window or aisle": "window"}, asked=asked)
async with Client(mcp, mode="auto", elicitation_handler=handler) as client:
result = await client.call_tool("seat", {})
assert asked == ["[ACME] Window or aisle?"]
assert result.data == "window"
async def test_async_question(self):
mcp = FastMCP("x")
async def ask_later(destination: str) -> Elicit[str]:
return Elicit(f"Which airport in {destination}?")
@mcp.tool
async def book(
destination: str,
airport: Annotated[str, Elicit(ask_later)],
) -> str:
return 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 == "CDG"
async def test_unresolvable_question_dependency_names_itself(self):
"""The DI engine reports a failed dependency rather than raising, so the
sentinel has to be caught before it reaches the question as a value."""
mcp = FastMCP("x")
def needs_a_tool_argument(destination: str) -> str:
return destination
def styled(place: str = Depends(needs_a_tool_argument)) -> Elicit[str]:
return Elicit(f"Where in {place}?")
@mcp.tool
async def book(
destination: str,
airport: Annotated[str, Elicit(styled)],
) -> str:
return airport
async with Client(
mcp, mode="auto", elicitation_handler=accept(value="CDG")
) as client:
with pytest.raises(ToolError, match="depends on 'place'"):
await client.call_tool("book", {"destination": "Paris"})
class _StubContext:
"""The three things resolution reads off a live context."""
def __init__(
self,
*,
modern: bool = True,
request_state: str | None = None,
input_responses: dict | None = None,
) -> None:
self.request_state = request_state
self.input_responses = input_responses
self._modern = modern
def _is_modern_protocol(self) -> bool:
return self._modern
async def _ask_once(specs, arguments, context):
"""Run one round, returning either the values or the raised question."""
try:
return await resolve_elicitations(specs, arguments, context), None
except NeedsInput as needs_input:
return None, needs_input
class TestQuestionDigest:
"""An answer only counts for the exact question it was shown against."""
def _specs(self, question):
def book(seat: Annotated[str, Elicit(question)]) -> str:
return seat
return find_elicit_parameters(book)
async def test_answer_to_a_changed_question_is_re_asked(self):
"""A redeploy that rewords a question must not reuse the old answer."""
first = self._specs("Window or aisle?")
_, asked = await _ask_once(first, {}, _StubContext())
assert asked is not None
reply = {
"seat": mcp_types.ElicitResult(action="accept", content={"value": "W"})
}
# Same wording: the reply is accepted.
same = await _ask_once(
first,
{},
_StubContext(request_state=asked.request_state, input_responses=reply),
)
assert same[0] == {"seat": "W"}
# Reworded: the reply is dropped and the new question goes out instead.
reworded = self._specs("Which seat would you prefer?")
values, again = await _ask_once(
reworded,
{},
_StubContext(request_state=asked.request_state, input_responses=reply),
)
assert values is None
assert again is not None
assert "seat" in again.input_requests
async def test_unreadable_state_is_treated_as_no_progress(self):
"""Drift inside a fleet re-asks rather than misreading an older layout."""
specs = self._specs("Window or aisle?")
values, asked = await _ask_once(
specs, {}, _StubContext(request_state='{"v":999,"answers":{}}')
)
assert values is None
assert asked is not None
class TestRegistrationErrors:
"""Signature mistakes fail at registration, not on the first call."""
def test_question_asks_for_an_unknown_name(self):
mcp = FastMCP("x")
def question(nonexistent: str) -> Elicit[str]:
return Elicit(nonexistent)
with pytest.raises(TypeError, match="not a parameter of the function"):
@mcp.tool
async def book(
airport: Annotated[str, Elicit(question)],
) -> str:
return airport
def test_cyclic_questions(self):
mcp = FastMCP("x")
def needs_b(b: str) -> Elicit[str]:
return Elicit(b)
def needs_a(a: str) -> Elicit[str]:
return Elicit(a)
with pytest.raises(TypeError, match="form a cycle"):
@mcp.tool
async def book(
a: Annotated[str, Elicit(needs_b)],
b: Annotated[str, Elicit(needs_a)],
) -> str:
return a + b
def test_mixing_with_a_hand_returned_ask(self):
"""One call, one input channel — the two ways of asking cannot share it."""
import mcp_types
mcp = FastMCP("x")
with pytest.raises(TypeError, match="one channel for gathering input"):
@mcp.tool
async def book(
destination: Annotated[str, Elicit("Where to?")],
) -> str | mcp_types.InputRequiredResult:
return destination
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(
destinations: list[Annotated[str, Elicit("Where?")]],
) -> str:
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"