mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-20 20:44:17 +02:00
Document how question ordering falls out of the questions
There is no ordering knob. A question that must wait almost always has something to say about what it waits for — a confirmation quoting the details being the clearest case — so writing it that way fixes the wording and the timing together.
This commit is contained in:
parent
79d9f58f2c
commit
e56b9ce2ff
2 changed files with 100 additions and 3 deletions
|
|
@ -95,6 +95,34 @@ So this tool takes two rounds, and neither the round count nor the ordering appe
|
|||
|
||||
Because a question can quote a tool argument, it can also quote something the model supplied — which is exactly what you want for `f"Which airport in {destination}?"`, and a good reason to treat the wording as untrusted display text rather than as an instruction to the user.
|
||||
|
||||
There is no separate knob for ordering, and you rarely want one. A question that has to wait for another almost always has something to say about it, so saying it is both the better question and the thing that orders the asks. Confirmations show this most clearly. `Elicit("Book it?")` refers to nothing, so it goes out in the very first round, asking someone to approve a booking that nobody has described yet. Written as a function it quotes the details, which fixes the wording and the timing at once:
|
||||
|
||||
```python
|
||||
from typing import Annotated
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.elicitation import Elicit
|
||||
|
||||
mcp = FastMCP("Booking Server")
|
||||
|
||||
|
||||
def confirm(destination: str, date: str) -> str:
|
||||
return f"Book a flight to {destination} on {date}?"
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def book_flight(
|
||||
destination: Annotated[str, Elicit("Where would you like to fly?")],
|
||||
date: Annotated[str, Elicit("When would you like to fly?")],
|
||||
proceed: Annotated[bool, Elicit(confirm)],
|
||||
) -> str:
|
||||
return f"Booked {destination}" if proceed else "Cancelled"
|
||||
```
|
||||
|
||||
Where and when both fall out of the same annotations. The destination and date are independent, so they go out together in the first round; the confirmation quotes both, so it waits for the second. Questions that stay independent are asked in the order they appear in the signature, so moving a parameter down moves its question down in what the user sees — though it is the client that ultimately decides how to present a round.
|
||||
|
||||
If you ever find yourself adding a parameter to a question function purely to hold it back, treat that as a sign the question is underspecified rather than a technique. It works, and it costs a round trip to ask something you could have asked earlier.
|
||||
|
||||
A question may also declare its own [dependencies](/servers/dependency-injection) with `Depends(...)`, for the configuration and connections it needs to render itself. Those resolve the ordinary way and are not matched against the call's arguments.
|
||||
|
||||
### Optional questions
|
||||
|
|
|
|||
|
|
@ -34,15 +34,19 @@ from fastmcp.tools.base import InputRequiredToolResult
|
|||
|
||||
|
||||
class RecordAsks(Middleware):
|
||||
"""Counts how many legs of a call resolved to a question."""
|
||||
"""Records the questions asked on each leg of a call."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.asks = 0
|
||||
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.asks += 1
|
||||
self.rounds.append(list(result.input_required.input_requests))
|
||||
return result
|
||||
|
||||
|
||||
|
|
@ -419,6 +423,71 @@ class TestInterop:
|
|||
assert result.data == "x:Paris!"
|
||||
|
||||
|
||||
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) -> str:
|
||||
return f"Book a flight to {destination} on {date}?"
|
||||
|
||||
@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."""
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue