Consult request_state only for the question a resolver is asking (#3019)
This commit is contained in:
parent
533c6a8226
commit
8f2c97b769
6 changed files with 321 additions and 89 deletions
|
|
@ -19,7 +19,7 @@ That's the whole protocol. Every leg is an ordinary request from the client to t
|
||||||
|
|
||||||
## The server side
|
## 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"
|
```python title="server.py" hl_lines="44-47"
|
||||||
--8<-- "docs_src/mrtr/tutorial001.py"
|
--8<-- "docs_src/mrtr/tutorial001.py"
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
**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
|
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
|
multi-round-trip form any resolver may run again whenever the call resumes after a question,
|
||||||
its `return Elicit(...)` runs on the asking round and again on the answering one; a resolver
|
so code before a `return Elicit(...)` runs on each of those rounds; the recorded answer then
|
||||||
that answered *without* asking, like `check_stock`, may run again whenever the call resumes
|
satisfies the repeated question without prompting the user again. A recorded answer is only
|
||||||
after a question. When it resumes, each answer is matched back to its question, so an
|
ever consulted when the resolver asks; a resolver that answers *without* asking, like
|
||||||
eliciting resolver must derive its question deterministically from the tool's arguments and
|
`check_stock`, always supplies its own computed value. Because each answer is matched back to
|
||||||
earlier answers - a per-call generated value (a `default_factory` id, a timestamp) is
|
its question, an eliciting resolver must derive its question deterministically from the
|
||||||
re-derived on each round and must not appear in a question the answer is meant to bind to.
|
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
|
## Recap
|
||||||
|
|
||||||
* `Annotated[T, Resolve(fn)]` on a tool parameter: the SDK runs `fn` and injects its return value.
|
* `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 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.
|
* 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.
|
* Return `Elicit(message, Model)` to ask the user, only when you have to. Unwrapped annotations abort on decline; `ElicitationResult[T]` lets the tool branch.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,10 +60,11 @@ uv run python -m stories.refund_desk.client --http
|
||||||
consumer can abort.
|
consumer can abort.
|
||||||
- **Memoization scope.** Each question is asked at most once per call, and
|
- **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.
|
within a round each resolver runs at most once, keyed by function identity.
|
||||||
Across 2026 rounds only *elicited* outcomes persist (in `requestState`); a
|
Across 2026 rounds only *elicited* outcomes persist (in `requestState`); any
|
||||||
resolver that resolves without eliciting is pure and may re-run each round.
|
resolver's body may run again on each round the call passes through. A
|
||||||
An eliciting resolver's body runs again too — once to ask, once more to
|
recorded answer is consulted only when the resolver asks its question again:
|
||||||
consume its answer.
|
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
|
An answer is matched back to its question when the call resumes, so an
|
||||||
eliciting resolver must derive its question deterministically from the
|
eliciting resolver must derive its question deterministically from the
|
||||||
tool's arguments and earlier answers; a per-call generated value (a
|
tool's arguments and earlier answers; a per-call generated value (a
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,10 @@ and resumes when the client retries with `input_responses`/`request_state`
|
||||||
(independent resolvers are asked in one round; a resolver depending on another's
|
(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
|
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
|
`elicitation/create` request mid-call. Only *elicited* outcomes are carried in
|
||||||
`request_state` across rounds (so the user is asked each question once); a
|
`request_state` across rounds (so the user is asked each question once). Resolver
|
||||||
resolver that returns a value without eliciting is pure and may re-run each round.
|
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
|
Whether the consumer receives the unwrapped model or the full
|
||||||
`ElicitationResult` union is decided by the consumer's annotation:
|
`ElicitationResult` union is decided by the consumer's annotation:
|
||||||
|
|
@ -114,15 +116,11 @@ class _ResolverPlan:
|
||||||
fn: Callable[..., Any],
|
fn: Callable[..., Any],
|
||||||
params: dict[str, _ParamPlan],
|
params: dict[str, _ParamPlan],
|
||||||
is_async: bool,
|
is_async: bool,
|
||||||
elicit_schema: type[BaseModel] | None,
|
|
||||||
wire_key: str,
|
wire_key: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.fn = fn
|
self.fn = fn
|
||||||
self.params = params
|
self.params = params
|
||||||
self.is_async = is_async
|
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
|
# Deterministic, collision-free key for this resolver's elicitation on the
|
||||||
# wire (`input_requests`/`request_state`). Assigned at registration so it is
|
# wire (`input_requests`/`request_state`). Assigned at registration so it is
|
||||||
# stable across rounds even when `module:qualname` collides (closures).
|
# stable across rounds even when `module:qualname` collides (closures).
|
||||||
|
|
@ -176,6 +174,37 @@ def find_resolved_parameters(fn: Callable[..., Any]) -> dict[str, tuple[Resolve,
|
||||||
return resolved
|
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:
|
def _contains_resolve(annotation: Any) -> bool:
|
||||||
"""True when a `Resolve` marker is nested inside `annotation` (e.g. a union member)."""
|
"""True when a `Resolve` marker is nested inside `annotation` (e.g. a union member)."""
|
||||||
if get_origin(annotation) is Annotated:
|
if get_origin(annotation) is Annotated:
|
||||||
|
|
@ -183,16 +212,12 @@ def _contains_resolve(annotation: Any) -> bool:
|
||||||
return any(_contains_resolve(arg) for arg in get_args(annotation))
|
return any(_contains_resolve(arg) for arg in get_args(annotation))
|
||||||
|
|
||||||
|
|
||||||
def _elicit_return_schema(return_annotation: Any, name: str) -> type[BaseModel] | None:
|
def _check_elicit_return(return_annotation: Any, name: str) -> None:
|
||||||
"""Extract `T` from a resolver return type's `Elicit[T]` arm, if present.
|
"""Validate the `Elicit[...]` arms of a resolver's return annotation.
|
||||||
|
|
||||||
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:
|
Raises:
|
||||||
InvalidSignature: If the annotation has more than one `Elicit[...]` arm;
|
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.
|
# 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,)
|
candidates = get_args(return_annotation) if _is_union(return_annotation) else (return_annotation,)
|
||||||
|
|
@ -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; "
|
f"Resolver {name!r} return annotation has multiple Elicit arms; "
|
||||||
"a resolver asks one question - split it into separate resolvers"
|
"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:
|
def _is_union(annotation: Any) -> bool:
|
||||||
|
|
@ -299,8 +320,8 @@ def build_resolver_plans(
|
||||||
"expected a Context, an Annotated[_, Resolve(...)], or a tool argument by name"
|
"expected a Context, an Annotated[_, Resolve(...)], or a tool argument by name"
|
||||||
)
|
)
|
||||||
|
|
||||||
elicit_schema = _elicit_return_schema(hints.get("return"), _resolver_name(fn))
|
_check_elicit_return(hints.get("return"), _resolver_name(fn))
|
||||||
plans[key] = _ResolverPlan(fn, params, is_async_callable(fn), elicit_schema, wire_key)
|
plans[key] = _ResolverPlan(fn, params, is_async_callable(fn), wire_key)
|
||||||
for dep in nested:
|
for dep in nested:
|
||||||
analyze(dep, stack + (key,))
|
analyze(dep, stack + (key,))
|
||||||
|
|
||||||
|
|
@ -387,9 +408,10 @@ async def resolve_arguments(
|
||||||
negotiated protocol is >= 2026-07-28), returns an `InputRequiredResult`
|
negotiated protocol is >= 2026-07-28), returns an `InputRequiredResult`
|
||||||
carrying the batched questions instead; the tool body is not run.
|
carrying the batched questions instead; the tool body is not run.
|
||||||
|
|
||||||
An eliciting resolver asks its question once - its answer is carried in
|
Each question is asked once - its answer is carried in `request_state` across
|
||||||
`request_state` across rounds - while a resolver that resolves without
|
rounds and satisfies the question when the resolver asks it again. Resolver
|
||||||
eliciting is pure and may re-run on each round.
|
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:
|
Raises:
|
||||||
ToolError: If an elicited value is declined or cancelled and the consumer
|
ToolError: If an elicited value is declined or cancelled and the consumer
|
||||||
|
|
@ -428,15 +450,6 @@ async def _resolve(fn: Callable[..., Any], res: _Resolution) -> ElicitationResul
|
||||||
if wire_key in res.pending:
|
if wire_key in res.pending:
|
||||||
# Already asked this round by another consumer; don't run the resolver again.
|
# Already asked this round by another consumer; don't run the resolver again.
|
||||||
raise _Pending
|
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] = {}
|
kwargs: dict[str, Any] = {}
|
||||||
dep_pending = False
|
dep_pending = False
|
||||||
|
|
@ -481,10 +494,11 @@ async def _elicit(elicit: Elicit[Any], key: str, res: _Resolution) -> Elicitatio
|
||||||
if not res.input_required:
|
if not res.input_required:
|
||||||
return await res.context.elicit(elicit.message, elicit.schema)
|
return await res.context.elicit(elicit.message, elicit.schema)
|
||||||
|
|
||||||
# Answered in a prior round (restored without a known schema, e.g. an unannotated
|
# A recorded outcome from a prior round is consulted only here, after the body
|
||||||
# resolver): re-validate the stored entry against the live `Elicit.schema`. A
|
# decided to ask, so a `request_state` entry can never stand in for a resolver's
|
||||||
# recorded outcome wins over a re-sent answer; an invalid entry self-deletes and
|
# own computation. Re-validate it against the live `Elicit.schema`. A recorded
|
||||||
# falls through to the fresh answer (or to re-asking).
|
# 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)
|
outcome = _restore_outcome(res, key, elicit.schema)
|
||||||
if outcome is not None:
|
if outcome is not None:
|
||||||
return outcome
|
return outcome
|
||||||
|
|
@ -614,24 +628,21 @@ def _encode_state(outcomes: Mapping[str, _StateEntry]) -> str:
|
||||||
return _State(v=_STATE_VERSION, outcomes=dict(outcomes)).model_dump_json()
|
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.
|
"""Rebuild an `ElicitationResult` from a decoded `request_state` entry.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValidationError: If `schema` is known and the entry's data does not
|
ValidationError: If an accepted entry's data does not validate against
|
||||||
validate against it.
|
`schema` (the live `Elicit.schema` of the question being asked).
|
||||||
"""
|
"""
|
||||||
if entry.action == "decline":
|
if entry.action == "decline":
|
||||||
return DeclinedElicitation()
|
return DeclinedElicitation()
|
||||||
if entry.action == "cancel":
|
if entry.action == "cancel":
|
||||||
return CancelledElicitation()
|
return CancelledElicitation()
|
||||||
data = entry.data
|
return _accepted(schema.model_validate(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:
|
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.
|
"""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
|
`request_state` is client-trusted, so an entry whose data fails validation gets
|
||||||
|
|
@ -665,4 +676,5 @@ __all__ = [
|
||||||
"find_resolved_parameters",
|
"find_resolved_parameters",
|
||||||
"build_resolver_plans",
|
"build_resolver_plans",
|
||||||
"resolve_arguments",
|
"resolve_arguments",
|
||||||
|
"returns_input_required",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,12 @@ from typing import TYPE_CHECKING, Any
|
||||||
from mcp_types import Icon, InputRequiredResult, ToolAnnotations
|
from mcp_types import Icon, InputRequiredResult, ToolAnnotations
|
||||||
from pydantic import BaseModel, Field
|
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 (
|
from mcp.server.mcpserver.resolve import (
|
||||||
build_resolver_plans,
|
build_resolver_plans,
|
||||||
find_resolved_parameters,
|
find_resolved_parameters,
|
||||||
resolve_arguments,
|
resolve_arguments,
|
||||||
|
returns_input_required,
|
||||||
)
|
)
|
||||||
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter
|
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter
|
||||||
from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, func_metadata
|
from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, func_metadata
|
||||||
|
|
@ -81,6 +82,12 @@ class Tool(BaseModel):
|
||||||
context_kwarg = find_context_parameter(fn)
|
context_kwarg = find_context_parameter(fn)
|
||||||
|
|
||||||
resolved_params = find_resolved_parameters(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 = [context_kwarg] if context_kwarg is not None else []
|
||||||
skip_names.extend(resolved_params)
|
skip_names.extend(resolved_params)
|
||||||
|
|
@ -150,6 +157,15 @@ class Tool(BaseModel):
|
||||||
pre_validated=pre_validated,
|
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:
|
if convert_result:
|
||||||
result = self.fn_metadata.convert_result(result)
|
result = self.fn_metadata.convert_result(result)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import json
|
import json
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal, TypeVar
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -18,7 +18,8 @@ from mcp_types import (
|
||||||
InputResponses,
|
InputResponses,
|
||||||
TextContent,
|
TextContent,
|
||||||
)
|
)
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, ValidationError, create_model
|
||||||
|
from typing_extensions import TypeAliasType
|
||||||
|
|
||||||
from mcp import Client, InputRequiredRoundsExceededError
|
from mcp import Client, InputRequiredRoundsExceededError
|
||||||
from mcp.client import ClientRequestContext
|
from mcp.client import ClientRequestContext
|
||||||
|
|
@ -34,8 +35,8 @@ from mcp.server.mcpserver import (
|
||||||
)
|
)
|
||||||
from mcp.server.mcpserver.exceptions import InvalidSignature
|
from mcp.server.mcpserver.exceptions import InvalidSignature
|
||||||
from mcp.server.mcpserver.resolve import (
|
from mcp.server.mcpserver.resolve import (
|
||||||
|
_check_elicit_return,
|
||||||
_decode_state,
|
_decode_state,
|
||||||
_elicit_return_schema,
|
|
||||||
_encode_state,
|
_encode_state,
|
||||||
_outcome_from_state,
|
_outcome_from_state,
|
||||||
_resolver_key,
|
_resolver_key,
|
||||||
|
|
@ -43,6 +44,7 @@ from mcp.server.mcpserver.resolve import (
|
||||||
_StateEntry,
|
_StateEntry,
|
||||||
_uses_input_required,
|
_uses_input_required,
|
||||||
find_resolved_parameters,
|
find_resolved_parameters,
|
||||||
|
returns_input_required,
|
||||||
)
|
)
|
||||||
from mcp.server.mcpserver.tools.base import Tool
|
from mcp.server.mcpserver.tools.base import Tool
|
||||||
from mcp.shared.exceptions import MCPError
|
from mcp.shared.exceptions import MCPError
|
||||||
|
|
@ -56,6 +58,26 @@ class Confirm(BaseModel):
|
||||||
ok: bool
|
ok: bool
|
||||||
|
|
||||||
|
|
||||||
|
class Restock(BaseModel):
|
||||||
|
needed: bool
|
||||||
|
|
||||||
|
|
||||||
|
# The `type X = ...` spelling of an InputRequiredResult-bearing return annotation,
|
||||||
|
# bare and generic (a subscripted alias forwards `__value__` to its origin).
|
||||||
|
IRRAlias = TypeAliasType("IRRAlias", InputRequiredResult | str)
|
||||||
|
T_alias = TypeVar("T_alias")
|
||||||
|
IRRAliasGeneric = TypeAliasType("IRRAliasGeneric", InputRequiredResult | T_alias, type_params=(T_alias,))
|
||||||
|
|
||||||
|
|
||||||
|
class _UnevaluableAlias:
|
||||||
|
"""Stand-in for `type X = GhostType | str` whose names exist only under
|
||||||
|
TYPE_CHECKING: accessing `__value__` evaluates the alias and raises."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def __value__(self) -> Any:
|
||||||
|
raise NameError("name 'GhostType' is not defined")
|
||||||
|
|
||||||
|
|
||||||
class Handle(BaseModel):
|
class Handle(BaseModel):
|
||||||
user_name: str = Field(alias="userName")
|
user_name: str = Field(alias="userName")
|
||||||
|
|
||||||
|
|
@ -700,7 +722,7 @@ async def test_input_required_empty_folder_completes_without_eliciting():
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_input_required_resolver_asks_and_consumes_then_never_reruns():
|
async def test_input_required_asks_each_question_once_while_bodies_rerun():
|
||||||
mcp = MCPServer(name="ExactlyOnceMRTR")
|
mcp = MCPServer(name="ExactlyOnceMRTR")
|
||||||
counts = {"login": 0, "confirm": 0}
|
counts = {"login": 0, "confirm": 0}
|
||||||
|
|
||||||
|
|
@ -735,12 +757,13 @@ async def test_input_required_resolver_asks_and_consumes_then_never_reruns():
|
||||||
# `confirm` can only form its question from `login`'s answer, so the auto-driver
|
# `confirm` can only form its question from `login`'s answer, so the auto-driver
|
||||||
# sees the questions in two successive rounds and answers each exactly once.
|
# sees the questions in two successive rounds and answers each exactly once.
|
||||||
assert asked == ["Username?", "As octocat?"]
|
assert asked == ["Username?", "As octocat?"]
|
||||||
# An eliciting resolver runs twice - once to ask, once to consume the answer -
|
# The once-per-call guarantee is about the question, not the body: a recorded
|
||||||
# then its outcome is carried in `request_state` and it never runs again. `login`
|
# answer is consulted only after the body asks again, so `login` runs on every
|
||||||
# asks in round 1 and is consumed in round 2; `confirm` (which depends on
|
# round the call passes through (asks in round 1, consumes its answer in round 2,
|
||||||
# `login`) only forms its question once `login` is known, so it asks in round 2
|
# re-asks-and-restores in round 3) while the user is prompted exactly once.
|
||||||
# and is consumed in round 3. Neither re-runs beyond consuming its own answer.
|
# `confirm` only forms its question once `login` is known: it asks in round 2
|
||||||
assert counts == {"login": 2, "confirm": 2}
|
# and consumes in round 3.
|
||||||
|
assert counts == {"login": 3, "confirm": 2}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
@ -863,24 +886,23 @@ def test_state_round_trips_accept_decline_cancel():
|
||||||
|
|
||||||
accepted = _outcome_from_state(decoded["a"], Login)
|
accepted = _outcome_from_state(decoded["a"], Login)
|
||||||
assert isinstance(accepted, AcceptedElicitation) and accepted.data == Login(username="octocat")
|
assert isinstance(accepted, AcceptedElicitation) and accepted.data == Login(username="octocat")
|
||||||
assert isinstance(_outcome_from_state(decoded["b"], None), DeclinedElicitation)
|
# Decline/cancel entries carry no data; the schema is not consulted for them.
|
||||||
assert isinstance(_outcome_from_state(decoded["c"], None), CancelledElicitation)
|
assert isinstance(_outcome_from_state(decoded["b"], Login), DeclinedElicitation)
|
||||||
raw = _outcome_from_state(decoded["d"], None)
|
assert isinstance(_outcome_from_state(decoded["c"], Login), CancelledElicitation)
|
||||||
assert isinstance(raw, AcceptedElicitation) and raw.data == "raw-token"
|
# An accepted restore always validates against the question's live schema -
|
||||||
|
# data that doesn't fit is rejected, never passed through raw.
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
_outcome_from_state(decoded["d"], Login)
|
||||||
|
|
||||||
|
|
||||||
def test_elicit_return_schema_extraction():
|
def test_check_elicit_return_allows_one_arm_and_rejects_two():
|
||||||
assert _elicit_return_schema(Elicit[Login], "r") is Login # bare Elicit[T]
|
_check_elicit_return(Elicit[Login], "r") # bare Elicit[T]
|
||||||
assert _elicit_return_schema(Login | Elicit[Login], "r") is Login # union arm
|
_check_elicit_return(Login | Elicit[Login], "r") # union arm
|
||||||
assert _elicit_return_schema(Login, "r") is None # no Elicit arm
|
_check_elicit_return(Login, "r") # no Elicit arm
|
||||||
assert _elicit_return_schema(None, "r") is None
|
_check_elicit_return(None, "r") # unannotated
|
||||||
# The bound on `Elicit`'s parameter is unenforced at runtime, so a non-model
|
# A resolver asks one question: two distinct Elicit arms mean it should be split.
|
||||||
# subscription is constructible and must yield no schema rather than crash.
|
|
||||||
unbounded_elicit: Any = Elicit
|
|
||||||
assert _elicit_return_schema(unbounded_elicit[int], "r") is None
|
|
||||||
# Two distinct Elicit arms are ambiguous: the runtime can honor only one schema.
|
|
||||||
with pytest.raises(InvalidSignature, match="'r' return annotation has multiple Elicit arms"):
|
with pytest.raises(InvalidSignature, match="'r' return annotation has multiple Elicit arms"):
|
||||||
_elicit_return_schema(Elicit[Login] | Elicit[Confirm], "r")
|
_check_elicit_return(Elicit[Login] | Elicit[Confirm], "r")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
@ -1195,13 +1217,14 @@ async def test_factory_closures_get_distinct_wire_keys():
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_eliciting_resolver_without_elicit_arm_restores_a_typed_model():
|
async def test_eliciting_resolver_without_elicit_arm_restores_a_typed_model():
|
||||||
# A resolver annotated `-> Login` that actually returns `Elicit(...)` has no
|
# A resolver annotated `-> object` that actually returns `Elicit(...)` declares
|
||||||
# `Elicit[T]` return arm, so `elicit_schema` is None. Its answer, restored from
|
# no `Elicit[T]` return arm. Its answer, restored from request_state in a 3+
|
||||||
# request_state in a 3+ round flow, must still come back as a Login model (not a
|
# round flow, must still come back as a Login model (not a raw dict): restore
|
||||||
# raw dict) so a dependent resolver/tool can use its attributes.
|
# validates against the live `Elicit.schema` the body produced, not the lying
|
||||||
|
# annotation, so a dependent resolver/tool can use its attributes.
|
||||||
mcp = MCPServer(name="LyingAnnotation")
|
mcp = MCPServer(name="LyingAnnotation")
|
||||||
|
|
||||||
# Annotated without an `Elicit[T]` return arm, so `elicit_schema` is None.
|
# Annotated without an `Elicit[T]` return arm; the body asks anyway.
|
||||||
async def login(ctx: Context) -> object:
|
async def login(ctx: Context) -> object:
|
||||||
return Elicit("user?", Login)
|
return Elicit("user?", Login)
|
||||||
|
|
||||||
|
|
@ -1591,3 +1614,181 @@ async def test_divergent_validation_and_serialization_aliases_round_trip():
|
||||||
assert isinstance(final, CallToolResult)
|
assert isinstance(final, CallToolResult)
|
||||||
assert isinstance(final.content[0], TextContent)
|
assert isinstance(final.content[0], TextContent)
|
||||||
assert final.content[0].text == "octocat:True"
|
assert final.content[0].text == "octocat:True"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_state_entry_never_replaces_a_resolver_computed_value():
|
||||||
|
# `request_state` is client-echoed: an accept entry under a resolver's wire key
|
||||||
|
# must only satisfy a question the resolver is actually asking, never stand in
|
||||||
|
# for the body's own computation on a branch that does not ask.
|
||||||
|
mcp = MCPServer(name="StateVsBody")
|
||||||
|
calls = {"decide": 0}
|
||||||
|
|
||||||
|
async def decide(ctx: Context) -> Restock | Elicit[Restock]:
|
||||||
|
calls["decide"] += 1
|
||||||
|
return Restock(needed=False) # this branch computes server-side; no question
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def plan_restock(restock: Annotated[Restock, Resolve(decide)]) -> str:
|
||||||
|
return str(restock.needed)
|
||||||
|
|
||||||
|
wire_key = f"{decide.__module__}:{decide.__qualname__}"
|
||||||
|
crafted = json.dumps({"v": 1, "outcomes": {wire_key: {"action": "accept", "data": {"needed": True}}}})
|
||||||
|
|
||||||
|
async with Client(mcp, elicitation_callback=_never) as client:
|
||||||
|
result = await client.session.call_tool("plan_restock", {}, request_state=crafted, allow_input_required=True)
|
||||||
|
assert isinstance(result, CallToolResult)
|
||||||
|
assert isinstance(result.content[0], TextContent)
|
||||||
|
# The body ran and its computation won; the crafted entry was never consulted.
|
||||||
|
assert result.content[0].text == "False"
|
||||||
|
assert calls["decide"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_state_decline_entry_for_a_pure_resolver_is_ignored():
|
||||||
|
# A decline/cancel entry can only answer a question; a resolver with no Elicit
|
||||||
|
# arm never asks one, so such an entry cannot suppress its computed value.
|
||||||
|
mcp = MCPServer(name="PureVsDecline")
|
||||||
|
|
||||||
|
async def lookup(ctx: Context) -> Login:
|
||||||
|
return Login(username="server-side")
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def whoami(login: Annotated[Login, Resolve(lookup)]) -> str:
|
||||||
|
return login.username
|
||||||
|
|
||||||
|
wire_key = f"{lookup.__module__}:{lookup.__qualname__}"
|
||||||
|
crafted = json.dumps({"v": 1, "outcomes": {wire_key: {"action": "decline"}}})
|
||||||
|
|
||||||
|
async with Client(mcp, elicitation_callback=_never) as client:
|
||||||
|
result = await client.session.call_tool("whoami", {}, request_state=crafted, allow_input_required=True)
|
||||||
|
assert isinstance(result, CallToolResult)
|
||||||
|
assert not result.is_error
|
||||||
|
assert isinstance(result.content[0], TextContent)
|
||||||
|
assert result.content[0].text == "server-side"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_dynamic_schema_resolver_restores_across_rounds():
|
||||||
|
# `-> Elicit[BaseModel]` is the natural annotation for `create_model(...)`
|
||||||
|
# schemas; the restored answer must validate against the live question's
|
||||||
|
# schema, so the dynamic shape works across a multi-question chain.
|
||||||
|
mcp = MCPServer(name="DynamicSchema")
|
||||||
|
dyn = create_model("Dyn", token=(str, ...))
|
||||||
|
|
||||||
|
async def first(ctx: Context) -> Elicit[BaseModel]:
|
||||||
|
return Elicit("Q1?", dyn)
|
||||||
|
|
||||||
|
async def second(f: Annotated[BaseModel, Resolve(first)], ctx: Context) -> Elicit[Confirm]:
|
||||||
|
return Elicit("Q2?", Confirm)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def chain(c: Annotated[Confirm, Resolve(second)]) -> str:
|
||||||
|
return str(c.ok)
|
||||||
|
|
||||||
|
def answer(key: str, params: ElicitRequestFormParams) -> ElicitResult:
|
||||||
|
if "Q1" in params.message:
|
||||||
|
return ElicitResult(action="accept", content={"token": "t"})
|
||||||
|
return ElicitResult(action="accept", content={"ok": True})
|
||||||
|
|
||||||
|
async with Client(mcp, elicitation_callback=_never) as client:
|
||||||
|
one = await client.session.call_tool("chain", {}, allow_input_required=True)
|
||||||
|
assert isinstance(one, InputRequiredResult)
|
||||||
|
two = await client.session.call_tool(
|
||||||
|
"chain",
|
||||||
|
{},
|
||||||
|
input_responses=_answer_round(one, answer),
|
||||||
|
request_state=one.request_state,
|
||||||
|
allow_input_required=True,
|
||||||
|
)
|
||||||
|
assert isinstance(two, InputRequiredResult) # Q1 consumed, Q2 asked
|
||||||
|
final = await client.session.call_tool(
|
||||||
|
"chain",
|
||||||
|
{},
|
||||||
|
input_responses=_answer_round(two, answer),
|
||||||
|
request_state=two.request_state,
|
||||||
|
allow_input_required=True,
|
||||||
|
)
|
||||||
|
# Round 3 restores Q1's answer against the live dynamic schema and completes.
|
||||||
|
assert isinstance(final, CallToolResult)
|
||||||
|
assert isinstance(final.content[0], TextContent)
|
||||||
|
assert final.content[0].text == "True"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"annotation",
|
||||||
|
[
|
||||||
|
InputRequiredResult,
|
||||||
|
InputRequiredResult | str,
|
||||||
|
Annotated[InputRequiredResult | str, "meta"],
|
||||||
|
str | Annotated[InputRequiredResult, "meta"], # Annotated as a union member
|
||||||
|
IRRAlias, # `type X = ...` alias
|
||||||
|
IRRAliasGeneric[str], # subscripted generic alias
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_tool_combining_resolvers_with_input_required_return_is_rejected(annotation: Any):
|
||||||
|
# A call has one input_responses/request_state channel: resolver elicitation
|
||||||
|
# and a hand-rolled InputRequiredResult body cannot share it.
|
||||||
|
mcp = MCPServer(name="ChannelOwnership")
|
||||||
|
|
||||||
|
async def lookup(ctx: Context) -> Login:
|
||||||
|
return Login(username="x") # pragma: no cover - registration is rejected
|
||||||
|
|
||||||
|
async def combo(login: Annotated[Login, Resolve(lookup)]):
|
||||||
|
raise NotImplementedError # pragma: no cover
|
||||||
|
|
||||||
|
combo.__annotations__["return"] = annotation
|
||||||
|
with pytest.raises(InvalidSignature, match="combines Resolve\\(\\.\\.\\.\\) parameters"):
|
||||||
|
mcp.tool()(combo)
|
||||||
|
|
||||||
|
# Without resolver parameters the hand-rolled form remains available.
|
||||||
|
@mcp.tool()
|
||||||
|
async def manual() -> InputRequiredResult:
|
||||||
|
raise NotImplementedError # pragma: no cover - only registration is exercised
|
||||||
|
|
||||||
|
assert returns_input_required(manual)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unevaluable_alias_and_parameterized_generics_declare_no_arm():
|
||||||
|
# A `type X = ...` alias is evaluated lazily, so one naming TYPE_CHECKING-only
|
||||||
|
# imports raises NameError on `__value__` access: it declares no arm the check
|
||||||
|
# can see and must not break registration (the in-call guard still covers a
|
||||||
|
# body that returns an InputRequiredResult anyway). A parameterized generic
|
||||||
|
# return is never the InputRequiredResult class either.
|
||||||
|
mcp = MCPServer(name="RegistrationTolerance")
|
||||||
|
|
||||||
|
async def lookup(ctx: Context) -> Login:
|
||||||
|
return Login(username="x") # pragma: no cover - only registration is exercised
|
||||||
|
|
||||||
|
async def lazy(login: Annotated[Login, Resolve(lookup)]):
|
||||||
|
raise NotImplementedError # pragma: no cover
|
||||||
|
|
||||||
|
lazy.__annotations__["return"] = _UnevaluableAlias()
|
||||||
|
assert not returns_input_required(lazy)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def listy(login: Annotated[Login, Resolve(lookup)]) -> list[str]:
|
||||||
|
raise NotImplementedError # pragma: no cover
|
||||||
|
|
||||||
|
assert not returns_input_required(listy)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_tool_returning_input_required_dynamically_with_resolvers_is_an_error():
|
||||||
|
# The annotated form of this combination is rejected at registration; a body
|
||||||
|
# that returns an InputRequiredResult without declaring it fails loudly at the
|
||||||
|
# same boundary instead of silently fighting the resolvers for the channel.
|
||||||
|
mcp = MCPServer(name="DynamicChannelClash")
|
||||||
|
|
||||||
|
async def lookup(ctx: Context) -> Login:
|
||||||
|
return Login(username="x")
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def sneaky(login: Annotated[Login, Resolve(lookup)]):
|
||||||
|
return InputRequiredResult(input_requests={}, request_state="opaque")
|
||||||
|
|
||||||
|
async with Client(mcp) as client:
|
||||||
|
result = await client.call_tool("sneaky", {})
|
||||||
|
assert result.is_error
|
||||||
|
assert isinstance(result.content[0], TextContent)
|
||||||
|
assert "the multi-round flow is driven either by resolvers or by the tool body" in result.content[0].text
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue