Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/advanced/multi-round-trip.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ That's the whole protocol. Every leg is an ordinary request from the client to t

## The server side

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:
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:

```python title="server.py" hl_lines="44-47"
--8<-- "docs_src/mrtr/tutorial001.py"
Expand Down
18 changes: 10 additions & 8 deletions docs/tutorial/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,19 +123,21 @@ That's the right default for a precondition: no answer, no order. When declining
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.
multi-round-trip form any resolver may run again whenever the call resumes after a question,
so code before a `return Elicit(...)` runs on each of those rounds; the recorded answer then
satisfies the repeated question without prompting the user again. A recorded answer is only
ever consulted when the resolver asks; a resolver that answers *without* asking, like
`check_stock`, always supplies its own computed value. Because each answer is matched back to
its question, 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 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.
* 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.
* 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.

Expand Down
9 changes: 5 additions & 4 deletions examples/stories/refund_desk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,11 @@ uv run python -m stories.refund_desk.client --http
consumer can abort.
- **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.
Across 2026 rounds only *elicited* outcomes persist (in `requestState`); any
resolver's body may run again on each round the call passes through. A
recorded answer is consulted only when the resolver asks its question again:
it satisfies the question without re-prompting the user, and it never stands
in for a value the resolver computes itself.
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
Expand Down
98 changes: 55 additions & 43 deletions src/mcp/server/mcpserver/resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@
(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.
`request_state` across rounds (so the user is asked each question once). Resolver
bodies may re-run on every round; a recorded outcome is consulted only when the
body asks its question again, so a resolver's own computation always wins over
anything the client echoes back in `request_state`.

Whether the consumer receives the unwrapped model or the full
`ElicitationResult` union is decided by the consumer's annotation:
Expand Down Expand Up @@ -114,15 +116,11 @@ def __init__(
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).
Expand Down Expand Up @@ -176,23 +174,50 @@ def find_resolved_parameters(fn: Callable[..., Any]) -> dict[str, tuple[Resolve,
return resolved


def returns_input_required(fn: Callable[..., Any]) -> bool:
"""True when `fn`'s return annotation carries an `InputRequiredResult` arm.

Used at tool registration to reject combining `Resolve(...)` parameters with a
hand-rolled `InputRequiredResult` flow: a call has a single
`input_responses`/`request_state` channel, so the two flows would overwrite
each other's state and the call could never converge.
"""
return _has_input_required_arm(_type_hints(fn).get("return"))


def _has_input_required_arm(annotation: Any) -> bool:
"""Walk an annotation's arms through `Annotated`, type aliases, and unions."""
if get_origin(annotation) is Annotated:
return _has_input_required_arm(get_args(annotation)[0])
# A `type X = ...` / `TypeAliasType` alias carries its target on `__value__` (a
# subscripted alias forwards the attribute to its origin). The access evaluates
# a PEP 695 alias lazily, so an alias naming things unavailable at runtime
# (TYPE_CHECKING-only imports) raises NameError; such an alias declares no arm
# this check can see, and the in-call guard in `Tool.run` still covers it.
try:
value = getattr(annotation, "__value__", None)
except NameError:
return False
if value is not None:
return _has_input_required_arm(value)
if _is_union(annotation):
return any(_has_input_required_arm(arg) for arg in get_args(annotation))
return isinstance(annotation, type) and issubclass(annotation, InputRequiredResult)


def _contains_resolve(annotation: Any) -> bool:
"""True when a `Resolve` marker is nested inside `annotation` (e.g. a union member)."""
if get_origin(annotation) is Annotated:
return any(isinstance(m, Resolve) for m in get_args(annotation)[1:])
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.
def _check_elicit_return(return_annotation: Any, name: str) -> None:
"""Validate the `Elicit[...]` arms of a resolver's return annotation.

Raises:
InvalidSignature: If the annotation has more than one `Elicit[...]` arm;
the runtime can honor only one static question schema per resolver.
a resolver asks one question - a second arm means it should be split.
"""
# 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,)
Expand All @@ -203,10 +228,6 @@ def _elicit_return_schema(return_annotation: Any, name: str) -> type[BaseModel]
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:
Expand Down Expand Up @@ -299,8 +320,8 @@ def analyze(fn: Callable[..., Any], stack: tuple[Hashable, ...]) -> None:
"expected a Context, an Annotated[_, Resolve(...)], or a tool argument by name"
)

elicit_schema = _elicit_return_schema(hints.get("return"), _resolver_name(fn))
plans[key] = _ResolverPlan(fn, params, is_async_callable(fn), elicit_schema, wire_key)
_check_elicit_return(hints.get("return"), _resolver_name(fn))
plans[key] = _ResolverPlan(fn, params, is_async_callable(fn), wire_key)
for dep in nested:
analyze(dep, stack + (key,))

Expand Down Expand Up @@ -387,9 +408,10 @@ async def resolve_arguments(
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.
Each question is asked once - its answer is carried in `request_state` across
rounds and satisfies the question when the resolver asks it again. Resolver
bodies themselves may re-run on each round; a recorded answer is consulted
only when the body asks, never in place of running it.

Raises:
ToolError: If an elicited value is declined or cancelled and the consumer
Expand Down Expand Up @@ -428,15 +450,6 @@ async def _resolve(fn: Callable[..., Any], res: _Resolution) -> ElicitationResul
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

kwargs: dict[str, Any] = {}
dep_pending = False
Expand Down Expand Up @@ -481,10 +494,11 @@ async def _elicit(elicit: Elicit[Any], key: str, res: _Resolution) -> Elicitatio
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).
# A recorded outcome from a prior round is consulted only here, after the body
# decided to ask, so a `request_state` entry can never stand in for a resolver's
# own computation. Re-validate it 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
Expand Down Expand Up @@ -614,24 +628,21 @@ def _encode_state(outcomes: Mapping[str, _StateEntry]) -> str:
return _State(v=_STATE_VERSION, outcomes=dict(outcomes)).model_dump_json()


def _outcome_from_state(entry: _StateEntry, schema: type[BaseModel] | None) -> ElicitationResult[Any]:
def _outcome_from_state(entry: _StateEntry, schema: type[BaseModel]) -> 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.
ValidationError: If an accepted entry's data does not validate against
`schema` (the live `Elicit.schema` of the question being asked).
"""
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)
return _accepted(schema.model_validate(entry.data))


def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel] | None) -> ElicitationResult[Any] | None:
def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel]) -> 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
Expand Down Expand Up @@ -665,4 +676,5 @@ def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel] | None)
"find_resolved_parameters",
"build_resolver_plans",
"resolve_arguments",
"returns_input_required",
]
18 changes: 17 additions & 1 deletion src/mcp/server/mcpserver/tools/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@
from mcp_types import Icon, InputRequiredResult, ToolAnnotations
from pydantic import BaseModel, Field

from mcp.server.mcpserver.exceptions import ToolError
from mcp.server.mcpserver.exceptions import InvalidSignature, ToolError
from mcp.server.mcpserver.resolve import (
build_resolver_plans,
find_resolved_parameters,
resolve_arguments,
returns_input_required,
)
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter
from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, func_metadata
Expand Down Expand Up @@ -81,6 +82,12 @@ def from_function(
context_kwarg = find_context_parameter(fn)

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

skip_names = [context_kwarg] if context_kwarg is not None else []
skip_names.extend(resolved_params)
Expand Down Expand Up @@ -150,6 +157,15 @@ async def run(
pre_validated=pre_validated,
)

# Registration rejects the annotated form of this combination; this covers
# a body that returns an InputRequiredResult without declaring it.
if self.resolved_params and isinstance(result, InputRequiredResult):
raise ToolError(
"the tool returned an InputRequiredResult but its parameters use Resolve(...); "
"a call has one input_required channel, so the multi-round flow is driven "
"either by resolvers or by the tool body, not both"
)

if convert_result:
result = self.fn_metadata.convert_result(result)

Expand Down
Loading
Loading