Skip to content

Commit 5dd78a9

Browse files
committed
Consult request_state only for the question a resolver is asking
The resolver framework restored recorded outcomes from the client-echoed request_state before running the resolver body, so an entry under a resolver's wire key could stand in for the body's own computation on a branch that never asks, and a decline/cancel entry could abort a resolver with no Elicit arm at all. Restore now happens only inside _elicit, after the body returned an Elicit this round: the body's concrete return always wins, and a recorded answer satisfies exactly the question being asked, validated against the live Elicit.schema. That makes the statically-extracted elicit_schema unused, so it is removed - which also fixes resolvers annotated `-> Elicit[BaseModel]` (dynamic create_model schemas): restoring through the static schema called BaseModel.model_validate, whose PydanticUserError is not a ValidationError and hard-failed the call after the user had answered. A tool combining Resolve(...) parameters with a hand-rolled InputRequiredResult body flow shares the call's single input_responses/request_state channel; the flows overwrite each other's state and the call never converges. A declared InputRequiredResult return arm is now rejected at registration (InvalidSignature, seeing through Annotated wrappers, type aliases, and unions) and an undeclared one fails the call at runtime. The cost of body-wins is that an eliciting resolver's body re-runs on each round instead of being restored without running; the docs scope the once-per-call guarantee to the question accordingly.
1 parent 533c6a8 commit 5dd78a9

6 files changed

Lines changed: 276 additions & 88 deletions

File tree

docs/advanced/multi-round-trip.md

Lines changed: 1 addition & 1 deletion
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-
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:
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 two forms don't mix: a call has one `input_responses`/`request_state` channel, so a tool that uses `Resolve(...)` parameters cannot also return `InputRequiredResult` from its body - a declared `InputRequiredResult` return is rejected at registration (`InvalidSignature`), and an undeclared one fails the call at runtime. 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"

docs/tutorial/dependencies.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -123,19 +123,21 @@ That's the right default for a precondition: no answer, no order. When declining
123123
answers it, and the `Client` retries the call for you (**[Multi-round-trip requests](../advanced/multi-round-trip.md)**). On
124124
**2025-11-25** and earlier it is a synchronous elicitation request mid-call. Each question is
125125
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.
126+
multi-round-trip form any resolver may run again whenever the call resumes after a question,
127+
so code before a `return Elicit(...)` runs on each of those rounds; the recorded answer then
128+
satisfies the repeated question without prompting the user again. A recorded answer is only
129+
ever consulted when the resolver asks - a resolver that answers *without* asking, like
130+
`check_stock`, always supplies its own computed value. Because each answer is matched back to
131+
its question, an eliciting resolver must derive its question deterministically from the
132+
tool's arguments and earlier answers - a per-call generated value (a `default_factory` id, a
133+
timestamp) is re-derived on each round and must not appear in a question the answer is meant
134+
to bind to.
133135

134136
## Recap
135137

136138
* `Annotated[T, Resolve(fn)]` on a tool parameter: the SDK runs `fn` and injects its return value.
137139
* 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.
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.
140+
* 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, and any resolver may run again when a call resumes after a question.
139141
* Bad graphs fail at registration with `InvalidSignature`, not mid-call.
140142
* Return `Elicit(message, Model)` to ask the user, only when you have to. Unwrapped annotations abort on decline; `ElicitationResult[T]` lets the tool branch.
141143

examples/stories/refund_desk/README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,11 @@ uv run python -m stories.refund_desk.client --http
6060
consumer can abort.
6161
- **Memoization scope.** Each question is asked at most once per call, and
6262
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.
63+
Across 2026 rounds only *elicited* outcomes persist (in `requestState`); any
64+
resolver's body may run again on each round the call passes through. A
65+
recorded answer is consulted only when the resolver asks its question again —
66+
it satisfies the question without re-prompting the user, and never stands in
67+
for a value the resolver computes itself.
6768
An answer is matched back to its question when the call resumes, so an
6869
eliciting resolver must derive its question deterministically from the
6970
tool's arguments and earlier answers; a per-call generated value (a

src/mcp/server/mcpserver/resolve.py

Lines changed: 48 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@
1313
(independent resolvers are asked in one round; a resolver depending on another's
1414
answer is asked in a later round). At <= 2025-11-25 it issues a synchronous
1515
`elicitation/create` request mid-call. Only *elicited* outcomes are carried in
16-
`request_state` across rounds (so the user is asked each question once); a
17-
resolver that returns a value without eliciting is pure and may re-run each round.
16+
`request_state` across rounds (so the user is asked each question once). Resolver
17+
bodies may re-run on every round; a recorded outcome is consulted only when the
18+
body asks its question again, so a resolver's own computation always wins over
19+
anything the client echoes back in `request_state`.
1820
1921
Whether the consumer receives the unwrapped model or the full
2022
`ElicitationResult` union is decided by the consumer's annotation:
@@ -114,15 +116,11 @@ def __init__(
114116
fn: Callable[..., Any],
115117
params: dict[str, _ParamPlan],
116118
is_async: bool,
117-
elicit_schema: type[BaseModel] | None,
118119
wire_key: str,
119120
) -> None:
120121
self.fn = fn
121122
self.params = params
122123
self.is_async = is_async
123-
# The `T` from the resolver's `Elicit[T]` return arm, if annotated. Used to
124-
# re-validate an outcome restored from `request_state` into a model.
125-
self.elicit_schema = elicit_schema
126124
# Deterministic, collision-free key for this resolver's elicitation on the
127125
# wire (`input_requests`/`request_state`). Assigned at registration so it is
128126
# stable across rounds even when `module:qualname` collides (closures).
@@ -176,23 +174,43 @@ def find_resolved_parameters(fn: Callable[..., Any]) -> dict[str, tuple[Resolve,
176174
return resolved
177175

178176

177+
def returns_input_required(fn: Callable[..., Any]) -> bool:
178+
"""True when `fn`'s return annotation carries an `InputRequiredResult` arm.
179+
180+
Used at tool registration to reject combining `Resolve(...)` parameters with a
181+
hand-rolled `InputRequiredResult` flow: a call has a single
182+
`input_responses`/`request_state` channel, so the two flows would overwrite
183+
each other's state and the call could never converge.
184+
"""
185+
return _has_input_required_arm(_type_hints(fn).get("return"))
186+
187+
188+
def _has_input_required_arm(annotation: Any) -> bool:
189+
"""Walk an annotation's arms through `Annotated`, type aliases, and unions."""
190+
if get_origin(annotation) is Annotated:
191+
return _has_input_required_arm(get_args(annotation)[0])
192+
# A `type X = ...` / `TypeAliasType` alias carries its target on `__value__`.
193+
value = getattr(annotation, "__value__", None)
194+
if value is not None:
195+
return _has_input_required_arm(value)
196+
if _is_union(annotation):
197+
return any(_has_input_required_arm(arg) for arg in get_args(annotation))
198+
return isinstance(annotation, type) and issubclass(annotation, InputRequiredResult)
199+
200+
179201
def _contains_resolve(annotation: Any) -> bool:
180202
"""True when a `Resolve` marker is nested inside `annotation` (e.g. a union member)."""
181203
if get_origin(annotation) is Annotated:
182204
return any(isinstance(m, Resolve) for m in get_args(annotation)[1:])
183205
return any(_contains_resolve(arg) for arg in get_args(annotation))
184206

185207

186-
def _elicit_return_schema(return_annotation: Any, name: str) -> type[BaseModel] | None:
187-
"""Extract `T` from a resolver return type's `Elicit[T]` arm, if present.
188-
189-
Handles a bare `-> Elicit[T]` and a `-> T | Elicit[T]` union. Lets an elicited
190-
outcome restored from `request_state` (a plain dict) be re-validated into its
191-
model so dependent resolvers and tools receive a typed value.
208+
def _check_elicit_return(return_annotation: Any, name: str) -> None:
209+
"""Validate the `Elicit[...]` arms of a resolver's return annotation.
192210
193211
Raises:
194212
InvalidSignature: If the annotation has more than one `Elicit[...]` arm;
195-
the runtime can honor only one static question schema per resolver.
213+
a resolver asks one question - a second arm means it should be split.
196214
"""
197215
# A bare `Elicit[T]` is itself a candidate; a union contributes its members.
198216
candidates = get_args(return_annotation) if _is_union(return_annotation) else (return_annotation,)
@@ -203,10 +221,6 @@ def _elicit_return_schema(return_annotation: Any, name: str) -> type[BaseModel]
203221
f"Resolver {name!r} return annotation has multiple Elicit arms; "
204222
"a resolver asks one question - split it into separate resolvers"
205223
)
206-
if not arms:
207-
return None
208-
schema = get_args(arms[0])[0]
209-
return schema if isinstance(schema, type) and issubclass(schema, BaseModel) else None
210224

211225

212226
def _is_union(annotation: Any) -> bool:
@@ -299,8 +313,8 @@ def analyze(fn: Callable[..., Any], stack: tuple[Hashable, ...]) -> None:
299313
"expected a Context, an Annotated[_, Resolve(...)], or a tool argument by name"
300314
)
301315

302-
elicit_schema = _elicit_return_schema(hints.get("return"), _resolver_name(fn))
303-
plans[key] = _ResolverPlan(fn, params, is_async_callable(fn), elicit_schema, wire_key)
316+
_check_elicit_return(hints.get("return"), _resolver_name(fn))
317+
plans[key] = _ResolverPlan(fn, params, is_async_callable(fn), wire_key)
304318
for dep in nested:
305319
analyze(dep, stack + (key,))
306320

@@ -387,9 +401,10 @@ async def resolve_arguments(
387401
negotiated protocol is >= 2026-07-28), returns an `InputRequiredResult`
388402
carrying the batched questions instead; the tool body is not run.
389403
390-
An eliciting resolver asks its question once - its answer is carried in
391-
`request_state` across rounds - while a resolver that resolves without
392-
eliciting is pure and may re-run on each round.
404+
Each question is asked once - its answer is carried in `request_state` across
405+
rounds and satisfies the question when the resolver asks it again. Resolver
406+
bodies themselves may re-run on each round; a recorded answer is consulted
407+
only when the body asks, never in place of running it.
393408
394409
Raises:
395410
ToolError: If an elicited value is declined or cancelled and the consumer
@@ -428,15 +443,6 @@ async def _resolve(fn: Callable[..., Any], res: _Resolution) -> ElicitationResul
428443
if wire_key in res.pending:
429444
# Already asked this round by another consumer; don't run the resolver again.
430445
raise _Pending
431-
# Restore a prior round's outcome directly only when its model is known from the
432-
# `Elicit[T]` return arm. Without that (a resolver that elicits but isn't annotated
433-
# `-> ... Elicit[T]`), fall through and re-run the resolver so `_elicit` can
434-
# re-validate the stored answer against the live `Elicit.schema`.
435-
if wire_key in res.state and (plan.elicit_schema is not None or res.state[wire_key].action != "accept"):
436-
outcome = _restore_outcome(res, wire_key, plan.elicit_schema)
437-
if outcome is not None:
438-
res.cache[cache_key] = outcome
439-
return outcome
440446

441447
kwargs: dict[str, Any] = {}
442448
dep_pending = False
@@ -481,10 +487,11 @@ async def _elicit(elicit: Elicit[Any], key: str, res: _Resolution) -> Elicitatio
481487
if not res.input_required:
482488
return await res.context.elicit(elicit.message, elicit.schema)
483489

484-
# Answered in a prior round (restored without a known schema, e.g. an unannotated
485-
# resolver): re-validate the stored entry against the live `Elicit.schema`. A
486-
# recorded outcome wins over a re-sent answer; an invalid entry self-deletes and
487-
# falls through to the fresh answer (or to re-asking).
490+
# A recorded outcome from a prior round is consulted only here, after the body
491+
# decided to ask, so a `request_state` entry can never stand in for a resolver's
492+
# own computation. Re-validate it against the live `Elicit.schema`. A recorded
493+
# outcome wins over a re-sent answer; an invalid entry self-deletes and falls
494+
# through to the fresh answer (or to re-asking).
488495
outcome = _restore_outcome(res, key, elicit.schema)
489496
if outcome is not None:
490497
return outcome
@@ -614,24 +621,21 @@ def _encode_state(outcomes: Mapping[str, _StateEntry]) -> str:
614621
return _State(v=_STATE_VERSION, outcomes=dict(outcomes)).model_dump_json()
615622

616623

617-
def _outcome_from_state(entry: _StateEntry, schema: type[BaseModel] | None) -> ElicitationResult[Any]:
624+
def _outcome_from_state(entry: _StateEntry, schema: type[BaseModel]) -> ElicitationResult[Any]:
618625
"""Rebuild an `ElicitationResult` from a decoded `request_state` entry.
619626
620627
Raises:
621-
ValidationError: If `schema` is known and the entry's data does not
622-
validate against it.
628+
ValidationError: If an accepted entry's data does not validate against
629+
`schema` (the live `Elicit.schema` of the question being asked).
623630
"""
624631
if entry.action == "decline":
625632
return DeclinedElicitation()
626633
if entry.action == "cancel":
627634
return CancelledElicitation()
628-
data = entry.data
629-
if schema is not None:
630-
data = schema.model_validate(data)
631-
return _accepted(data)
635+
return _accepted(schema.model_validate(entry.data))
632636

633637

634-
def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel] | None) -> ElicitationResult[Any] | None:
638+
def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel]) -> ElicitationResult[Any] | None:
635639
"""Restore `key`'s recorded outcome from a prior round, or `None` when absent.
636640
637641
`request_state` is client-trusted, so an entry whose data fails validation gets
@@ -665,4 +669,5 @@ def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel] | None)
665669
"find_resolved_parameters",
666670
"build_resolver_plans",
667671
"resolve_arguments",
672+
"returns_input_required",
668673
]

src/mcp/server/mcpserver/tools/base.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@
77
from mcp_types import Icon, InputRequiredResult, ToolAnnotations
88
from pydantic import BaseModel, Field
99

10-
from mcp.server.mcpserver.exceptions import ToolError
10+
from mcp.server.mcpserver.exceptions import InvalidSignature, ToolError
1111
from mcp.server.mcpserver.resolve import (
1212
build_resolver_plans,
1313
find_resolved_parameters,
1414
resolve_arguments,
15+
returns_input_required,
1516
)
1617
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter
1718
from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, func_metadata
@@ -81,6 +82,12 @@ def from_function(
8182
context_kwarg = find_context_parameter(fn)
8283

8384
resolved_params = find_resolved_parameters(fn)
85+
if resolved_params and returns_input_required(fn):
86+
raise InvalidSignature(
87+
f"Tool {func_name!r} combines Resolve(...) parameters with an InputRequiredResult "
88+
"return; a call has one input_required channel, so the multi-round flow is driven "
89+
"either by resolvers or by the tool body, not both"
90+
)
8491

8592
skip_names = [context_kwarg] if context_kwarg is not None else []
8693
skip_names.extend(resolved_params)
@@ -150,6 +157,15 @@ async def run(
150157
pre_validated=pre_validated,
151158
)
152159

160+
# Registration rejects the annotated form of this combination; this covers
161+
# a body that returns an InputRequiredResult without declaring it.
162+
if self.resolved_params and isinstance(result, InputRequiredResult):
163+
raise ToolError(
164+
"the tool returned an InputRequiredResult but its parameters use Resolve(...); "
165+
"a call has one input_required channel, so the multi-round flow is driven "
166+
"either by resolvers or by the tool body, not both"
167+
)
168+
153169
if convert_result:
154170
result = self.fn_metadata.convert_result(result)
155171

0 commit comments

Comments
 (0)