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:
parent
24fdd909ac
commit
c85836a081
16 changed files with 1521 additions and 83 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 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)**.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue