Skip to content

Commit 85756a7

Browse files
committed
Reconcile docs with the negotiated elicitation transport
The Dependencies tutorial explains that the framework picks the question's transport from the negotiated protocol version, scopes the run-once claim to what is guaranteed (each question asked once; a non-eliciting resolver may re-run when a call resumes), and documents the determinism constraint on questions. Multi-round-trip requests no longer claims @mcp.tool() has no high-level path to InputRequiredResult - resolver dependencies are that path. The eliciting tutorial snippets are tested on both transports.
1 parent 1821703 commit 85756a7

3 files changed

Lines changed: 33 additions & 9 deletions

File tree

docs/advanced/multi-round-trip.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ That's the whole protocol. Every leg is an ordinary request from the client to t
1919

2020
## The server side
2121

22-
The high-level `@mcp.tool()` decorator has no sugar for this yet. Today you write it on the **low-level** `Server`, whose `on_call_tool` handler is allowed to return either result type:
22+
On `@mcp.tool()` you rarely build this by hand: declare a dependency that asks the user and the SDK returns the `InputRequiredResult` for you - that form is the **[Dependencies](../tutorial/dependencies.md)** tutorial. The manual form is the **low-level** `Server`, whose `on_call_tool` handler is allowed to return either result type:
2323

2424
```python title="server.py" hl_lines="44-47"
2525
--8<-- "docs_src/mrtr/tutorial001.py"
@@ -93,6 +93,6 @@ Drop to the underlying session, where `allow_input_required=True` hands you the
9393
* `input_requests` is what it needs. `request_state` is an opaque resume token only the server reads.
9494
* `Client` runs the retry loop for you: register `elicitation_callback` / `sampling_callback` / `list_roots_callback` and `call_tool` returns a plain `CallToolResult`. `input_required_max_rounds` (default 10) bounds it.
9595
* To inspect or persist rounds, use `client.session.call_tool(..., allow_input_required=True)` and own the `while isinstance(result, InputRequiredResult)` loop yourself.
96-
* The server side is the **low-level** `Server` only; `@mcp.tool()` has no sugar for this yet.
96+
* On `@mcp.tool()`, a dependency that asks the user produces this result for you (**[Dependencies](../tutorial/dependencies.md)**); the **low-level** `Server` is the manual form.
9797

9898
This is the mechanism that replaces server-initiated sampling and the rest of the push-style back-channel; see **[Deprecated features](deprecated.md)**.

docs/tutorial/dependencies.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,24 @@ And if the user won't answer at all - declines the question, or cancels it?
116116

117117
That's the right default for a precondition: no answer, no order. When declining is an outcome your tool wants to handle - skip the backorder but still suggest another title - annotate `ElicitationResult[Backorder]` instead and the tool receives the full accept/decline/cancel outcome to branch on. **[Elicitation](elicitation.md)** shows that form, and everything else about asking: the schema rules, the three answers, the client's side of the conversation.
118118

119+
!!! info
120+
The framework picks the question's transport from the negotiated protocol version; the code
121+
above is identical on both. On **2026-07-28** and later the question rides inside a
122+
multi-round-trip `tools/call` - the server returns it, the client's `elicitation_callback`
123+
answers it, and the `Client` retries the call for you (**[Multi-round-trip requests](../advanced/multi-round-trip.md)**). On
124+
**2025-11-25** and earlier it is a synchronous elicitation request mid-call. Each question is
125+
asked exactly once per call; a resolver that answered *without* asking, like `check_stock`,
126+
may run again when the call resumes after a question. When it resumes, each answer is matched
127+
back to its question, so an eliciting resolver must derive its question deterministically from
128+
the tool's arguments and earlier answers - a per-call generated value (a `default_factory` id,
129+
a timestamp) is re-derived on each round and must not appear in a question the answer is meant
130+
to bind to.
131+
119132
## Recap
120133

121134
* `Annotated[T, Resolve(fn)]` on a tool parameter: the SDK runs `fn` and injects its return value.
122135
* A resolved parameter is invisible to the model and cannot be supplied by a client. Values the model must not invent - prices, identities, permissions - belong here.
123-
* A resolver's parameters are resolved the same way: the `Context`, another `Resolve(...)`, or a tool argument by name. The graph runs each resolver at most once per call.
136+
* A resolver's parameters are resolved the same way: the `Context`, another `Resolve(...)`, or a tool argument by name. The graph runs each resolver at most once, however many consumers it has; a resolver that never asked may run again when a call resumes after a question.
124137
* Bad graphs fail at registration with `InvalidSignature`, not mid-call.
125138
* Return `Elicit(message, Model)` to ask the user, only when you have to. Unwrapped annotations abort on decline; `ElicitationResult[T]` lets the tool branch.
126139

tests/docs_src/test_dependencies.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""`docs/tutorial/dependencies.md`: every claim the page makes, proved against the real SDK."""
22

3+
from typing import Literal
4+
35
import pytest
46
from inline_snapshot import snapshot
57
from mcp_types import ElicitRequestParams, ElicitResult, TextContent
@@ -79,47 +81,56 @@ def get(self, key: str, default: int) -> int:
7981
assert inventory.lookups == ["Dune", "Dune"]
8082

8183

82-
async def test_an_in_stock_order_asks_no_question() -> None:
84+
# The `!!! info` claims the tutorial003 behaviour is transport-independent, so each claim is
85+
# proved on both: mode="legacy" elicits synchronously mid-call (2025-11-25 and earlier), while
86+
# mode="auto" negotiates 2026-07-28, where the question rides a multi-round-trip `tools/call`
87+
# and `Client` drives the retries.
88+
@pytest.mark.parametrize("mode", ["legacy", "auto"])
89+
async def test_an_in_stock_order_asks_no_question(mode: Literal["legacy", "auto"]) -> None:
8390
"""tutorial003: `confirm_backorder` returns directly when stock exists - no round-trip."""
8491

8592
async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover
8693
raise AssertionError("an in-stock order must not elicit")
8794

88-
async with Client(tutorial003.mcp, mode="legacy", elicitation_callback=never) as client:
95+
async with Client(tutorial003.mcp, mode=mode, elicitation_callback=never) as client:
8996
result = await client.call_tool("order_book", {"title": "Dune"})
9097

9198
assert result.content == [TextContent(type="text", text="Ordered 'Dune'.")]
9299

93100

101+
@pytest.mark.parametrize("mode", ["legacy", "auto"])
94102
@pytest.mark.parametrize(
95103
("confirm", "expected"),
96104
[
97105
(True, "Backordered 'Neuromancer'; it ships in 2-3 weeks."),
98106
(False, "No order placed."),
99107
],
100108
)
101-
async def test_an_out_of_stock_order_asks_and_honours_the_answer(confirm: bool, expected: str) -> None:
109+
async def test_an_out_of_stock_order_asks_and_honours_the_answer(
110+
mode: Literal["legacy", "auto"], confirm: bool, expected: str
111+
) -> None:
102112
"""tutorial003: the resolver elicits, the SDK validates the answer, the tool reads it."""
103113
asked: list[str] = []
104114

105115
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
106116
asked.append(params.message)
107117
return ElicitResult(action="accept", content={"confirm": confirm})
108118

109-
async with Client(tutorial003.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
119+
async with Client(tutorial003.mcp, mode=mode, elicitation_callback=on_elicit) as client:
110120
result = await client.call_tool("order_book", {"title": "Neuromancer"})
111121

112122
assert result.content == [TextContent(type="text", text=expected)]
113123
assert asked == ["'Neuromancer' is out of stock (2-3 weeks). Order anyway?"]
114124

115125

116-
async def test_declining_an_unwrapped_dependency_aborts_the_call() -> None:
126+
@pytest.mark.parametrize("mode", ["legacy", "auto"])
127+
async def test_declining_an_unwrapped_dependency_aborts_the_call(mode: Literal["legacy", "auto"]) -> None:
117128
"""tutorial003: no answer, no order - the error text on the page is the real one."""
118129

119130
async def decline(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
120131
return ElicitResult(action="decline")
121132

122-
async with Client(tutorial003.mcp, mode="legacy", elicitation_callback=decline) as client:
133+
async with Client(tutorial003.mcp, mode=mode, elicitation_callback=decline) as client:
123134
result = await client.call_tool("order_book", {"title": "Neuromancer"})
124135

125136
assert result.is_error

0 commit comments

Comments
 (0)