mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-24 06:24:18 +02:00
Test declarative elicitation on both protocol eras
This commit is contained in:
parent
6ffe9bf981
commit
bcab7ac619
1 changed files with 622 additions and 0 deletions
622
tests/server/test_elicit_resolution.py
Normal file
622
tests/server/test_elicit_resolution.py
Normal file
|
|
@ -0,0 +1,622 @@
|
|||
"""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):
|
||||
"""Counts how many legs of a call resolved to a question."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.asks = 0
|
||||
|
||||
async def on_call_tool(self, context, call_next):
|
||||
result = await call_next(context)
|
||||
if isinstance(result, InputRequiredToolResult):
|
||||
self.asks += 1
|
||||
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) -> str:
|
||||
return f"Which airport in {destination}?"
|
||||
|
||||
@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) -> str:
|
||||
return f"Which airport in {destination}?"
|
||||
|
||||
@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) -> str:
|
||||
return f"Which airport in {destination}?"
|
||||
|
||||
@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) -> str:
|
||||
return f"After {first}, then?"
|
||||
|
||||
@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) -> str:
|
||||
return f"Which airport in {destination}?"
|
||||
|
||||
@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 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)) -> str:
|
||||
return 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) -> str:
|
||||
return 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)) -> str:
|
||||
return 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) -> str:
|
||||
return 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) -> str:
|
||||
return b
|
||||
|
||||
def needs_a(a: str) -> str:
|
||||
return 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_in_a_union(self):
|
||||
mcp = FastMCP("x")
|
||||
|
||||
with pytest.raises(TypeError, match="wraps Elicit"):
|
||||
|
||||
@mcp.tool
|
||||
async def book(
|
||||
destination: Annotated[str, Elicit("Where?")] | None = None,
|
||||
) -> str:
|
||||
return destination or ""
|
||||
Loading…
Add table
Add a link
Reference in a new issue