Drive resolver elicitation over the 2026-07-28 input_required flow (#2986)

Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
This commit is contained in:
Marcelo Trylesinski 2026-06-29 15:39:43 +02:00 • committed by GitHub
commit c85836a081
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1521 additions and 83 deletions

View file

@ -68,6 +68,6 @@ uv run python -m stories.legacy_elicitation.client --http --legacy --server serv
## See also
`sampling/` (same push-request shape, deprecated per SEP-2577), `mrtr/`
(planned — the 2026-era carrier), `error_handling/`
(the 2026-era carrier), `error_handling/`
(`UrlElicitationRequiredError`), `refund_desk/` (resolver DI rides this push
mechanism today).
mechanism on handshake-era connections).

View file

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

View file

@ -46,7 +46,7 @@ uv run python -m stories.mrtr.client --http --server server_lowlevel
## Spec
[Multi-round results — server features](https://modelcontextprotocol.io/specification/draft/server/tools#multi-round-results)
[Input required tool results — server features](https://modelcontextprotocol.io/specification/draft/server/tools#input-required-tool-results)
## See also

View file

@ -7,9 +7,10 @@ reason)` refunds what the order record says — `cents` is resolver-computed and
does not appear in the input schema at all, so the model cannot supply or
inflate the amount. Resolvers form a DAG (`load_order` → `refund_scope` →
`refund_amount` / `ask_restock`), may return `Elicit[...]` to ask the human,
and run at most once per call. A resolver's own plain parameters are filled
from the tool's arguments by name — `load_order(order_id)` receives the
`order_id` the model passed to `refund_order`.
and ask each question at most once per call. A resolver's own plain
parameters are filled from the tool's arguments by name —
`load_order(order_id)` receives the `order_id` the model passed to
`refund_order`.
## Run it
@ -18,9 +19,9 @@ from the tool's arguments by name — `load_order(order_id)` receives the
uv run python -m stories.refund_desk.client
# HTTP — the client self-hosts the server on a free port, runs, then tears it
# down (--legacy: resolver elicitation rides the push request today; the
# manifest pins this era, so bare --http runs the same leg)
uv run python -m stories.refund_desk.client --http --legacy
# down (2026 protocol: the questions ride embedded input_required round-trips;
# add --legacy to ride synchronous push elicitation instead)
uv run python -m stories.refund_desk.client --http
```
## What to look at
@ -47,21 +48,38 @@ uv run python -m stories.refund_desk.client --http --legacy
## Caveats
- **Transport per era.** The framework picks the elicitation transport from
the negotiated protocol: at >= 2026-07-28 the questions ride embedded
`input_required` round-trips (a resolver that depends on another's answer is
asked in a later round); at <= 2025-11-25 each is a synchronous
`elicitation/create` push request mid-call. Author code is identical on
both — this client runs unchanged on either era.
- **Decline order.** A declined unwrapped dependency aborts resolution in
tool-signature order — `cents` resolves before `restock`, so `ask_restock`
never runs. Don't rely on a later resolver's side effects after an earlier
consumer can abort.
- **Memoization scope.** Each resolver runs at most once per `tools/call`,
keyed by function identity; nothing is cached across calls or connections.
- **Memoization scope.** Each question is asked at most once per call, and
within a round each resolver runs at most once, keyed by function identity.
Across 2026 rounds only *elicited* outcomes persist (in `requestState`); a
resolver that resolves without eliciting is pure and may re-run each round.
An eliciting resolver's body runs again too — once to ask, once more to
consume its answer.
An answer is matched back to its question when the call resumes, so an
eliciting resolver must derive its question deterministically from the
tool's arguments and earlier answers; a per-call generated value (a
`default_factory` id, a timestamp) is re-derived each round and must not
appear in a question the answer is meant to bind to. Nothing is cached
across calls or connections.
- **Validate elicited values.** Elicited answers are human-typed; check them
against your records (as `_scoped` does) before acting on them.
## Spec
[Elicitation — client features](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation)
[Elicitation — client features](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation),
[Input required tool results — server features](https://modelcontextprotocol.io/specification/draft/server/tools#input-required-tool-results)
## See also
`legacy_elicitation/` (the push mechanism resolver elicitation rides on today),
`mrtr/` (the 2026 `input_required` carrier; resolver DI will ride it once the
SDK wires them together).
`mrtr/` (the 2026 `input_required` carrier these questions ride at
>= 2026-07-28), `legacy_elicitation/` (the push mechanism they ride on
handshake-era connections).

View file

@ -41,7 +41,9 @@ async def main(target: Target, *, mode: str = "auto") -> None:
assert counts == {"scope": 0, "restock": 0}, counts
# Full refund of a three-line order. The scope question fires exactly ONCE even though
# both refund_amount and ask_restock consume it — memoized within the call.
# both refund_amount and ask_restock consume it — asked at most once per call on either
# era. ask_restock needs the scope ANSWER, so at 2026 the two questions land in
# successive rounds, never one concurrent batch: counts and order are era-independent.
receipt = await client.call_tool("refund_order", {"order_id": "ORD-7002", "reason": "arrived broken"})
assert receipt.structured_content == {
"order_id": "ORD-7002",
@ -53,7 +55,7 @@ async def main(target: Target, *, mode: str = "auto") -> None:
# Declining restock still refunds: the tool keeps the ElicitationResult union for
# `restock`, sees the decline, and just skips the restock. The scope counter moves
# again — the memo cache is per tools/call, not per connection.
# again — questions are deduped per call, not per connection.
declines.add("restock")
answers["scope"] = {"full": False, "sku": "canvas-tote"}
receipt = await client.call_tool("refund_order", {"order_id": "ORD-7002", "reason": "wrong colour"})