Skip to content

Commit c85836a

Browse files
Kludexmaxisbey
andauthored
Drive resolver elicitation over the 2026-07-28 input_required flow (#2986)
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
1 parent 24fdd90 commit c85836a

16 files changed

Lines changed: 1521 additions & 83 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/migration.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,8 @@ Positional calls (`await ctx.info("hello")`) are unaffected.
786786

787787
`Context.elicit()` (and `elicit_with_validation()`) now render the schema first and validate each property against the spec's `PrimitiveSchemaDefinition`, raising `TypeError` at the call site for anything outside it. `Optional[T]` fields render as `{"type": ...}` with the field omitted from `required` (previously the non-spec `anyOf` shape). A bare `list[str]` field is rejected because it renders without the required enum items; use `list[Literal[...]]` or `list[str]` with `json_schema_extra` supplying the items. Unions of multiple primitives (e.g. `int | str`) and nested models are rejected.
788788

789+
A schema-mismatched *accepted* answer also fails differently: the call now raises `ValueError` with a stable message ("Received an accepted elicitation whose content does not match the requested schema") instead of letting pydantic's `ValidationError` escape with its internals. Code that caught `ValidationError` around `ctx.elicit()` should catch `ValueError` (or rely on the tool's error result).
790+
789791
### Replace `RootModel` by union types with `TypeAdapter` validation
790792

791793
The following union types are no longer `RootModel` subclasses:

docs/tutorial/dependencies.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,26 @@ 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 guarantee about the question, not the resolver. In the
126+
multi-round-trip form an eliciting resolver runs again to consume its answer, so code before
127+
its `return Elicit(...)` runs on the asking round and again on the answering one; a resolver
128+
that answered *without* asking, like `check_stock`, may run again whenever the call resumes
129+
after a question. When it resumes, each answer is matched back to its question, so an
130+
eliciting resolver must derive its question deterministically from the tool's arguments and
131+
earlier answers - a per-call generated value (a `default_factory` id, a timestamp) is
132+
re-derived on each round and must not appear in a question the answer is meant to bind to.
133+
119134
## Recap
120135

121136
* `Annotated[T, Resolve(fn)]` on a tool parameter: the SDK runs `fn` and injects its return value.
122137
* 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.
138+
* 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 round, however many consumers it has; each question is asked exactly once, an eliciting resolver runs again to consume its answer, and a resolver that never asked may run again when a call resumes.
124139
* Bad graphs fail at registration with `InvalidSignature`, not mid-call.
125140
* Return `Elicit(message, Model)` to ask the user, only when you have to. Unwrapped annotations abort on decline; `ElicitationResult[T]` lets the tool branch.
126141

docs/tutorial/elicitation.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,8 @@ A refusal is not an error. The tool decides what declining means (here, no booki
7676

7777
!!! tip
7878
The answer is validated against your model before your code sees it. A client that sends
79-
`"maybe"` for a `bool` doesn't corrupt your booking: the call fails with the
80-
`ValidationError`, your `if` never runs.
79+
`"maybe"` for a `bool` doesn't corrupt your booking: the call fails with a
80+
schema-mismatch error, your `if` never runs.
8181

8282
## Ask before the tool runs
8383

examples/stories/legacy_elicitation/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,6 @@ uv run python -m stories.legacy_elicitation.client --http --legacy --server serv
6868
## See also
6969

7070
`sampling/` (same push-request shape, deprecated per SEP-2577), `mrtr/`
71-
(planned — the 2026-era carrier), `error_handling/`
71+
(the 2026-era carrier), `error_handling/`
7272
(`UrlElicitationRequiredError`), `refund_desk/` (resolver DI rides this push
73-
mechanism today).
73+
mechanism on handshake-era connections).

examples/stories/manifest.toml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,8 @@ era = "legacy"
4040
status = "legacy"
4141

4242
[story.refund_desk]
43-
# Resolver DI rides push elicitation (ctx.elicit) today; era flips to "dual" once
44-
# the SDK carries resolver elicitation over the 2026 input_required round-trip.
45-
era = "legacy"
43+
# Resolver elicitation picks its transport per era: input_required round-trips on
44+
# the modern leg, push elicitation (ctx.elicit) on the legacy one.
4645
lowlevel = false
4746

