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
16 changed files with 1521 additions and 83 deletions

View file

@ -19,7 +19,7 @@ That's the whole protocol. Every leg is an ordinary request from the client to t
## The server side
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:
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:
```python title="server.py" hl_lines="44-47"
--8<-- "docs_src/mrtr/tutorial001.py"
@ -93,6 +93,6 @@ Drop to the underlying session, where `allow_input_required=True` hands you the
* `input_requests` is what it needs. `request_state` is an opaque resume token only the server reads.
* `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.
* To inspect or persist rounds, use `client.session.call_tool(..., allow_input_required=True)` and own the `while isinstance(result, InputRequiredResult)` loop yourself.
* The server side is the **low-level** `Server` only; `@mcp.tool()` has no sugar for this yet.
* 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.
This is the mechanism that replaces server-initiated sampling and the rest of the push-style back-channel; see **[Deprecated features](deprecated.md)**.

View file

@ -786,6 +786,8 @@ Positional calls (`await ctx.info("hello")`) are unaffected.
`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.
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).
### Replace `RootModel` by union types with `TypeAdapter` validation
The following union types are no longer `RootModel` subclasses:

View file

@ -116,11 +116,26 @@ And if the user won't answer at all - declines the question, or cancels it?
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.
!!! info
The framework picks the question's transport from the negotiated protocol version; the code
above is identical on both. On **2026-07-28** and later the question rides inside a
multi-round-trip `tools/call` - the server returns it, the client's `elicitation_callback`
answers it, and the `Client` retries the call for you (**[Multi-round-trip requests](../advanced/multi-round-trip.md)**). On
**2025-11-25** and earlier it is a synchronous elicitation request mid-call. Each question is
asked exactly once per call - a guarantee about the question, not the resolver. In the
multi-round-trip form an eliciting resolver runs again to consume its answer, so code before
its `return Elicit(...)` runs on the asking round and again on the answering one; a resolver
that answered *without* asking, like `check_stock`, may run again whenever the call resumes
after a question. When it resumes, each answer is matched back to its question, 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 on each round and must not appear in a question the answer is meant to bind to.
## Recap
* `Annotated[T, Resolve(fn)]` on a tool parameter: the SDK runs `fn` and injects its return value.
* 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.
* 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.
* 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.
* Bad graphs fail at registration with `InvalidSignature`, not mid-call.
* Return `Elicit(message, Model)` to ask the user, only when you have to. Unwrapped annotations abort on decline; `ElicitationResult[T]` lets the tool branch.

View file

@ -76,8 +76,8 @@ A refusal is not an error. The tool decides what declining means (here, no booki
!!! tip
The answer is validated against your model before your code sees it. A client that sends
`"maybe"` for a `bool` doesn't corrupt your booking: the call fails with the
`ValidationError`, your `if` never runs.
`"maybe"` for a `bool` doesn't corrupt your booking: the call fails with a
schema-mismatch error, your `if` never runs.
## Ask before the tool runs

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"})

View file

@ -87,6 +87,18 @@ def _validate_rendered_properties(json_schema: dict[str, Any]) -> None:
) from None
def render_elicitation_schema(schema: type[BaseModel]) -> dict[str, Any]:
"""Render a model as the spec-valid `requested_schema` for an elicitation.
Raises:
TypeError: If a field renders as something the spec's
`PrimitiveSchemaDefinition` does not accept.
"""
json_schema = schema.model_json_schema(schema_generator=_ElicitationJsonSchema)
_validate_rendered_properties(json_schema)
return json_schema
async def elicit_with_validation(
session: ServerSession,
message: str,
@ -102,9 +114,12 @@ async def elicit_with_validation(
the user or automatically generating a response.
For sensitive data like credentials or OAuth flows, use elicit_url() instead.
Raises:
ValueError: If the client accepted the elicitation without supplying
content, or with content that does not match the requested schema.
"""
json_schema = schema.model_json_schema(schema_generator=_ElicitationJsonSchema)
_validate_rendered_properties(json_schema)
json_schema = render_elicitation_schema(schema)
result = await session.elicit_form(
message=message,
@ -112,17 +127,19 @@ async def elicit_with_validation(
related_request_id=related_request_id,
)
if result.action == "accept" and result.content is not None:
# Validate and parse the content using the schema
validated_data = schema.model_validate(result.content)
if result.action == "accept":
if result.content is None:
raise ValueError("Received an accepted elicitation with no content")
try:
validated_data = schema.model_validate(result.content)
except ValidationError as e:
raise ValueError(
"Received an accepted elicitation whose content does not match the requested schema"
) from e
return AcceptedElicitation(data=validated_data)
elif result.action == "decline":
if result.action == "decline":
return DeclinedElicitation()
elif result.action == "cancel":
return CancelledElicitation()
else: # pragma: no cover
# This should never happen, but handle it just in case
raise ValueError(f"Unexpected elicitation action: {result.action}")
return CancelledElicitation()
async def elicit_url(

View file

@ -232,6 +232,11 @@ class Context(BaseModel, Generic[LifespanContextT, RequestT]):
"""Get the unique ID for this request."""
return str(self.request_context.request_id)
@property
def protocol_version(self) -> str | None:
"""The negotiated protocol version, or `None` outside of an active request."""
return self._request_context.protocol_version if self._request_context is not None else None
@property
def input_responses(self) -> InputResponses | None:
"""Client responses to a prior `InputRequiredResult.input_requests`.

View file

@ -7,25 +7,47 @@ take tool arguments by name, and take the `Context`. A resolver may return
`Elicit[T]` to ask the client; the framework runs the elicitation and injects the
answer.
The framework picks the elicitation transport from the negotiated protocol. At
>= 2026-07-28 it returns an `InputRequiredResult` carrying the batched questions
and resumes when the client retries with `input_responses`/`request_state`
(independent resolvers are asked in one round; a resolver depending on another's
answer is asked in a later round). At <= 2025-11-25 it issues a synchronous
`elicitation/create` request mid-call. Only *elicited* outcomes are carried in
`request_state` across rounds (so the user is asked each question once); a
resolver that returns a value without eliciting is pure and may re-run each round.
Whether the consumer receives the unwrapped model or the full
`ElicitationResult` union is decided by the consumer's annotation:
- `Annotated[T, Resolve(fn)]` -> unwrapped `T`; decline/cancel aborts the call.
- `Annotated[ElicitationResult[T], Resolve(fn)]` (or a specific member) -> the
full outcome; the consumer branches on accept/decline/cancel.
Each resolver runs at most once per `tools/call` (memoized by function identity).
"""
from __future__ import annotations
import inspect
import types
import typing
from collections.abc import Callable, Hashable, Mapping
from typing import Annotated, Any, Generic, cast, get_args, get_origin
from typing import Annotated, Any, Generic, Literal, TypeGuard, get_args, get_origin
import anyio.to_thread
from pydantic import BaseModel
from mcp_types import (
MISSING_REQUIRED_CLIENT_CAPABILITY,
ClientCapabilities,
ElicitationCapability,
ElicitRequest,
ElicitRequestFormParams,
ElicitResult,
FormElicitationCapability,
InputRequests,
InputRequiredResult,
InputResponses,
MissingRequiredClientCapabilityErrorData,
)
from mcp_types.version import is_version_at_least
from pydantic import BaseModel, ValidationError
from typing_extensions import TypeVar
from mcp.server.elicitation import (
@ -33,16 +55,24 @@ from mcp.server.elicitation import (
CancelledElicitation,
DeclinedElicitation,
ElicitationResult,
render_elicitation_schema,
)
from mcp.server.mcpserver.context import Context
from mcp.server.mcpserver.exceptions import InvalidSignature, ToolError
from mcp.shared._callable_inspection import is_async_callable
from mcp.shared.exceptions import MCPError
T = TypeVar("T", bound=BaseModel)
# The union members the framework injects when a consumer opts into the outcome.
_ELICITATION_RESULT_MEMBERS = (AcceptedElicitation, DeclinedElicitation, CancelledElicitation)
# First protocol revision whose `tools/call` carries elicitation inside
# `InputRequiredResult` rather than as a standalone server-to-client request.
# Pinned (not `LATEST_MODERN_VERSION`, which moves when newer revisions are added).
_INPUT_REQUIRED_VERSION = "2026-07-28"
_STATE_VERSION = 1
class Resolve:
"""Marker for `Annotated[T, Resolve(fn)]`: fill the parameter by running `fn`."""
@ -79,10 +109,24 @@ class _ParamPlan:
class _ResolverPlan:
"""A resolver's parameters and whether it is async, analyzed once."""
def __init__(self, fn: Callable[..., Any], params: dict[str, _ParamPlan], is_async: bool) -> None:
def __init__(
self,
fn: Callable[..., Any],
params: dict[str, _ParamPlan],
is_async: bool,
elicit_schema: type[BaseModel] | None,
wire_key: str,
) -> None:
self.fn = fn
self.params = params
self.is_async = is_async
# The `T` from the resolver's `Elicit[T]` return arm, if annotated. Used to
# re-validate an outcome restored from `request_state` into a model.
self.elicit_schema = elicit_schema
# Deterministic, collision-free key for this resolver's elicitation on the
# wire (`input_requests`/`request_state`). Assigned at registration so it is
# stable across rounds even when `module:qualname` collides (closures).
self.wire_key = wire_key
def _type_hints(fn: Callable[..., Any]) -> dict[str, Any]:
@ -139,6 +183,36 @@ def _contains_resolve(annotation: Any) -> bool:
return any(_contains_resolve(arg) for arg in get_args(annotation))
def _elicit_return_schema(return_annotation: Any, name: str) -> type[BaseModel] | None:
"""Extract `T` from a resolver return type's `Elicit[T]` arm, if present.
Handles a bare `-> Elicit[T]` and a `-> T | Elicit[T]` union. Lets an elicited
outcome restored from `request_state` (a plain dict) be re-validated into its
model so dependent resolvers and tools receive a typed value.
Raises:
InvalidSignature: If the annotation has more than one `Elicit[...]` arm;
the runtime can honor only one static question schema per resolver.
"""
# A bare `Elicit[T]` is itself a candidate; a union contributes its members.
candidates = get_args(return_annotation) if _is_union(return_annotation) else (return_annotation,)
# Typing dedupes equal union members, so two arms here are genuinely distinct.
arms = [c for c in candidates if get_origin(c) is Elicit]
if len(arms) > 1:
raise InvalidSignature(
f"Resolver {name!r} return annotation has multiple Elicit arms; "
"a resolver asks one question - split it into separate resolvers"
)
if not arms:
return None
schema = get_args(arms[0])[0]
return schema if isinstance(schema, type) and issubclass(schema, BaseModel) else None
def _is_union(annotation: Any) -> bool:
return get_origin(annotation) in (typing.Union, types.UnionType)
def _wants_union(type_arg: Any) -> bool:
"""True when `type_arg` is an `ElicitationResult` member (or a union of them).
@ -187,6 +261,9 @@ def build_resolver_plans(
or a tool argument by name).
"""
plans: dict[Hashable, _ResolverPlan] = {}
# Count how many distinct resolvers share each `module:qualname` base so closures
# from one factory get distinct, deterministic wire keys (`base`, `base#1`, ...).
base_counts: dict[str, int] = {}
def analyze(fn: Callable[..., Any], stack: tuple[Hashable, ...]) -> None:
key = _resolver_key(fn)
@ -195,6 +272,11 @@ def build_resolver_plans(
if key in plans:
return
base = _state_key(fn)
seen = base_counts.get(base, 0)
base_counts[base] = seen + 1
wire_key = base if seen == 0 else f"{base}#{seen}"
hints = _type_hints(fn)
sig = inspect.signature(fn)
params: dict[str, _ParamPlan] = {}
@ -217,7 +299,8 @@ def build_resolver_plans(
"expected a Context, an Annotated[_, Resolve(...)], or a tool argument by name"
)
plans[key] = _ResolverPlan(fn, params, is_async_callable(fn))
elicit_schema = _elicit_return_schema(hints.get("return"), _resolver_name(fn))
plans[key] = _ResolverPlan(fn, params, is_async_callable(fn), elicit_schema, wire_key)
for dep in nested:
analyze(dep, stack + (key,))
@ -241,76 +324,337 @@ def _is_context_annotation(annotation: Any) -> bool:
return any(isinstance(c, type) and issubclass(c, Context) for c in candidates)
class _Pending(Exception):
"""Internal: a resolver needs client input not yet available this round."""
class _Resolution:
"""Per-`tools/call` resolution state, shared across the DAG walk.
`input_required` selects the transport: at >= 2026-07-28 elicitations are
batched into `pending` and surfaced as an `InputRequiredResult`; at older
revisions each `Elicit` is answered synchronously via `ctx.elicit`.
"""
def __init__(
self,
plans: Mapping[Hashable, _ResolverPlan],
tool_args: Mapping[str, Any],
context: Context[Any, Any],
input_required: bool,
) -> None:
self.plans = plans
self.tool_args = tool_args
self.context = context
self.input_required = input_required
self.answers: InputResponses = context.input_responses or {} if input_required else {}
self.state = _decode_state(context.request_state) if input_required else {}
# In-call dedup keyed by resolver identity (distinguishes two instances of
# the same bound method); `persist` holds the wire-shaped record of each
# elicited outcome, keyed by its wire key - exactly what the next round's
# `request_state` carries. Entries are the client's own (validated) wire
# data, never re-derived from a model, so encode-restore is the identity.
# Pure resolvers are cheap to re-run each round and are not persisted.
self.cache: dict[Hashable, ElicitationResult[Any]] = {}
self.persist: dict[str, _StateEntry] = {}
self.pending: InputRequests = {}
def _state_key(fn: Callable[..., Any]) -> str:
"""Worker-stable base wire key for a resolver, derived only from registration data.
`input_requests`/`request_state` must round-trip through the client and resume on
any worker (stateless HTTP), so the key carries no `id(...)`: it is the resolver's
`module:qualname` (a callable object uses its type's). Distinct resolvers that
share this base - two instances of one method, two closures from one factory - are
disambiguated deterministically by `build_resolver_plans` (`base`, `base#1`, ...).
"""
qualname = getattr(fn, "__qualname__", None) or type(fn).__qualname__
module = getattr(fn, "__module__", None) or type(fn).__module__
return f"{module}:{qualname}"
async def resolve_arguments(
resolved_params: Mapping[str, tuple[Resolve, bool]],
plans: Mapping[Hashable, _ResolverPlan],
tool_args: Mapping[str, Any],
context: Context[Any, Any],
) -> dict[str, Any]:
) -> dict[str, Any] | InputRequiredResult:
"""Resolve every `Resolve`-marked tool parameter into a concrete value.
Each resolver runs at most once (memoized by function identity). Returns a
mapping of tool parameter name to the value to inject.
Returns the mapping of tool parameter name to injected value when every
resolver is satisfied. When a resolver still needs client input (and the
negotiated protocol is >= 2026-07-28), returns an `InputRequiredResult`
carrying the batched questions instead; the tool body is not run.
An eliciting resolver asks its question once - its answer is carried in
`request_state` across rounds - while a resolver that resolves without
eliciting is pure and may re-run on each round.
Raises:
ToolError: If an elicited value is declined or cancelled and the consumer
asked for the unwrapped model (rather than the result union).
"""
cache: dict[Hashable, ElicitationResult[Any]] = {}
# `ctx.protocol_version` is `None` outside an active request: `MCPServer.call_tool()`
# called directly builds such a `Context`, and a tool whose resolvers never elicit
# must still work there. A missing version means the synchronous (non-input_required)
# transport, which never reaches a server-to-client request anyway.
res = _Resolution(plans, tool_args, context, _uses_input_required(context.protocol_version))
injected: dict[str, Any] = {}
for name, (marker, wants_union) in resolved_params.items():
outcome = await _resolve(marker.fn, plans, tool_args, context, cache)
try:
outcome = await _resolve(marker.fn, res)
except _Pending:
continue
injected[name] = outcome if wants_union else _unwrap(outcome, name)
if res.pending:
return InputRequiredResult(input_requests=res.pending, request_state=_encode_state(res.persist))
return injected
async def _resolve(
fn: Callable[..., Any],
plans: Mapping[Hashable, _ResolverPlan],
tool_args: Mapping[str, Any],
context: Context[Any, Any],
cache: dict[Hashable, ElicitationResult[Any]],
) -> ElicitationResult[Any]:
key = _resolver_key(fn)
if key in cache:
return cache[key]
async def _resolve(fn: Callable[..., Any], res: _Resolution) -> ElicitationResult[Any]:
"""Resolve one resolver, deduped within the call by its resolver identity.
Raises `_Pending` when the resolver (or one of its dependencies) needs client
input that has not arrived yet.
"""
cache_key = _resolver_key(fn)
if cache_key in res.cache:
return res.cache[cache_key]
plan = res.plans[cache_key]
wire_key = plan.wire_key
if wire_key in res.pending:
# Already asked this round by another consumer; don't run the resolver again.
raise _Pending
# Restore a prior round's outcome directly only when its model is known from the
# `Elicit[T]` return arm. Without that (a resolver that elicits but isn't annotated
# `-> ... Elicit[T]`), fall through and re-run the resolver so `_elicit` can
# re-validate the stored answer against the live `Elicit.schema`.
if wire_key in res.state and (plan.elicit_schema is not None or res.state[wire_key].action != "accept"):
outcome = _restore_outcome(res, wire_key, plan.elicit_schema)
if outcome is not None:
res.cache[cache_key] = outcome
return outcome
plan = plans[key]
kwargs: dict[str, Any] = {}
dep_pending = False
for param_name, param_plan in plan.params.items():
if param_plan.kind == "context":
kwargs[param_name] = context
kwargs[param_name] = res.context
elif param_plan.kind == "by_name":
kwargs[param_name] = tool_args[param_name]
kwargs[param_name] = res.tool_args[param_name]
else:
assert param_plan.resolve is not None
dep_outcome = await _resolve(param_plan.resolve.fn, plans, tool_args, context, cache)
try:
# Visit every dependency so independent ones that need input are all
# collected into `res.pending` and batched into a single round.
dep_outcome = await _resolve(param_plan.resolve.fn, res)
except _Pending:
dep_pending = True
continue
kwargs[param_name] = dep_outcome if param_plan.wants_union else _unwrap(dep_outcome, param_name)
if dep_pending:
raise _Pending
result: Any
if plan.is_async:
result = await fn(**kwargs)
else:
result = await anyio.to_thread.run_sync(lambda: fn(**kwargs))
outcome: ElicitationResult[Any]
if isinstance(result, Elicit):
elicit = cast("Elicit[BaseModel]", result)
outcome = await context.elicit(elicit.message, elicit.schema)
if _is_elicit(result):
outcome = await _elicit(result, wire_key, res)
else:
# A resolver may return any type (not just `BaseModel`); `model_construct`
# wraps it as an accepted result without validating against the schema bound.
outcome = cast("AcceptedElicitation[Any]", AcceptedElicitation.model_construct(data=result))
# A resolver may return any type (not just `BaseModel`), so accept it as the
# outcome without validating against the schema bound. Plain outcomes are not
# persisted in `request_state`; the resolver re-runs next round instead.
outcome = _accepted(result)
cache[key] = outcome
res.cache[cache_key] = outcome
return outcome
async def _elicit(elicit: Elicit[Any], key: str, res: _Resolution) -> ElicitationResult[Any]:
"""Turn a resolver's `Elicit` into an outcome via the negotiated transport."""
if not res.input_required:
return await res.context.elicit(elicit.message, elicit.schema)
# Answered in a prior round (restored without a known schema, e.g. an unannotated
# resolver): re-validate the stored entry against the live `Elicit.schema`. A
# recorded outcome wins over a re-sent answer; an invalid entry self-deletes and
# falls through to the fresh answer (or to re-asking).
outcome = _restore_outcome(res, key, elicit.schema)
if outcome is not None:
return outcome
answer = res.answers.get(key)
if answer is None:
_require_form_elicitation(res.context, key)
res.pending[key] = _elicit_request(elicit)
raise _Pending
if not isinstance(answer, ElicitResult):
raise ToolError(f"Resolver {key!r} received a non-elicitation response")
if answer.action == "accept":
if answer.content is None:
raise ToolError(f"Resolver {key!r} received an accepted elicitation with no content")
try:
data = elicit.schema.model_validate(answer.content)
except ValidationError as e:
raise ToolError(
f"Resolver {key!r} received an accepted elicitation whose content does not match the requested schema"
) from e
# Persist the exact wire content that just passed validation - never the
# model - so restoring next round revalidates the same bytes the client sent.
res.persist[key] = _StateEntry(action="accept", data=answer.content)
return AcceptedElicitation(data=data)
if answer.action == "decline":
res.persist[key] = _StateEntry(action="decline")
return DeclinedElicitation()
res.persist[key] = _StateEntry(action="cancel")
return CancelledElicitation()
def _unwrap(outcome: ElicitationResult[Any], name: str) -> Any:
if isinstance(outcome, AcceptedElicitation):
return outcome.data
raise ToolError(f"Resolver for parameter {name!r} could not resolve: elicitation was {outcome.action}")
def _is_elicit(value: Any) -> TypeGuard[Elicit[Any]]:
"""Runtime narrow of a resolver's return value to a (parameter-erased) `Elicit`."""
return isinstance(value, Elicit)
def _accepted(data: Any) -> AcceptedElicitation[Any]:
"""Wrap a resolved value as an accepted outcome without schema validation.
A resolver may return any type (the schema bound only constrains `Elicit[T]`),
and a value restored from `request_state` is already validated.
"""
return AcceptedElicitation[Any].model_construct(data=data)
def _uses_input_required(protocol_version: str | None) -> bool:
"""True when this request must elicit via `InputRequiredResult` (>= 2026-07-28).
Older revisions still carry a standalone `elicitation/create` server-to-client
request, so the framework keeps the synchronous `ctx.elicit()` path for them.
"""
return protocol_version is not None and is_version_at_least(protocol_version, _INPUT_REQUIRED_VERSION)
def _require_form_elicitation(context: Context[Any, Any], key: str) -> None:
"""Assert the client declared form elicitation before queueing a question for it.
The spec forbids sending an `input_requests` entry the client has not declared a
capability for. A bare `elicitation: {}` declaration (the only shape before modes
existed) counts as form support; an explicit url-only declaration does not.
Raises:
MCPError: With code `MISSING_REQUIRED_CLIENT_CAPABILITY` and a
`requiredCapabilities` payload when form elicitation is not declared.
"""
capabilities = context.client_capabilities
elicitation = capabilities.elicitation if capabilities is not None else None
if elicitation is not None and (elicitation.form is not None or elicitation.url is None):
return
data = MissingRequiredClientCapabilityErrorData(
required_capabilities=ClientCapabilities(elicitation=ElicitationCapability(form=FormElicitationCapability()))
)
raise MCPError(
code=MISSING_REQUIRED_CLIENT_CAPABILITY,
message=f"Client did not declare the form elicitation capability required by resolver {key!r}",
data=data.model_dump(by_alias=True, mode="json", exclude_none=True),
)
def _elicit_request(elicit: Elicit[Any]) -> ElicitRequest:
"""Render an `Elicit[T]` as the embedded `elicitation/create` request for `input_requests`."""
json_schema = render_elicitation_schema(elicit.schema)
return ElicitRequest(params=ElicitRequestFormParams(message=elicit.message, requested_schema=json_schema))
class _StateEntry(BaseModel):
"""One resolver's recorded outcome inside `request_state`."""
action: Literal["accept", "decline", "cancel"]
data: Any = None
class _State(BaseModel):
"""The decoded `request_state`: resolver outcomes from earlier rounds."""
v: int
outcomes: dict[str, _StateEntry] = {}
def _decode_state(request_state: str | None) -> dict[str, _StateEntry]:
"""Decode the per-call resolution progress from `request_state`.
`request_state` is client-trusted (integrity sealing is a follow-up); validate
it through `_State` and treat anything malformed as "no progress yet".
"""
if not request_state:
return {}
try:
state = _State.model_validate_json(request_state)
except ValidationError:
return {}
return state.outcomes if state.v == _STATE_VERSION else {}
def _encode_state(outcomes: Mapping[str, _StateEntry]) -> str:
"""Encode recorded elicitation outcomes (keyed by wire key) for the next round.
Entries already hold the client's wire-shaped data exactly as it was sent (and
validated), so encoding is pure wrapping: encode-restore is the identity.
"""
return _State(v=_STATE_VERSION, outcomes=dict(outcomes)).model_dump_json()
def _outcome_from_state(entry: _StateEntry, schema: type[BaseModel] | None) -> ElicitationResult[Any]:
"""Rebuild an `ElicitationResult` from a decoded `request_state` entry.
Raises:
ValidationError: If `schema` is known and the entry's data does not
validate against it.
"""
if entry.action == "decline":
return DeclinedElicitation()
if entry.action == "cancel":
return CancelledElicitation()
data = entry.data
if schema is not None:
data = schema.model_validate(data)
return _accepted(data)
def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel] | None) -> ElicitationResult[Any] | None:
"""Restore `key`'s recorded outcome from a prior round, or `None` when absent.
`request_state` is client-trusted, so an entry whose data fails validation gets
the `_decode_state` treatment - dropped as if no progress was recorded, so the
question is asked again - rather than surfacing a validation error.
Carries the original decoded entry forward unchanged in `res.persist`: if a
later resolver is still pending, the next round's `request_state` is built from
`res.persist`, so an earlier answer must stay there - byte-identical, never
re-derived - or it would be dropped and re-asked.
"""
entry = res.state.get(key)
if entry is None:
return None
try:
outcome = _outcome_from_state(entry, schema)
except ValidationError:
del res.state[key]
return None
res.persist[key] = entry
return outcome
__all__ = [
"Resolve",
"Elicit",

View file

@ -4,7 +4,7 @@ from collections.abc import Callable, Hashable
from functools import cached_property
from typing import TYPE_CHECKING, Any
from mcp_types import Icon, ToolAnnotations
from mcp_types import Icon, InputRequiredResult, ToolAnnotations
from pydantic import BaseModel, Field
from mcp.server.mcpserver.exceptions import ToolError
@ -135,9 +135,12 @@ class Tool(BaseModel):
pre_validated: dict[str, Any] | None = None
if self.resolved_params:
pre_validated = self.fn_metadata.validate_arguments(arguments)
pass_directly |= await resolve_arguments(
self.resolved_params, self.resolver_plans, pre_validated, context
)
resolved = await resolve_arguments(self.resolved_params, self.resolver_plans, pre_validated, context)
if isinstance(resolved, InputRequiredResult):
# A resolver still needs client input (>= 2026-07-28): surface the
# batched questions instead of running the tool body this round.
return self.fn_metadata.convert_result(resolved) if convert_result else resolved
pass_directly |= resolved
result = await self.fn_metadata.call_fn_with_arg_validation(
self.fn,

View file

@ -1,5 +1,7 @@
"""`docs/tutorial/dependencies.md`: every claim the page makes, proved against the real SDK."""
from typing import Literal
import pytest
from inline_snapshot import snapshot
from mcp_types import ElicitRequestParams, ElicitResult, TextContent
@ -79,18 +81,24 @@ async def test_a_shared_dependency_runs_once_per_call(monkeypatch: pytest.Monkey
assert inventory.lookups == ["Dune", "Dune"]
async def test_an_in_stock_order_asks_no_question() -> None:
# The `!!! info` claims the tutorial003 behaviour is transport-independent, so each claim is
# proved on both: mode="legacy" elicits synchronously mid-call (2025-11-25 and earlier), while
# mode="auto" negotiates 2026-07-28, where the question rides a multi-round-trip `tools/call`
# and `Client` drives the retries.
@pytest.mark.parametrize("mode", ["legacy", "auto"])
async def test_an_in_stock_order_asks_no_question(mode: Literal["legacy", "auto"]) -> None:
"""tutorial003: `confirm_backorder` returns directly when stock exists - no round-trip."""
async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover
raise AssertionError("an in-stock order must not elicit")
async with Client(tutorial003.mcp, mode="legacy", elicitation_callback=never) as client:
async with Client(tutorial003.mcp, mode=mode, elicitation_callback=never) as client:
result = await client.call_tool("order_book", {"title": "Dune"})
assert result.content == [TextContent(type="text", text="Ordered 'Dune'.")]
@pytest.mark.parametrize("mode", ["legacy", "auto"])
@pytest.mark.parametrize(
("confirm", "expected"),
[
@ -98,7 +106,9 @@ async def test_an_in_stock_order_asks_no_question() -> None:
(False, "No order placed."),
],
)
async def test_an_out_of_stock_order_asks_and_honours_the_answer(confirm: bool, expected: str) -> None:
async def test_an_out_of_stock_order_asks_and_honours_the_answer(
mode: Literal["legacy", "auto"], confirm: bool, expected: str
) -> None:
"""tutorial003: the resolver elicits, the SDK validates the answer, the tool reads it."""
asked: list[str] = []
@ -106,20 +116,21 @@ async def test_an_out_of_stock_order_asks_and_honours_the_answer(confirm: bool,
asked.append(params.message)
return ElicitResult(action="accept", content={"confirm": confirm})
async with Client(tutorial003.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
async with Client(tutorial003.mcp, mode=mode, elicitation_callback=on_elicit) as client:
result = await client.call_tool("order_book", {"title": "Neuromancer"})
assert result.content == [TextContent(type="text", text=expected)]
assert asked == ["'Neuromancer' is out of stock (2-3 weeks). Order anyway?"]
async def test_declining_an_unwrapped_dependency_aborts_the_call() -> None:
@pytest.mark.parametrize("mode", ["legacy", "auto"])
async def test_declining_an_unwrapped_dependency_aborts_the_call(mode: Literal["legacy", "auto"]) -> None:
"""tutorial003: no answer, no order - the error text on the page is the real one."""
async def decline(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="decline")
async with Client(tutorial003.mcp, mode="legacy", elicitation_callback=decline) as client:
async with Client(tutorial003.mcp, mode=mode, elicitation_callback=decline) as client:
result = await client.call_tool("order_book", {"title": "Neuromancer"})
assert result.is_error

View file

@ -124,7 +124,7 @@ async def test_an_answer_that_does_not_match_the_schema_never_reaches_the_tool_c
result = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
assert result.is_error
assert isinstance(result.content[0], TextContent)
assert "Input should be a valid boolean" in result.content[0].text
assert "does not match the requested schema" in result.content[0].text
class Address(BaseModel):

File diff suppressed because it is too large Load diff