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:
Jeremiah Lowin 2026-07-28 09:42:05 -04:00
commit e56b9ce2ff
No known key found for this signature in database
2 changed files with 100 additions and 3 deletions

View file

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

View file

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