4847
[story.sampling]

examples/stories/mrtr/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ uv run python -m stories.mrtr.client --http --server server_lowlevel
4646

4747
## Spec
4848

49-
[Multi-round results — server features](https://modelcontextprotocol.io/specification/draft/server/tools#multi-round-results)
49+
[Input required tool results — server features](https://modelcontextprotocol.io/specification/draft/server/tools#input-required-tool-results)
5050

5151
## See also
5252

examples/stories/refund_desk/README.md

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ reason)` refunds what the order record says — `cents` is resolver-computed and
77
does not appear in the input schema at all, so the model cannot supply or
88
inflate the amount. Resolvers form a DAG (`load_order``refund_scope`
99
`refund_amount` / `ask_restock`), may return `Elicit[...]` to ask the human,
10-
and run at most once per call. A resolver's own plain parameters are filled
11-
from the tool's arguments by name — `load_order(order_id)` receives the
12-
`order_id` the model passed to `refund_order`.
10+
and ask each question at most once per call. A resolver's own plain
11+
parameters are filled from the tool's arguments by name —
12+
`load_order(order_id)` receives the `order_id` the model passed to
13+
`refund_order`.
1314

1415
## Run it
1516

@@ -18,9 +19,9 @@ from the tool's arguments by name — `load_order(order_id)` receives the
1819
uv run python -m stories.refund_desk.client
1920

2021
# HTTP — the client self-hosts the server on a free port, runs, then tears it
21-
# down (--legacy: resolver elicitation rides the push request today; the
22-
# manifest pins this era, so bare --http runs the same leg)
23-
uv run python -m stories.refund_desk.client --http --legacy
22+
# down (2026 protocol: the questions ride embedded input_required round-trips;
23+
# add --legacy to ride synchronous push elicitation instead)
24+
uv run python -m stories.refund_desk.client --http
2425
```
2526

2627
## What to look at
@@ -47,21 +48,38 @@ uv run python -m stories.refund_desk.client --http --legacy
4748

4849
## Caveats
4950

51+
- **Transport per era.** The framework picks the elicitation transport from
52+
the negotiated protocol: at >= 2026-07-28 the questions ride embedded
53+
`input_required` round-trips (a resolver that depends on another's answer is
54+
asked in a later round); at <= 2025-11-25 each is a synchronous
55+
`elicitation/create` push request mid-call. Author code is identical on
56+
both — this client runs unchanged on either era.
5057
- **Decline order.** A declined unwrapped dependency aborts resolution in
5158
tool-signature order — `cents` resolves before `restock`, so `ask_restock`
5259
never runs. Don't rely on a later resolver's side effects after an earlier
5360
consumer can abort.
54-
- **Memoization scope.** Each resolver runs at most once per `tools/call`,
55-
keyed by function identity; nothing is cached across calls or connections.
61+
- **Memoization scope.** Each question is asked at most once per call, and
62+
within a round each resolver runs at most once, keyed by function identity.
63+
Across 2026 rounds only *elicited* outcomes persist (in `requestState`); a
64+
resolver that resolves without eliciting is pure and may re-run each round.
65+
An eliciting resolver's body runs again too — once to ask, once more to
66+
consume its answer.
67+
An answer is matched back to its question when the call resumes, so an
68+
eliciting resolver must derive its question deterministically from the
69+
tool's arguments and earlier answers; a per-call generated value (a
70+
`default_factory` id, a timestamp) is re-derived each round and must not
71+
appear in a question the answer is meant to bind to. Nothing is cached
72+
across calls or connections.
5673
- **Validate elicited values.** Elicited answers are human-typed; check them
5774
against your records (as `_scoped` does) before acting on them.
5875

5976
## Spec
6077

61-
[Elicitation — client features](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation)
78+
[Elicitation — client features](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation),
79+
[Input required tool results — server features](https://modelcontextprotocol.io/specification/draft/server/tools#input-required-tool-results)
6280

6381
## See also
6482

65-
`legacy_elicitation/` (the push mechanism resolver elicitation rides on today),
66-
`mrtr/` (the 2026 `input_required` carrier; resolver DI will ride it once the
67-
SDK wires them together).
83+
`mrtr/` (the 2026 `input_required` carrier these questions ride at
84+
>= 2026-07-28), `legacy_elicitation/` (the push mechanism they ride on
85+
handshake-era connections).

examples/stories/refund_desk/client.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ async def on_elicit(context: ClientRequestContext, params: types.ElicitRequestPa
4141
assert counts == {"scope": 0, "restock": 0}, counts
4242

4343
# Full refund of a three-line order. The scope question fires exactly ONCE even though
44-
# both refund_amount and ask_restock consume it — memoized within the call.
44+
# both refund_amount and ask_restock consume it — asked at most once per call on either
45+
# era. ask_restock needs the scope ANSWER, so at 2026 the two questions land in
46+
# successive rounds, never one concurrent batch: counts and order are era-independent.
4547
receipt = await client.call_tool("refund_order", {"order_id": "ORD-7002", "reason": "arrived broken"})
4648
assert receipt.structured_content == {
4749
"order_id": "ORD-7002",
@@ -53,7 +55,7 @@ async def on_elicit(context: ClientRequestContext, params: types.ElicitRequestPa
5355

5456
# Declining restock still refunds: the tool keeps the ElicitationResult union for
5557
# `restock`, sees the decline, and just skips the restock. The scope counter moves
56-
# again — the memo cache is per tools/call, not per connection.
58+
# again — questions are deduped per call, not per connection.
5759
declines.add("restock")
5860
answers["scope"] = {"full": False, "sku": "canvas-tote"}
5961
receipt = await client.call_tool("refund_order", {"order_id": "ORD-7002", "reason": "wrong colour"})

src/mcp/server/elicitation.py

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,18 @@ def _validate_rendered_properties(json_schema: dict[str, Any]) -> None:
8787
) from None
8888

8989

90+
def render_elicitation_schema(schema: type[BaseModel]) -> dict[str, Any]:
91+
"""Render a model as the spec-valid `requested_schema` for an elicitation.
92+
93+
Raises:
94+
TypeError: If a field renders as something the spec's
95+
`PrimitiveSchemaDefinition` does not accept.
96+
"""
97+
json_schema = schema.model_json_schema(schema_generator=_ElicitationJsonSchema)
98+
_validate_rendered_properties(json_schema)
99+
return json_schema
100+
101+
90102
async def elicit_with_validation(
91103
session: ServerSession,
92104
message: str,
@@ -102,27 +114,32 @@ async def elicit_with_validation(
102114
the user or automatically generating a response.
103115
104116
For sensitive data like credentials or OAuth flows, use elicit_url() instead.
117+
118+
Raises:
119+
ValueError: If the client accepted the elicitation without supplying
120+
content, or with content that does not match the requested schema.
105121
"""
106-
json_schema = schema.model_json_schema(schema_generator=_ElicitationJsonSchema)
107-
_validate_rendered_properties(json_schema)
122+
json_schema = render_elicitation_schema(schema)
108123

109124
result = await session.elicit_form(
110125
message=message,
111126
requested_schema=json_schema,
112127
related_request_id=related_request_id,
113128
)
114129

115-
if result.action == "accept" and result.content is not None:
116-
# Validate and parse the content using the schema
117-
validated_data = schema.model_validate(result.content)
130+
if result.action == "accept":
131+
if result.content is None:
132+
raise ValueError("Received an accepted elicitation with no content")
133+
try:
134+
validated_data = schema.model_validate(result.content)
135+
except ValidationError as e:
136+
raise ValueError(
137+
"Received an accepted elicitation whose content does not match the requested schema"
138+
) from e
118139
return AcceptedElicitation(data=validated_data)
119-
elif result.action == "decline":
140+
if result.action == "decline":
120141
return DeclinedElicitation()
121-
elif result.action == "cancel":
122-
return CancelledElicitation()
123-
else: # pragma: no cover
124-
# This should never happen, but handle it just in case
125-
raise ValueError(f"Unexpected elicitation action: {result.action}")
142+
return CancelledElicitation()
126143

127144

128145
async def elicit_url(

0 commit comments

Comments
 (0)