Files
modelcontextprotocol--pytho…/tests/server/mcpserver/test_resolve.py
T
wehub-resource-sync 49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:10:27 +08:00

2869 lines
117 KiB
Python

"""Tests for resolver dependency injection (MRTR) on MCPServer tools."""
import json
from collections.abc import Callable
from datetime import datetime
from typing import Annotated, Any, Literal, TypeVar, cast
import anyio
import pytest
from inline_snapshot import snapshot
from mcp_types import (
MISSING_REQUIRED_CLIENT_CAPABILITY,
CallToolResult,
CreateMessageRequest,
CreateMessageRequestParams,
CreateMessageResult,
CreateMessageResultWithTools,
ElicitRequest,
ElicitRequestFormParams,
ElicitRequestParams,
ElicitResult,
InputRequiredResult,
InputResponses,
JSONRPCError,
JSONRPCNotification,
JSONRPCRequest,
ListRootsResult,
Root,
SamplingCapability,
SamplingMessage,
SamplingToolsCapability,
TextContent,
ToolChoice,
)
from mcp_types import (
Tool as SamplingTool,
)
from pydantic import BaseModel, Field, FileUrl, ValidationError, create_model
from typing_extensions import TypeAliasType
from mcp import Client, InputRequiredRoundsExceededError
from mcp.client import ClientRequestContext
from mcp.client._memory import InMemoryTransport
from mcp.server.context import ServerRequestContext
from mcp.server.mcpserver import (
AcceptedElicitation,
AESGCMRequestStateCodec,
CancelledElicitation,
Context,
DeclinedElicitation,
Elicit,
ElicitationResult,
ListRoots,
MCPServer,
RequestStateBoundary,
RequestStateSecurity,
Resolve,
Sample,
)
from mcp.server.mcpserver.exceptions import InvalidSignature
from mcp.server.mcpserver.resolve import (
_check_elicit_return,
_decode_state,
_encode_state,
_outcome_from_state,
_render_request,
_request_digest,
_resolver_key,
_state_key,
_StateEntry,
_uses_input_required,
find_resolved_parameters,
returns_input_required,
)
from mcp.server.mcpserver.tools.base import Tool
from mcp.shared.exceptions import MCPError
from mcp.shared.message import SessionMessage
def _question_digest(elicit: Elicit[Any]) -> str:
"""The digest `_fulfil` pins: the rendered request the client would be shown."""
return _request_digest(_render_request(elicit))
class Login(BaseModel):
username: str
class Confirm(BaseModel):
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):
user_name: str = Field(alias="userName")
class Account(BaseModel):
user_name: str = Field(validation_alias="vUser", serialization_alias="sUser")
async def _alias_login(ctx: Context) -> Login:
return Login(username="x") # pragma: no cover - only the signature is inspected
def _accept(content: dict[str, str | int | float | bool | list[str] | None]):
async def callback(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="accept", content=content)
return callback
async def _decline(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="decline")
async def _never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover
# Declares the form elicitation capability for clients that drive the
# input_required loop manually; the auto-driver never invokes it.
raise AssertionError("should not be called")
async def _text(client: Client, tool: str, args: dict[str, object]) -> str:
result = await client.call_tool(tool, args)
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
return result.content[0].text
def _answer_round(
result: InputRequiredResult, answer: Callable[[str, ElicitRequestFormParams], ElicitResult]
) -> InputResponses:
"""Fulfil every question in one `InputRequiredResult` round via `answer(key, request_params)`."""
assert result.input_requests is not None
responses: InputResponses = {}
for key, req in result.input_requests.items():
assert isinstance(req.params, ElicitRequestFormParams)
responses[key] = answer(key, req.params)
return responses
# Fixed key shared with servers under test, so tests can unseal minted wire
# state and seal crafted state the server will accept.
_PIN_KEY = b"0123456789abcdef0123456789abcdef"
def _unseal_inner(request_state: str | None) -> str:
"""Unseal a wire `request_state` minted under `_PIN_KEY` into the inner plaintext state."""
assert request_state is not None
claims = json.loads(AESGCMRequestStateCodec([_PIN_KEY]).unseal(request_state))
inner = claims["s"]
assert isinstance(inner, str)
return inner
def _outcomes_on_the_wire(request_state: str | None) -> dict[str, Any]:
"""Unseal a wire `request_state` minted under `_PIN_KEY` and return its outcomes."""
return json.loads(_unseal_inner(request_state))["outcomes"]
def _sealed_state(inner: str, *, tool: str, args: dict[str, Any], audience: str) -> str:
"""Seal a hand-built inner state exactly as the boundary does for a `tools/call` retry.
The production `RequestStateBoundary._seal` binds method, tool, arguments, and
audience (the server name), so the test must then call exactly `tool` with
exactly `args` on the MCPServer named `audience`.
"""
ctx = ServerRequestContext(
session=cast("Any", None),
lifespan_context={},
protocol_version="2026-07-28",
method="tools/call",
params={"name": tool, "arguments": args},
)
return RequestStateBoundary(RequestStateSecurity(keys=[_PIN_KEY]), default_audience=audience)._seal(ctx, inner)
def _wire_key(fn: Callable[..., Any]) -> str:
return f"{fn.__module__}:{fn.__qualname__}"
@pytest.mark.anyio
async def test_consumer_receives_result_union_and_branches():
mcp = MCPServer(name="Union", request_state_security=RequestStateSecurity.ephemeral())
async def login(ctx: Context) -> Login | Elicit[Login]:
return Elicit("GitHub username?", Login)
@mcp.tool()
async def whoami(login: Annotated[ElicitationResult[Login], Resolve(login)]) -> str:
match login:
case AcceptedElicitation(data=data):
return f"hi {data.username}"
case _: # pragma: no cover - accepted in this test
return "no username"
async with Client(mcp, mode="legacy", elicitation_callback=_accept({"username": "octocat"})) as client:
assert await _text(client, "whoami", {}) == "hi octocat"
@pytest.mark.anyio
async def test_nested_resolver_sees_dependency_and_tool_args():
mcp = MCPServer(name="Nested", request_state_security=RequestStateSecurity.ephemeral())
async def login(ctx: Context) -> Login | Elicit[Login]:
return Elicit("GitHub username?", Login)
async def confirm(repo: str, login: Annotated[Login, Resolve(login)]) -> Elicit[Confirm]:
return Elicit(f"Star {repo} as {login.username}?", Confirm)
@mcp.tool()
async def star_repo(
repo: str,
login: Annotated[Login, Resolve(login)],
confirm: Annotated[Confirm, Resolve(confirm)],
) -> str:
if confirm.ok:
return f"starred {repo} as {login.username}"
raise NotImplementedError
async def callback(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
if "username" in params.message:
return ElicitResult(action="accept", content={"username": "octocat"})
assert "Star modelcontextprotocol/python-sdk as octocat?" in params.message
return ElicitResult(action="accept", content={"ok": True})
async with Client(mcp, mode="legacy", elicitation_callback=callback) as client:
text = await _text(client, "star_repo", {"repo": "modelcontextprotocol/python-sdk"})
assert text == "starred modelcontextprotocol/python-sdk as octocat"
@pytest.mark.anyio
async def test_resolver_runs_once_for_two_consumers():
mcp = MCPServer(name="ExactlyOnce", request_state_security=RequestStateSecurity.ephemeral())
elicit_count = 0
async def login(ctx: Context) -> Login | Elicit[Login]:
return Elicit("GitHub username?", Login)
async def confirm(login: Annotated[Login, Resolve(login)]) -> Elicit[Confirm]:
return Elicit(f"As {login.username}?", Confirm)
@mcp.tool()
async def star_repo(
login: Annotated[Login, Resolve(login)],
confirm: Annotated[Confirm, Resolve(confirm)],
) -> str:
return f"{login.username}:{confirm.ok}"
async def callback(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
nonlocal elicit_count
if "username" in params.message:
elicit_count += 1
return ElicitResult(action="accept", content={"username": "octocat"})
return ElicitResult(action="accept", content={"ok": True})
async with Client(mcp, mode="legacy", elicitation_callback=callback) as client:
assert await _text(client, "star_repo", {}) == "octocat:True"
assert elicit_count == 1
@pytest.mark.anyio
async def test_sync_resolver():
mcp = MCPServer(name="Sync", request_state_security=RequestStateSecurity.ephemeral())
def login(ctx: Context) -> Login:
return Login(username="sync-user")
@mcp.tool()
async def whoami(login: Annotated[Login, Resolve(login)]) -> str:
return login.username
async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover
raise AssertionError("should not elicit")
async with Client(mcp, mode="legacy", elicitation_callback=never) as client:
assert await _text(client, "whoami", {}) == "sync-user"
def test_cycle_detection_raises_at_registration():
async def a(dep: Login) -> Login:
return dep # pragma: no cover
async def b(dep: Login) -> Login:
return dep # pragma: no cover
# Close the loop after both exist: a depends on b, b depends on a.
a.__annotations__["dep"] = Annotated[Login, Resolve(b)]
b.__annotations__["dep"] = Annotated[Login, Resolve(a)]
async def tool(value: Annotated[Login, Resolve(a)]) -> str:
return value.username # pragma: no cover
with pytest.raises(InvalidSignature, match="cyclic"):
Tool.from_function(tool)
def test_find_resolved_parameters_tolerates_unresolvable_hints():
def fn(x: int) -> int:
return x # pragma: no cover
fn.__annotations__["x"] = "DoesNotExist"
assert find_resolved_parameters(fn) == {}
def test_elicitation_result_alias_resolves_under_postponed_annotations():
# Reproduces the case where `from __future__ import annotations` stringifies
# `Annotated[ElicitationResult[Login], Resolve(_alias_login)]`: the alias must be
# subscriptable so the resolver is detected (not silently dropped) and the
# consumer is recognized as wanting the result union.
def tool(login: str) -> str:
return login # pragma: no cover
tool.__annotations__["login"] = "Annotated[ElicitationResult[Login], Resolve(_alias_login)]"
resolved = find_resolved_parameters(tool)
assert "login" in resolved
assert resolved["login"][1] is True # wants_union
def test_unresolvable_resolver_param_raises_at_registration():
async def login(mystery: int) -> Login:
return Login(username="x") # pragma: no cover
async def tool(login: Annotated[Login, Resolve(login)]) -> str:
return login.username # pragma: no cover
with pytest.raises(InvalidSignature, match="cannot be resolved"):
Tool.from_function(tool)
def test_multiple_elicit_arms_raise_at_registration():
# The runtime can honor only one static question schema per resolver, so an
# ambiguous `-> Elicit[A] | Elicit[B]` must not register (the second arm used
# to be silently ignored).
async def ambiguous(ctx: Context) -> Elicit[Login] | Elicit[Confirm]:
raise NotImplementedError # pragma: no cover
async def tool(login: Annotated[Login, Resolve(ambiguous)]) -> str:
return login.username # pragma: no cover
with pytest.raises(InvalidSignature, match="multiple Elicit/Sample/ListRoots arms"):
Tool.from_function(tool)
def test_resolve_marker_inside_a_union_raises_at_registration():
async def login(ctx: Context) -> Login:
return Login(username="x") # pragma: no cover
async def tool(login: Annotated[Login, Resolve(login)] | None = None) -> str:
return login.username if login else "" # pragma: no cover
with pytest.raises(InvalidSignature, match="wraps `Resolve"):
Tool.from_function(tool)
def test_bare_elicitation_result_alias_wants_the_outcome_union():
# The bare `ElicitationResult` alias (no `[T]` subscription) must still opt into
# the result union, not be treated as wanting the unwrapped model.
async def login(ctx: Context) -> Login:
return Login(username="x") # pragma: no cover
async def tool(login: object) -> str:
return "x" # pragma: no cover
bare_alias: Any = ElicitationResult
tool.__annotations__["login"] = Annotated[bare_alias, Resolve(login)]
(_, wants_union) = find_resolved_parameters(tool)["login"]
assert wants_union is True
def test_resolve_marker_on_return_annotation_is_ignored():
async def login(ctx: Context) -> Login:
return Login(username="x") # pragma: no cover
async def tool(repo: str) -> Annotated[str, Resolve(login)]:
return repo # pragma: no cover
assert find_resolved_parameters(tool) == {}
def test_callable_object_resolver_error_uses_type_name():
class BadResolver:
async def __call__(self, mystery: int) -> Login:
return Login(username="x") # pragma: no cover
async def tool(login: Annotated[Login, Resolve(BadResolver())]) -> str:
return login.username # pragma: no cover
with pytest.raises(InvalidSignature, match="'BadResolver'"):
Tool.from_function(tool)
@pytest.mark.anyio
async def test_by_name_resolver_param_uses_aliased_tool_arg():
mcp = MCPServer(name="Aliased", request_state_security=RequestStateSecurity.ephemeral())
# `schema` collides with a BaseModel attribute, so func_metadata aliases the field;
# the runtime kwarg key is the alias, which is what a by-name resolver must match.
async def upper(schema: str) -> Login:
return Login(username=schema.upper())
@mcp.tool()
async def run(schema: str, shouted: Annotated[Login, Resolve(upper)]) -> str:
return shouted.username
async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover
raise AssertionError("should not elicit")
async with Client(mcp, mode="legacy", elicitation_callback=never) as client:
assert await _text(client, "run", {"schema": "gpt"}) == "GPT"
@pytest.mark.anyio
async def test_resolver_may_return_non_basemodel_value():
mcp = MCPServer(name="NonModel", request_state_security=RequestStateSecurity.ephemeral())
async def get_token(ctx: Context) -> str:
return "secret-token"
@mcp.tool()
async def use_token(token: Annotated[str, Resolve(get_token)]) -> str:
return token
async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover
raise AssertionError("should not elicit")
async with Client(mcp, mode="legacy", elicitation_callback=never) as client:
assert await _text(client, "use_token", {}) == "secret-token"
@pytest.mark.anyio
async def test_resolver_accepts_optional_context_annotation():
mcp = MCPServer(name="OptionalContext", request_state_security=RequestStateSecurity.ephemeral())
async def whoami(ctx: Context | None) -> str:
assert ctx is not None
return "has-context"
@mcp.tool()
async def run(who: Annotated[str, Resolve(whoami)]) -> str:
return who
async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover
raise AssertionError("should not elicit")
async with Client(mcp, mode="legacy", elicitation_callback=never) as client:
assert await _text(client, "run", {}) == "has-context"
@pytest.mark.anyio
async def test_bound_method_resolver_runs_once_across_references():
mcp = MCPServer(name="BoundMethod", request_state_security=RequestStateSecurity.ephemeral())
calls = 0
class Service:
async def token(self, ctx: Context) -> str:
nonlocal calls
calls += 1
return "tok"
service = Service()
# Each `service.token` access is a fresh bound-method object; keying by the
# callable (not id) keeps the resolver memoized to a single call.
async def downstream(token: Annotated[str, Resolve(service.token)]) -> str:
return token.upper()
@mcp.tool()
async def run(
token: Annotated[str, Resolve(service.token)],
shouted: Annotated[str, Resolve(downstream)],
) -> str:
return f"{token}:{shouted}"
async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover
raise AssertionError("should not elicit")
async with Client(mcp, mode="legacy", elicitation_callback=never) as client:
assert await _text(client, "run", {}) == "tok:TOK"
assert calls == 1
def test_bound_method_cycle_is_detected():
class Service:
async def a(self, dep: Login) -> Login:
return dep # pragma: no cover
async def b(self, dep: Login) -> Login:
return dep # pragma: no cover
service = Service()
service.a.__func__.__annotations__["dep"] = Annotated[Login, Resolve(service.b)]
service.b.__func__.__annotations__["dep"] = Annotated[Login, Resolve(service.a)]
async def tool(value: Annotated[Login, Resolve(service.a)]) -> str:
return value.username # pragma: no cover
with pytest.raises(InvalidSignature, match="cyclic"):
Tool.from_function(tool)
@pytest.mark.anyio
async def test_resolver_and_body_see_the_same_validated_default():
mcp = MCPServer(name="DefaultFactory", request_state_security=RequestStateSecurity.ephemeral())
counter = {"n": 0}
def next_id() -> int:
counter["n"] += 1
return counter["n"]
# A by-name resolver and the tool body must observe one validation pass, so the
# `default_factory` runs once and both see the same generated value.
async def echo_id(request_id: int) -> int:
return request_id
@mcp.tool()
async def run(
request_id: Annotated[int, Field(default_factory=next_id)],
resolved_id: Annotated[int, Resolve(echo_id)],
) -> str:
return f"{request_id}:{resolved_id}"
async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover
raise AssertionError("should not elicit")
async with Client(mcp, mode="legacy", elicitation_callback=never) as client:
assert await _text(client, "run", {}) == "1:1"
assert counter["n"] == 1
def test_resolver_key_is_stable_for_methods_and_distinct_callables():
class Service:
def handler(self) -> None: ... # pragma: no cover
a, b = Service(), Service()
# Pure-python bound methods: stable across accesses, distinct per instance.
assert _resolver_key(a.handler) == _resolver_key(a.handler)
assert _resolver_key(a.handler) != _resolver_key(b.handler)
# Built-in bound methods (no `__func__`): fresh object each access, but the key
# is stable and keyed to `__self__`.
items: list[int] = []
others: list[int] = []
assert _resolver_key(items.append) == _resolver_key(items.append)
assert _resolver_key(items.append) != _resolver_key(others.append)
assert _resolver_key(items.append) != _resolver_key(items.pop)
# Plain functions key by identity.
def fn() -> None: ... # pragma: no cover
assert _resolver_key(fn) == _resolver_key(fn)
def _delete_folder_server() -> tuple[MCPServer, dict[str, list[str]]]:
"""The `delete_folder` example from docs/migration.md, wired to an in-memory fs."""
mcp = MCPServer(name="files", request_state_security=RequestStateSecurity.ephemeral())
fs: dict[str, list[str]] = {}
async def confirm_delete(path: str) -> Confirm | Elicit[Confirm]:
file_count = len(fs.get(path, []))
if file_count == 0:
return Confirm(ok=True)
return Elicit(f"{path} has {file_count} file(s). Delete anyway?", Confirm)
@mcp.tool()
async def delete_folder(
path: str,
confirm: Annotated[ElicitationResult[Confirm], Resolve(confirm_delete)],
) -> str:
match confirm:
case AcceptedElicitation(data=Confirm(ok=True)):
fs.pop(path, None)
return f"deleted {path}"
case AcceptedElicitation():
return "kept the folder"
case DeclinedElicitation():
return "declined: folder not deleted"
case CancelledElicitation(): # pragma: no branch
return "cancelled: folder not deleted"
return mcp, fs
@pytest.mark.anyio
async def test_delete_empty_folder_does_not_elicit():
mcp, fs = _delete_folder_server()
fs["/empty"] = []
async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover
raise AssertionError("should not elicit for an empty folder")
async with Client(mcp, mode="legacy", elicitation_callback=never) as client:
assert await _text(client, "delete_folder", {"path": "/empty"}) == "deleted /empty"
assert "/empty" not in fs
@pytest.mark.anyio
@pytest.mark.parametrize(
("action", "content", "expected"),
[
("accept", {"ok": True}, "deleted /docs"),
("accept", {"ok": False}, "kept the folder"),
("decline", None, "declined: folder not deleted"),
("cancel", None, "cancelled: folder not deleted"),
],
)
async def test_delete_non_empty_folder_handles_every_outcome(
action: Literal["accept", "decline", "cancel"],
content: dict[str, str | int | float | bool | list[str] | None] | None,
expected: str,
):
mcp, fs = _delete_folder_server()
fs["/docs"] = ["a.txt", "b.txt"]
async def callback(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
assert "/docs has 2 file(s)" in params.message
return ElicitResult(action=action, content=content)
async with Client(mcp, mode="legacy", elicitation_callback=callback) as client:
assert await _text(client, "delete_folder", {"path": "/docs"}) == expected
assert ("/docs" in fs) is (expected != "deleted /docs")
@pytest.mark.anyio
async def test_input_required_first_round_returns_the_question():
mcp, fs = _delete_folder_server()
fs["/docs"] = ["a.txt", "b.txt"]
async with Client(mcp, elicitation_callback=_never) as client: # mode="auto" negotiates 2026-07-28
assert client.session.protocol_version == "2026-07-28"
result = await client.session.call_tool("delete_folder", {"path": "/docs"}, allow_input_required=True)
assert isinstance(result, InputRequiredResult)
assert result.input_requests is not None
(request,) = result.input_requests.values()
assert request.method == "elicitation/create"
assert "/docs has 2 file(s)" in request.params.message
assert result.request_state is not None
assert "/docs" in fs # nothing deleted before the answer arrives
@pytest.mark.anyio
@pytest.mark.parametrize(
("action", "content", "expected"),
[
("accept", {"ok": True}, "deleted /docs"),
("accept", {"ok": False}, "kept the folder"),
("decline", None, "declined: folder not deleted"),
("cancel", None, "cancelled: folder not deleted"),
],
)
async def test_input_required_loop_handles_every_outcome(
action: Literal["accept", "decline", "cancel"],
content: dict[str, str | int | float | bool | list[str] | None] | None,
expected: str,
):
# End-to-end at 2026-07-28: the client's auto-driver answers the embedded
# elicitation through the ordinary `elicitation_callback` and retries.
mcp, fs = _delete_folder_server()
fs["/docs"] = ["a.txt", "b.txt"]
async def callback(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
assert "/docs has 2 file(s)" in params.message
return ElicitResult(action=action, content=content)
async with Client(mcp, elicitation_callback=callback) as client: # mode="auto" negotiates 2026-07-28
result = await client.call_tool("delete_folder", {"path": "/docs"})
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == expected
assert ("/docs" in fs) is (expected != "deleted /docs")
@pytest.mark.anyio
async def test_input_required_empty_folder_completes_without_eliciting():
mcp, fs = _delete_folder_server()
fs["/empty"] = []
async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover
raise AssertionError("should not elicit for an empty folder")
async with Client(mcp, elicitation_callback=never) as client:
result = await client.call_tool("delete_folder", {"path": "/empty"})
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "deleted /empty"
assert "/empty" not in fs
@pytest.mark.anyio
async def test_input_required_asks_each_question_once_while_bodies_rerun():
mcp = MCPServer(name="ExactlyOnceMRTR", request_state_security=RequestStateSecurity.ephemeral())
counts = {"login": 0, "confirm": 0}
async def login(ctx: Context) -> Login | Elicit[Login]:
counts["login"] += 1
return Elicit("Username?", Login)
async def confirm(login: Annotated[Login, Resolve(login)]) -> Elicit[Confirm]:
counts["confirm"] += 1
return Elicit(f"As {login.username}?", Confirm)
@mcp.tool()
async def act(
login: Annotated[Login, Resolve(login)],
confirm: Annotated[Confirm, Resolve(confirm)],
) -> str:
return f"{login.username}:{confirm.ok}"
asked: list[str] = []
async def callback(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
asked.append(params.message)
if "Username" in params.message:
return ElicitResult(action="accept", content={"username": "octocat"})
return ElicitResult(action="accept", content={"ok": True})
async with Client(mcp, elicitation_callback=callback) as client:
result = await client.call_tool("act", {})
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "octocat:True"
# `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.
assert asked == ["Username?", "As octocat?"]
# The once-per-call guarantee is about the question, not the body: a recorded
# answer is consulted only after the body asks again, so `login` runs on every
# round the call passes through (asks in round 1, consumes its answer in round 2,
# re-asks-and-restores in round 3) while the user is prompted exactly once.
# `confirm` only forms its question once `login` is known: it asks in round 2
# and consumes in round 3.
assert counts == {"login": 3, "confirm": 2}
@pytest.mark.anyio
async def test_input_required_batches_independent_elicits_in_one_round():
mcp = MCPServer(name="BatchedMRTR", request_state_security=RequestStateSecurity.ephemeral())
async def ask_name(ctx: Context) -> Elicit[Login]:
return Elicit("Name?", Login)
async def ask_confirm(ctx: Context) -> Elicit[Confirm]:
return Elicit("Confirm?", Confirm)
@mcp.tool()
async def both(
name: Annotated[Login, Resolve(ask_name)],
confirm: Annotated[Confirm, Resolve(ask_confirm)],
) -> str:
return f"{name.username}:{confirm.ok}"
def answer(key: str, params: ElicitRequestFormParams) -> ElicitResult:
if "Name" in params.message:
return ElicitResult(action="accept", content={"username": "octocat"})
return ElicitResult(action="accept", content={"ok": True})
async with Client(mcp, elicitation_callback=_never) as client:
# Both independent resolvers are asked together in the first round.
first = await client.session.call_tool("both", {}, allow_input_required=True)
assert isinstance(first, InputRequiredResult)
assert first.input_requests is not None
assert len(first.input_requests) == 2
# Answering both and echoing `request_state` completes in a single retry.
final = await client.session.call_tool(
"both",
{},
input_responses=_answer_round(first, answer),
request_state=first.request_state,
allow_input_required=True,
)
assert isinstance(final, CallToolResult)
assert isinstance(final.content[0], TextContent)
assert final.content[0].text == "octocat:True"
@pytest.mark.anyio
async def test_auto_driver_answers_independent_questions_in_a_single_round():
# The pure `count_round` resolver is never persisted in `request_state`, so it
# re-runs on every round: its run count is the number of rounds the call took.
mcp = MCPServer(name="AutoBatch", request_state_security=RequestStateSecurity.ephemeral())
rounds = 0
async def count_round(ctx: Context) -> int:
nonlocal rounds
rounds += 1
return rounds
async def ask_name(ctx: Context) -> Elicit[Login]:
return Elicit("Name?", Login)
async def ask_confirm(ctx: Context) -> Elicit[Confirm]:
return Elicit("Confirm?", Confirm)
@mcp.tool()
async def both(
round_no: Annotated[int, Resolve(count_round)],
name: Annotated[Login, Resolve(ask_name)],
confirm: Annotated[Confirm, Resolve(ask_confirm)],
) -> str:
return f"{name.username}:{confirm.ok}"
asked: list[str] = []
async def callback(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
asked.append(params.message)
if "Name" in params.message:
return ElicitResult(action="accept", content={"username": "octocat"})
return ElicitResult(action="accept", content={"ok": True})
async with Client(mcp, elicitation_callback=callback) as client:
result = await client.call_tool("both", {})
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "octocat:True"
# The driver dispatches batched questions concurrently, so order is unspecified.
assert sorted(asked) == ["Confirm?", "Name?"] # both questions, each exactly once
assert rounds == 2 # one question round, then the completing round
def test_uses_input_required_version_gate():
assert _uses_input_required("2026-07-28") is True
assert _uses_input_required("2025-11-25") is False
assert _uses_input_required(None) is False
@pytest.mark.parametrize(
"request_state",
[
None,
"",
"not json",
'{"v": 99, "outcomes": {}}', # wrong version
'{"v": 1}', # missing outcomes
'{"v": 1, "outcomes": []}', # outcomes not a dict
"[1, 2, 3]", # not an object
],
)
def test_decode_state_tolerates_malformed_request_state(request_state: str | None):
state = _decode_state(request_state)
assert state.outcomes == {} and state.asked == {}
def test_state_round_trips_accept_decline_cancel():
entries = {
"a": _StateEntry(action="accept", data={"username": "octocat"}),
"b": _StateEntry(action="decline"),
"c": _StateEntry(action="cancel"),
"d": _StateEntry(action="accept", data="raw-token"), # non-dict wire value
}
state = _decode_state(_encode_state(entries, {"e": "asked-digest"}))
decoded = state.outcomes
assert decoded == entries # encode-restore is the identity on the stored entries
assert state.asked == {"e": "asked-digest"}
ask = Elicit("q", Login)
accepted = _outcome_from_state(decoded["a"], ask)
assert isinstance(accepted, AcceptedElicitation) and accepted.data == Login(username="octocat")
# Decline/cancel entries carry no data; the schema is not consulted for them.
assert isinstance(_outcome_from_state(decoded["b"], ask), DeclinedElicitation)
assert isinstance(_outcome_from_state(decoded["c"], ask), CancelledElicitation)
# 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"], ask)
def test_check_elicit_return_allows_one_arm_and_rejects_two():
_check_elicit_return(Elicit[Login], "r") # bare Elicit[T]
_check_elicit_return(Login | Elicit[Login], "r") # union arm
_check_elicit_return(Login, "r") # no Elicit arm
_check_elicit_return(None, "r") # unannotated
# A resolver asks one question: two distinct Elicit arms mean it should be split.
with pytest.raises(InvalidSignature, match="'r' return annotation has multiple Elicit/Sample/ListRoots arms"):
_check_elicit_return(Elicit[Login] | Elicit[Confirm], "r")
@pytest.mark.anyio
async def test_non_elicitation_response_raises():
mcp = MCPServer(name="WrongResponse", request_state_security=RequestStateSecurity.ephemeral())
async def ask(ctx: Context) -> Elicit[Login]:
return Elicit("Name?", Login)
@mcp.tool()
async def tool(name: Annotated[Login, Resolve(ask)]) -> str:
return name.username # pragma: no cover
async with Client(mcp, elicitation_callback=_never) as client:
r1 = await client.session.call_tool("tool", {}, allow_input_required=True)
assert isinstance(r1, InputRequiredResult)
assert r1.input_requests is not None
(key,) = r1.input_requests
# Answer with a sampling result instead of an elicitation result.
r2 = await client.session.call_tool(
"tool",
{},
input_responses={
key: CreateMessageResult(role="assistant", content=TextContent(type="text", text="x"), model="m")
},
request_state=r1.request_state,
allow_input_required=True,
)
assert isinstance(r2, CallToolResult)
assert r2.is_error
assert isinstance(r2.content[0], TextContent)
assert "non-elicitation response" in r2.content[0].text
@pytest.mark.anyio
async def test_direct_call_tool_with_non_eliciting_resolver():
# `MCPServer.call_tool()` called directly builds a Context with no request, so
# `ctx.protocol_version` is None. A tool whose resolvers never elicit must still
# work there (regression: it used to raise "Context is not available").
mcp = MCPServer(name="Direct", request_state_security=RequestStateSecurity.ephemeral())
async def whoami(ctx: Context) -> Login:
return Login(username="direct")
@mcp.tool()
async def tool(login: Annotated[Login, Resolve(whoami)]) -> str:
return login.username
result = await mcp.call_tool("tool", {}, Context(mcp_server=mcp))
assert isinstance(result, CallToolResult)
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "direct"
@pytest.mark.anyio
async def test_two_instances_of_one_method_do_not_collide():
mcp = MCPServer(name="Instances", request_state_security=RequestStateSecurity.ephemeral())
class Service:
def __init__(self, name: str) -> None:
self.name = name
async def who(self, ctx: Context) -> Login:
return Login(username=self.name)
alice, bob = Service("alice"), Service("bob")
@mcp.tool()
async def both(
a: Annotated[Login, Resolve(alice.who)],
b: Annotated[Login, Resolve(bob.who)],
) -> str:
return f"{a.username},{b.username}"
result = await mcp.call_tool("both", {}, Context(mcp_server=mcp))
assert isinstance(result, CallToolResult)
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "alice,bob"
@pytest.mark.anyio
async def test_non_serializable_sibling_resolver_does_not_break_rounds():
mcp = MCPServer(name="NonSerializable", request_state_security=RequestStateSecurity.ephemeral())
async def clock(ctx: Context) -> datetime:
return datetime(2026, 1, 1)
async def ask(ctx: Context) -> Elicit[Confirm]:
return Elicit("ok?", Confirm)
@mcp.tool()
async def act(
when: Annotated[datetime, Resolve(clock)],
confirm: Annotated[Confirm, Resolve(ask)],
) -> str:
return f"{when.year}:{confirm.ok}"
async def callback(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="accept", content={"ok": True})
async with Client(mcp, elicitation_callback=callback) as client:
result = await client.call_tool("act", {})
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "2026:True"
@pytest.mark.anyio
async def test_bare_elicit_dependency_restored_as_model():
# A `-> Elicit[Login]` (bare, no union) resolver feeds a dependent resolver. After
# the round-trip the dependency must come back as a Login model, not a raw dict.
mcp = MCPServer(name="BareElicitDep", request_state_security=RequestStateSecurity.ephemeral())
async def login(ctx: Context) -> Elicit[Login]:
return Elicit("user?", Login)
async def confirm(login: Annotated[Login, Resolve(login)]) -> Elicit[Confirm]:
return Elicit(f"as {login.username}?", Confirm)
@mcp.tool()
async def act(
login: Annotated[Login, Resolve(login)],
confirm: Annotated[Confirm, Resolve(confirm)],
) -> str:
return f"{login.username}:{confirm.ok}"
async def callback(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
if "user" in params.message:
return ElicitResult(action="accept", content={"username": "octocat"})
assert "as octocat?" in params.message # proves login was a real model
return ElicitResult(action="accept", content={"ok": True})
async with Client(mcp, elicitation_callback=callback) as client:
result = await client.call_tool("act", {})
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "octocat:True"
@pytest.mark.anyio
@pytest.mark.parametrize("mode", ["legacy", "auto"])
async def test_accept_with_no_content_is_an_error_not_a_cancel(mode: Literal["legacy", "auto"]):
# Both transports must agree: mode="legacy" elicits synchronously mid-call,
# mode="auto" rides the 2026-07-28 input_required loop.
mcp = MCPServer(name="AcceptNoContent", request_state_security=RequestStateSecurity.ephemeral())
async def ask(ctx: Context) -> Elicit[Login]:
return Elicit("user?", Login)
@mcp.tool()
async def tool(login: Annotated[Login, Resolve(ask)]) -> str:
return login.username # pragma: no cover
async def empty_accept(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="accept", content=None)
async with Client(mcp, mode=mode, elicitation_callback=empty_accept) as client:
result = await client.call_tool("tool", {})
assert result.is_error
assert isinstance(result.content[0], TextContent)
assert "no content" in result.content[0].text
@pytest.mark.anyio
async def test_independent_nested_deps_batch_into_one_round():
mcp = MCPServer(name="NestedBatch", request_state_security=RequestStateSecurity.ephemeral())
async def ask_a(ctx: Context) -> Elicit[Login]:
return Elicit("A name?", Login)
async def ask_b(ctx: Context) -> Elicit[Confirm]:
return Elicit("B confirm?", Confirm)
# `combine` depends on two independent eliciting resolvers; both must be asked
# in the same round, not serialized across two InputRequiredResult rounds.
async def combine(
a: Annotated[Login, Resolve(ask_a)],
b: Annotated[Confirm, Resolve(ask_b)],
) -> Login:
return Login(username=f"{a.username}:{b.ok}")
@mcp.tool()
async def tool(combined: Annotated[Login, Resolve(combine)]) -> str:
return combined.username
def answer(key: str, params: ElicitRequestFormParams) -> ElicitResult:
if "name" in params.message:
return ElicitResult(action="accept", content={"username": "octocat"})
return ElicitResult(action="accept", content={"ok": True})
async with Client(mcp, elicitation_callback=_never) as client:
first = await client.session.call_tool("tool", {}, allow_input_required=True)
assert isinstance(first, InputRequiredResult)
assert first.input_requests is not None
assert len(first.input_requests) == 2 # batched, not serialized
final = await client.session.call_tool(
"tool",
{},
input_responses=_answer_round(first, answer),
request_state=first.request_state,
allow_input_required=True,
)
assert isinstance(final, CallToolResult)
assert isinstance(final.content[0], TextContent)
assert final.content[0].text == "octocat:True"
@pytest.mark.anyio
async def test_deep_chain_keeps_early_answers_across_rounds():
# A 4-round dependency chain where an early answer (A) must survive in
# request_state while later resolvers are asked. It must be asked exactly once.
mcp = MCPServer(name="DeepChain", request_state_security=RequestStateSecurity.ephemeral())
async def ra(ctx: Context) -> Elicit[Login]:
return Elicit("A name?", Login)
async def rb(a: Annotated[Login, Resolve(ra)]) -> Elicit[Confirm]:
return Elicit("B?", Confirm)
async def rc(b: Annotated[Confirm, Resolve(rb)]) -> Elicit[Confirm]:
return Elicit("C?", Confirm)
async def rd(c: Annotated[Confirm, Resolve(rc)]) -> Elicit[Confirm]:
return Elicit("D?", Confirm)
# Depends on `ra` directly AND on `rd` (which transitively needs ra->rb->rc).
@mcp.tool()
async def tool(
a: Annotated[Login, Resolve(ra)],
d: Annotated[Confirm, Resolve(rd)],
) -> str:
return f"{a.username}:{d.ok}"
a_asks = 0
async def callback(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
nonlocal a_asks
if "name" in params.message:
a_asks += 1
return ElicitResult(action="accept", content={"username": "octocat"})
return ElicitResult(action="accept", content={"ok": True})
async with Client(mcp, elicitation_callback=callback) as client:
result = await client.call_tool("tool", {})
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "octocat:True"
assert a_asks == 1 # ra's answer survived in request_state; never re-asked
@pytest.mark.anyio
async def test_factory_closures_get_distinct_wire_keys():
# Two resolvers from one factory share module:qualname; they must still get
# distinct questions and their own values (regression: they collided on the wire).
mcp = MCPServer(name="FactoryClosures", request_state_security=RequestStateSecurity.ephemeral())
def make(label: str):
async def resolver(ctx: Context) -> Elicit[Login]:
return Elicit(f"{label}?", Login)
return resolver
ask_a, ask_b = make("A"), make("B")
@mcp.tool()
async def tool(
a: Annotated[Login, Resolve(ask_a)],
b: Annotated[Login, Resolve(ask_b)],
) -> str:
return f"{a.username},{b.username}"
def answer(key: str, params: ElicitRequestFormParams) -> ElicitResult:
return ElicitResult(action="accept", content={"username": params.message[0]})
async with Client(mcp, elicitation_callback=_never) as client:
first = await client.session.call_tool("tool", {}, allow_input_required=True)
assert isinstance(first, InputRequiredResult)
assert first.input_requests is not None
assert len(first.input_requests) == 2 # distinct keys, not collapsed to one
final = await client.session.call_tool(
"tool",
{},
input_responses=_answer_round(first, answer),
request_state=first.request_state,
allow_input_required=True,
)
assert isinstance(final, CallToolResult)
assert isinstance(final.content[0], TextContent)
assert final.content[0].text == "A,B"
@pytest.mark.anyio
async def test_eliciting_resolver_without_elicit_arm_restores_a_typed_model():
# A resolver annotated `-> object` that actually returns `Elicit(...)` declares
# no `Elicit[T]` return arm. Its answer, restored from request_state in a 3+
# round flow, must still come back as a Login model (not a raw dict): restore
# 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", request_state_security=RequestStateSecurity.ephemeral())
# Annotated without an `Elicit[T]` return arm; the body asks anyway.
async def login(ctx: Context) -> object:
return Elicit("user?", Login)
async def confirm(login: Annotated[Login, Resolve(login)]) -> Elicit[Confirm]:
return Elicit(f"as {login.username}?", Confirm)
@mcp.tool()
async def act(
login: Annotated[Login, Resolve(login)],
confirm: Annotated[Confirm, Resolve(confirm)],
) -> str:
return f"{login.username}:{confirm.ok}"
async def callback(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
if "user" in params.message:
return ElicitResult(action="accept", content={"username": "octocat"})
assert "as octocat?" in params.message # login restored as a real model
return ElicitResult(action="accept", content={"ok": True})
async with Client(mcp, elicitation_callback=callback) as client:
result = await client.call_tool("act", {})
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "octocat:True"
def test_wire_key_is_worker_stable_for_methods_and_callable_objects():
class Service:
async def token(self, ctx: Context) -> Login:
return Login(username="x") # pragma: no cover
class CallableResolver:
async def __call__(self, ctx: Context) -> Login:
return Login(username="x") # pragma: no cover
a, b = Service(), Service()
# No id(...) in the key: two instances of one method get the same base (they are
# disambiguated at registration, not here), and the key carries no memory address.
assert _state_key(a.token) == _state_key(b.token)
assert "#" not in _state_key(a.token)
assert _state_key(a.token).endswith("Service.token")
# Callable objects key by their type's qualname (they have no `__qualname__`).
assert _state_key(CallableResolver()).endswith("CallableResolver")
@pytest.mark.anyio
async def test_declined_outcome_persists_in_request_state_and_is_not_reasked():
# A decline is recorded in `request_state` just like an accept: RB elicits only
# after seeing RA's decline, so RA's outcome must survive into the round that
# answers RB without RA being asked again.
mcp = MCPServer(name="DeclinePersists", request_state_security=RequestStateSecurity(keys=[_PIN_KEY]))
async def ra(ctx: Context) -> Elicit[Login]:
return Elicit("user?", Login)
async def rb(a: Annotated[ElicitationResult[Login], Resolve(ra)]) -> Elicit[Confirm]:
assert isinstance(a, DeclinedElicitation)
return Elicit("proceed anonymously?", Confirm)
@mcp.tool()
async def act(
a: Annotated[ElicitationResult[Login], Resolve(ra)],
c: Annotated[Confirm, Resolve(rb)],
) -> str:
assert isinstance(a, DeclinedElicitation)
return f"anonymous:{c.ok}"
async with Client(mcp, elicitation_callback=_never) as client:
first = await client.session.call_tool("act", {}, allow_input_required=True)
assert isinstance(first, InputRequiredResult)
assert first.input_requests is not None
(ra_key,) = first.input_requests
second = await client.session.call_tool(
"act",
{},
input_responses={ra_key: ElicitResult(action="decline")},
request_state=first.request_state,
allow_input_required=True,
)
assert isinstance(second, InputRequiredResult)
assert second.input_requests is not None
(rb_key,) = second.input_requests # only RB's question; RA is not re-asked
assert rb_key != ra_key
assert _outcomes_on_the_wire(second.request_state)[ra_key]["action"] == "decline"
final = await client.session.call_tool(
"act",
{},
input_responses={rb_key: ElicitResult(action="accept", content={"ok": True})},
request_state=second.request_state,
allow_input_required=True,
)
assert isinstance(final, CallToolResult)
assert isinstance(final.content[0], TextContent)
assert final.content[0].text == "anonymous:True"
@pytest.mark.anyio
async def test_unknown_response_keys_and_ghost_state_entries_are_ignored():
# `input_responses` keys the server never asked for and `request_state` outcome
# entries matching no resolver are tolerated and not echoed into later rounds.
mcp = MCPServer(name="GhostKeys", request_state_security=RequestStateSecurity(keys=[_PIN_KEY]))
async def ra(ctx: Context) -> Elicit[Login]:
return Elicit("user?", Login)
async def rb(a: Annotated[Login, Resolve(ra)]) -> Elicit[Confirm]:
return Elicit(f"as {a.username}?", Confirm)
@mcp.tool()
async def act(
a: Annotated[Login, Resolve(ra)],
c: Annotated[Confirm, Resolve(rb)],
) -> str:
return f"{a.username}:{c.ok}"
async with Client(mcp, elicitation_callback=_never) as client:
first = await client.session.call_tool("act", {}, allow_input_required=True)
assert isinstance(first, InputRequiredResult)
assert first.input_requests is not None
assert first.request_state is not None
(ra_key,) = first.input_requests
spliced = json.loads(_unseal_inner(first.request_state))
# A well-formed v2 entry under an unknown key: dropped as unknown, not as malformed.
spliced["outcomes"]["ghost"] = {
"action": "accept",
"data": {"username": "spooky"},
"q": _question_digest(Elicit("user?", Login)),
}
second = await client.session.call_tool(
"act",
{},
input_responses={
ra_key: ElicitResult(action="accept", content={"username": "octocat"}),
"ghost": ElicitResult(action="accept", content={"username": "spooky"}),
},
request_state=_sealed_state(json.dumps(spliced), tool="act", args={}, audience="GhostKeys"),
allow_input_required=True,
)
assert isinstance(second, InputRequiredResult)
assert second.input_requests is not None
(rb_key,) = second.input_requests
outcomes = _outcomes_on_the_wire(second.request_state)
assert ra_key in outcomes
assert "ghost" not in outcomes # the spliced entry is dropped, not carried onward
final = await client.session.call_tool(
"act",
{},
input_responses={rb_key: ElicitResult(action="accept", content={"ok": True})},
request_state=second.request_state,
allow_input_required=True,
)
assert isinstance(final, CallToolResult)
assert isinstance(final.content[0], TextContent)
assert final.content[0].text == "octocat:True"
@pytest.mark.anyio
@pytest.mark.parametrize(
"forged_data",
[
pytest.param("not-a-dict", id="non-dict-data"),
pytest.param({"hacked": True}, id="dict-failing-schema"),
],
)
async def test_forged_state_entry_failing_the_schema_is_reasked_not_an_error(forged_data: str | dict[str, bool]):
# Authenticated state is not schema-trusted: a failing accept entry reads as no progress and is re-asked.
mcp = MCPServer(name="ForgedState", request_state_security=RequestStateSecurity(keys=[_PIN_KEY]))
async def ask(ctx: Context) -> Elicit[Login]:
return Elicit("user?", Login)
@mcp.tool()
async def whoami(login: Annotated[Login, Resolve(ask)]) -> str:
return login.username
async with Client(mcp, elicitation_callback=_never) as client:
first = await client.session.call_tool("whoami", {}, allow_input_required=True)
assert isinstance(first, InputRequiredResult)
assert first.input_requests is not None
assert first.request_state is not None
(key,) = first.input_requests
forged = json.loads(_unseal_inner(first.request_state))
# The digest matches the live question, so the entry stands or falls on schema alone.
forged["outcomes"][key] = {
"action": "accept",
"data": forged_data,
"q": _question_digest(Elicit("user?", Login)),
}
second = await client.session.call_tool(
"whoami",
{},
request_state=_sealed_state(json.dumps(forged), tool="whoami", args={}, audience="ForgedState"),
allow_input_required=True,
)
assert isinstance(second, InputRequiredResult) # re-asked, not an error
assert second.input_requests is not None
assert set(second.input_requests) == {key}
assert _outcomes_on_the_wire(second.request_state) == {} # the forged entry is dropped
final = await client.session.call_tool(
"whoami",
{},
input_responses={key: ElicitResult(action="accept", content={"username": "octocat"})},
request_state=second.request_state,
allow_input_required=True,
)
assert isinstance(final, CallToolResult)
assert isinstance(final.content[0], TextContent)
assert final.content[0].text == "octocat"
@pytest.mark.anyio
@pytest.mark.parametrize("mode", ["legacy", "auto"])
async def test_schema_mismatched_fresh_answer_fails_the_call_without_pydantic_leakage(mode: Literal["legacy", "auto"]):
# An accepted answer whose content fails the requested schema fails the call
# with the framework's own message on both transports; pydantic's error text
# (which carries an "errors.pydantic.dev" link) must not leak to the client.
mcp = MCPServer(name="MismatchedAnswer", request_state_security=RequestStateSecurity.ephemeral())
async def ask(ctx: Context) -> Elicit[Login]:
return Elicit("user?", Login)
@mcp.tool()
async def whoami(login: Annotated[Login, Resolve(ask)]) -> str:
raise NotImplementedError # pragma: no cover - the mismatched answer never reaches the body
async with Client(mcp, mode=mode, elicitation_callback=_accept({"nope": "x"})) as client:
result = await client.call_tool("whoami", {})
assert result.is_error
assert isinstance(result.content[0], TextContent)
text = result.content[0].text
assert "does not match the requested schema" in text
assert "errors.pydantic.dev" not in text
if mode == "auto":
assert "Resolver" in text # the input_required transport names the offending resolver key
else:
assert "Received an accepted elicitation" in text # the legacy path has no wire key to name
@pytest.mark.anyio
async def test_auto_driver_gives_up_when_the_chain_outlasts_its_round_budget():
# A dependency chain of 11 eliciting resolvers needs 11 retry rounds, one more
# than the default `input_required_max_rounds`, so `client.call_tool` must raise
# rather than loop on. The pure `count_leg` resolver is never persisted, so it
# re-runs on every server leg: its final value is the exact number of legs.
mcp = MCPServer(name="TooDeep", request_state_security=RequestStateSecurity.ephemeral())
legs = 0
async def count_leg(ctx: Context) -> int:
nonlocal legs
legs += 1
return legs
async def root(ctx: Context) -> Elicit[Confirm]:
return Elicit("Q1?", Confirm)
def extend(dep: Callable[..., Any], n: int) -> Callable[..., Any]:
async def link(prev: Annotated[Confirm, Resolve(dep)]) -> Elicit[Confirm]:
return Elicit(f"Q{n}?", Confirm)
return link
chain: Callable[..., Any] = root
for n in range(2, 12): # 11 eliciting resolvers in total
chain = extend(chain, n)
@mcp.tool()
async def long_haul(
leg: Annotated[int, Resolve(count_leg)],
last: Annotated[Confirm, Resolve(chain)],
) -> str:
raise NotImplementedError # pragma: no cover - the driver gives up first
answered = 0
async def callback(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
nonlocal answered
answered += 1
return ElicitResult(action="accept", content={"ok": True})
async with Client(mcp, elicitation_callback=callback) as client:
with anyio.fail_after(5): # the loop must end by raising, not spin on retries
with pytest.raises(InputRequiredRoundsExceededError) as exc_info:
await client.call_tool("long_haul", {})
assert exc_info.value.max_rounds == client.input_required_max_rounds
assert answered == client.input_required_max_rounds # one question answered per retry round
assert legs == client.input_required_max_rounds + 1 # the initial call plus one leg per retry
@pytest.mark.anyio
async def test_aliased_elicitation_model_round_trips_through_request_state():
# The stored entry is the client's raw wire content, so it restores through
# the same validation the answer originally passed - aliases and all. A
# re-derived (field-name) shape would fail validation on the round after
# next, drop the stored answer, and re-ask the user forever.
mcp = MCPServer(name="AliasState", request_state_security=RequestStateSecurity.ephemeral())
async def who(ctx: Context) -> Elicit[Handle]:
return Elicit("handle?", Handle)
async def confirm(h: Annotated[Handle, Resolve(who)]) -> Elicit[Confirm]:
return Elicit(f"go as {h.user_name}?", Confirm)
@mcp.tool()
async def act(
h: Annotated[Handle, Resolve(who)],
c: Annotated[Confirm, Resolve(confirm)],
) -> str:
return f"{h.user_name}:{c.ok}"
async with Client(mcp, elicitation_callback=_never) as client:
first = await client.session.call_tool("act", {}, allow_input_required=True)
assert isinstance(first, InputRequiredResult)
assert first.input_requests is not None
(who_key,) = first.input_requests
second = await client.session.call_tool(
"act",
{},
input_responses={who_key: ElicitResult(action="accept", content={"userName": "octocat"})},
request_state=first.request_state,
allow_input_required=True,
)
assert isinstance(second, InputRequiredResult)
assert second.input_requests is not None
(confirm_key,) = second.input_requests # only the dependent question; the stored answer holds
assert confirm_key != who_key
final = await client.session.call_tool(
"act",
{},
input_responses={confirm_key: ElicitResult(action="accept", content={"ok": True})},
request_state=second.request_state,
allow_input_required=True,
)
assert isinstance(final, CallToolResult)
assert isinstance(final.content[0], TextContent)
assert final.content[0].text == "octocat:True"
@pytest.mark.anyio
async def test_divergent_validation_and_serialization_aliases_round_trip():
# `request_state` must carry the client's answer exactly as it was sent: the
# rendered question is validation-aliased, so re-deriving the stored shape from
# the validated model (which serializes under the *serialization* alias) would
# produce data the schema's own validation rejects, dropping the stored answer
# on the round after next and re-asking the user.
mcp = MCPServer(name="DivergentAliases", request_state_security=RequestStateSecurity(keys=[_PIN_KEY]))
async def who(ctx: Context) -> Elicit[Account]:
return Elicit("account?", Account)
async def confirm(a: Annotated[Account, Resolve(who)]) -> Elicit[Confirm]:
return Elicit(f"go as {a.user_name}?", Confirm)
@mcp.tool()
async def act(
a: Annotated[Account, Resolve(who)],
c: Annotated[Confirm, Resolve(confirm)],
) -> str:
return f"{a.user_name}:{c.ok}"
async with Client(mcp, elicitation_callback=_never) as client:
first = await client.session.call_tool("act", {}, allow_input_required=True)
assert isinstance(first, InputRequiredResult)
assert first.input_requests is not None
(who_key,) = first.input_requests
question = first.input_requests[who_key].params
assert isinstance(question, ElicitRequestFormParams)
assert "vUser" in question.requested_schema["properties"] # the client answers validation-aliased
second = await client.session.call_tool(
"act",
{},
input_responses={who_key: ElicitResult(action="accept", content={"vUser": "octocat"})},
request_state=first.request_state,
allow_input_required=True,
)
assert isinstance(second, InputRequiredResult)
assert second.input_requests is not None
(go_key,) = second.input_requests # only the dependent question; the stored answer holds
assert go_key != who_key
# The stored entry is the client's wire content, not a re-serialization of it.
assert _outcomes_on_the_wire(second.request_state)[who_key]["data"] == {"vUser": "octocat"}
final = await client.session.call_tool(
"act",
{},
input_responses={go_key: ElicitResult(action="accept", content={"ok": True})},
request_state=second.request_state,
allow_input_required=True,
)
assert isinstance(final, CallToolResult)
assert isinstance(final.content[0], TextContent)
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", request_state_security=RequestStateSecurity(keys=[_PIN_KEY]))
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)
# A decodable v2 entry; the resolver never asks, so it must go unconsulted, not dropped as malformed.
entry = {"action": "accept", "data": {"needed": True}, "q": _question_digest(Elicit("Restock?", Restock))}
crafted = json.dumps({"v": 3, "outcomes": {_wire_key(decide): entry}})
async with Client(mcp, elicitation_callback=_never) as client:
result = await client.session.call_tool(
"plan_restock",
{},
request_state=_sealed_state(crafted, tool="plan_restock", args={}, audience="StateVsBody"),
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", request_state_security=RequestStateSecurity(keys=[_PIN_KEY]))
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
# A decodable v2 entry: `lookup` never asks, so no digest can make the decline apply.
entry = {"action": "decline", "q": _question_digest(Elicit("user?", Login))}
crafted = json.dumps({"v": 3, "outcomes": {_wire_key(lookup): entry}})
async with Client(mcp, elicitation_callback=_never) as client:
result = await client.session.call_tool(
"whoami",
{},
request_state=_sealed_state(crafted, tool="whoami", args={}, audience="PureVsDecline"),
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", request_state_security=RequestStateSecurity.ephemeral())
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", request_state_security=RequestStateSecurity.ephemeral())
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", request_state_security=RequestStateSecurity.ephemeral())
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", request_state_security=RequestStateSecurity.ephemeral())
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
def test_question_digest_pins_the_rendered_question():
# Computed over the rendered wire question: identical Elicits agree, any change diverges.
digest = _question_digest(Elicit("Name?", Login))
assert digest == _question_digest(Elicit("Name?", Login))
assert digest != _question_digest(Elicit("Your name, please?", Login))
assert digest != _question_digest(Elicit("Name?", Confirm))
# A 16-byte sha256 prefix, base64url without padding.
assert len(digest) == 22 and "=" not in digest
def test_state_round_trips_question_digests_at_v3():
# v2 carries digests for every action and round-trips exactly; v1 (mid rolling deploy) reads as no progress.
entries = {
"a": _StateEntry(action="accept", data={"username": "octocat"}, q="qa"),
"b": _StateEntry(action="decline", q="qb"),
"c": _StateEntry(action="cancel", q="qc"),
}
encoded = _encode_state(entries, {})
assert json.loads(encoded)["v"] == 3
assert _decode_state(encoded).outcomes == entries
v1 = json.dumps({"v": 1, "outcomes": {"a": {"action": "decline"}}})
assert _decode_state(v1).outcomes == {}
@pytest.mark.anyio
async def test_restored_answer_with_matching_digest_completes_without_reasking():
mcp = MCPServer(name="PinHappyPath", request_state_security=RequestStateSecurity.ephemeral())
async def who(ctx: Context) -> Elicit[Login]:
return Elicit("Who?", Login)
async def check(login: Annotated[Login, Resolve(who)]) -> Elicit[Confirm]:
return Elicit(f"Go as {login.username}?", Confirm)
@mcp.tool()
async def act(
login: Annotated[Login, Resolve(who)],
confirm: Annotated[Confirm, Resolve(check)],
) -> str:
return f"{login.username}:{confirm.ok}"
async with Client(mcp, elicitation_callback=_never) as client:
first = await client.session.call_tool("act", {}, allow_input_required=True)
assert isinstance(first, InputRequiredResult)
assert first.input_requests is not None
assert set(first.input_requests) == {_wire_key(who)}
second = await client.session.call_tool(
"act",
{},
input_responses={_wire_key(who): ElicitResult(action="accept", content={"username": "octocat"})},
request_state=first.request_state,
allow_input_required=True,
)
assert isinstance(second, InputRequiredResult)
assert second.input_requests is not None
# Only the dependent question; the stored answer holds, "Who?" is not re-asked.
assert set(second.input_requests) == {_wire_key(check)}
final = await client.session.call_tool(
"act",
{},
input_responses={_wire_key(check): ElicitResult(action="accept", content={"ok": True})},
request_state=second.request_state,
allow_input_required=True,
)
assert isinstance(final, CallToolResult)
assert isinstance(final.content[0], TextContent)
assert final.content[0].text == "octocat:True"
@pytest.mark.anyio
async def test_restored_entry_is_repersisted_with_its_question_digest_intact():
# A restored entry must ride into the next round's state digest-intact, or it would be re-asked next round.
mcp = MCPServer(name="RepersistPin", request_state_security=RequestStateSecurity(keys=[_PIN_KEY]))
async def who(ctx: Context) -> Elicit[Login]:
return Elicit("Who?", Login)
async def check(login: Annotated[Login, Resolve(who)]) -> Elicit[Confirm]:
return Elicit(f"Go as {login.username}?", Confirm)
async def plan(confirm: Annotated[Confirm, Resolve(check)], ctx: Context) -> Elicit[Restock]:
return Elicit("Restock too?", Restock)
# The body never runs (a question always pends); a bare `...` costs no coverage.
@mcp.tool()
async def act(restock: Annotated[Restock, Resolve(plan)]) -> str: ...
async with Client(mcp, elicitation_callback=_never) as client:
first = await client.session.call_tool("act", {}, allow_input_required=True)
assert isinstance(first, InputRequiredResult)
second = await client.session.call_tool(
"act",
{},
input_responses={_wire_key(who): ElicitResult(action="accept", content={"username": "octocat"})},
request_state=first.request_state,
allow_input_required=True,
)
assert isinstance(second, InputRequiredResult)
third = await client.session.call_tool(
"act",
{},
input_responses={_wire_key(check): ElicitResult(action="accept", content={"ok": True})},
request_state=second.request_state,
allow_input_required=True,
)
assert isinstance(third, InputRequiredResult)
round_two = _outcomes_on_the_wire(second.request_state)
round_three = _outcomes_on_the_wire(third.request_state)
# Accept entries are pinned to the exact rendered question they answered.
assert round_two[_wire_key(who)]["q"] == _question_digest(Elicit("Who?", Login))
assert round_three[_wire_key(check)]["q"] == _question_digest(Elicit("Go as octocat?", Confirm))
assert round_three[_wire_key(who)] == round_two[_wire_key(who)]
@pytest.mark.anyio
async def test_decline_and_cancel_entries_carry_the_question_digest():
mcp = MCPServer(name="PinAllActions", request_state_security=RequestStateSecurity(keys=[_PIN_KEY]))
async def ask_name(ctx: Context) -> Elicit[Login]:
return Elicit("Name?", Login)
async def ask_confirm(ctx: Context) -> Elicit[Confirm]:
return Elicit("Confirm?", Confirm)
async def ask_restock(ctx: Context) -> Elicit[Restock]:
return Elicit("Restock?", Restock)
# The body never runs (a question always pends); a bare `...` costs no coverage.
@mcp.tool()
async def act(
name: Annotated[ElicitationResult[Login], Resolve(ask_name)],
confirm: Annotated[ElicitationResult[Confirm], Resolve(ask_confirm)],
restock: Annotated[Restock, Resolve(ask_restock)],
) -> str: ...
async with Client(mcp, elicitation_callback=_never) as client:
first = await client.session.call_tool("act", {}, allow_input_required=True)
assert isinstance(first, InputRequiredResult)
# The third question stays unanswered, so the call pends and outcomes hit the wire.
second = await client.session.call_tool(
"act",
{},
input_responses={
_wire_key(ask_name): ElicitResult(action="decline"),
_wire_key(ask_confirm): ElicitResult(action="cancel"),
},
request_state=first.request_state,
allow_input_required=True,
)
assert isinstance(second, InputRequiredResult)
outcomes = _outcomes_on_the_wire(second.request_state)
assert outcomes[_wire_key(ask_name)]["action"] == "decline"
assert outcomes[_wire_key(ask_name)]["q"] == _question_digest(Elicit("Name?", Login))
assert outcomes[_wire_key(ask_confirm)]["action"] == "cancel"
assert outcomes[_wire_key(ask_confirm)]["q"] == _question_digest(Elicit("Confirm?", Confirm))
@pytest.mark.anyio
async def test_state_entry_without_a_question_digest_is_dropped_and_reasked():
# An entry with no digest cannot prove its question, so it reads as no progress and is re-asked.
mcp = MCPServer(name="UnpinnedEntry", request_state_security=RequestStateSecurity(keys=[_PIN_KEY]))
async def ask(ctx: Context) -> Elicit[Login]:
return Elicit("user?", Login)
@mcp.tool()
async def whoami(login: Annotated[Login, Resolve(ask)]) -> str:
return login.username
async with Client(mcp, elicitation_callback=_never) as client:
first = await client.session.call_tool("whoami", {}, allow_input_required=True)
assert isinstance(first, InputRequiredResult)
assert first.input_requests is not None
(key,) = first.input_requests
# Schema-valid accept data under the live key, but no "q" pin.
entry = {"action": "accept", "data": {"username": "spooky"}}
crafted = json.dumps({"v": 3, "outcomes": {key: entry}})
second = await client.session.call_tool(
"whoami",
{},
request_state=_sealed_state(crafted, tool="whoami", args={}, audience="UnpinnedEntry"),
allow_input_required=True,
)
assert isinstance(second, InputRequiredResult) # re-asked, not honored and not an error
assert second.input_requests is not None
assert set(second.input_requests) == {key}
assert _outcomes_on_the_wire(second.request_state) == {} # the unpinned entry is dropped
final = await client.session.call_tool(
"whoami",
{},
input_responses={key: ElicitResult(action="accept", content={"username": "octocat"})},
request_state=second.request_state,
allow_input_required=True,
)
assert isinstance(final, CallToolResult)
assert isinstance(final.content[0], TextContent)
assert final.content[0].text == "octocat"
@pytest.mark.anyio
async def test_reworded_question_drops_the_stored_answer_and_reasks():
# An answer holds only while its question is byte-identical: a reword (redeploy) drops it and re-asks.
mcp = MCPServer(name="RewordAccept", request_state_security=RequestStateSecurity(keys=[_PIN_KEY]))
wording = {"deploy": "Deploy to prod?"}
async def ask_deploy(ctx: Context) -> Elicit[Confirm]:
return Elicit(wording["deploy"], Confirm)
async def ask_name(ctx: Context) -> Elicit[Login]:
return Elicit("Name?", Login)
@mcp.tool()
async def act(
deploy: Annotated[Confirm, Resolve(ask_deploy)],
name: Annotated[Login, Resolve(ask_name)],
) -> str:
return f"{deploy.ok}:{name.username}"
async with Client(mcp, elicitation_callback=_never) as client:
first = await client.session.call_tool("act", {}, allow_input_required=True)
assert isinstance(first, InputRequiredResult)
second = await client.session.call_tool(
"act",
{},
input_responses={_wire_key(ask_deploy): ElicitResult(action="accept", content={"ok": True})},
request_state=first.request_state,
allow_input_required=True,
)
assert isinstance(second, InputRequiredResult)
assert _outcomes_on_the_wire(second.request_state)[_wire_key(ask_deploy)]["q"] == _question_digest(
Elicit("Deploy to prod?", Confirm)
)
wording["deploy"] = "Deploy to staging?"
third = await client.session.call_tool(
"act",
{},
input_responses={_wire_key(ask_name): ElicitResult(action="accept", content={"username": "octocat"})},
request_state=second.request_state,
allow_input_required=True,
)
# The stale answer is dropped and the reworded question is asked, not an error.
assert isinstance(third, InputRequiredResult)
assert third.input_requests is not None
assert set(third.input_requests) == {_wire_key(ask_deploy)}
question = third.input_requests[_wire_key(ask_deploy)].params
assert isinstance(question, ElicitRequestFormParams)
assert question.message == "Deploy to staging?"
# The sibling answer recorded in the same state survives the drop.
outcomes = _outcomes_on_the_wire(third.request_state)
assert _wire_key(ask_deploy) not in outcomes
assert outcomes[_wire_key(ask_name)]["action"] == "accept"
final = await client.session.call_tool(
"act",
{},
input_responses={_wire_key(ask_deploy): ElicitResult(action="accept", content={"ok": True})},
request_state=third.request_state,
allow_input_required=True,
)
assert isinstance(final, CallToolResult)
assert isinstance(final.content[0], TextContent)
assert final.content[0].text == "True:octocat"
@pytest.mark.anyio
async def test_decline_of_a_reworded_question_does_not_suppress_the_new_question():
# A decline pinned to the old wording must not suppress the reworded question.
mcp = MCPServer(name="RewordDecline", request_state_security=RequestStateSecurity.ephemeral())
wording = {"q": "Use defaults?"}
async def ask(ctx: Context) -> Elicit[Confirm]:
return Elicit(wording["q"], Confirm)
async def ask_name(ctx: Context) -> Elicit[Login]:
return Elicit("Name?", Login)
@mcp.tool()
async def act(
choice: Annotated[ElicitationResult[Confirm], Resolve(ask)],
name: Annotated[Login, Resolve(ask_name)],
) -> str:
kind = "accepted" if isinstance(choice, AcceptedElicitation) else "declined"
return f"{kind}:{name.username}"
async with Client(mcp, elicitation_callback=_never) as client:
first = await client.session.call_tool("act", {}, allow_input_required=True)
assert isinstance(first, InputRequiredResult)
second = await client.session.call_tool(
"act",
{},
input_responses={_wire_key(ask): ElicitResult(action="decline")},
request_state=first.request_state,
allow_input_required=True,
)
assert isinstance(second, InputRequiredResult)
wording["q"] = "Use the new defaults?"
third = await client.session.call_tool(
"act",
{},
input_responses={_wire_key(ask_name): ElicitResult(action="accept", content={"username": "octocat"})},
request_state=second.request_state,
allow_input_required=True,
)
# The stale decline is dropped and the reworded question is asked again.
assert isinstance(third, InputRequiredResult)
assert third.input_requests is not None
assert set(third.input_requests) == {_wire_key(ask)}
question = third.input_requests[_wire_key(ask)].params
assert isinstance(question, ElicitRequestFormParams)
assert question.message == "Use the new defaults?"
final = await client.session.call_tool(
"act",
{},
input_responses={_wire_key(ask): ElicitResult(action="accept", content={"ok": True})},
request_state=third.request_state,
allow_input_required=True,
)
# Accepting the new question proves the old decline did not stick.
assert isinstance(final, CallToolResult)
assert isinstance(final.content[0], TextContent)
assert final.content[0].text == "accepted:octocat"
@pytest.mark.anyio
async def test_reworded_question_reasks_even_when_the_answer_first_arrives():
# The pend round records each question's digest in the state, so an answer that
# first arrives after a reword (redeploy between ask and retry) re-asks instead
# of being consumed as consent to the new wording.
mcp = MCPServer(name="RewordArrival", request_state_security=RequestStateSecurity(keys=[_PIN_KEY]))
wording = {"deploy": "Deploy to prod?"}
async def ask_deploy(ctx: Context) -> Elicit[Confirm]:
return Elicit(wording["deploy"], Confirm)
@mcp.tool()
async def act(deploy: Annotated[Confirm, Resolve(ask_deploy)]) -> str:
return f"deployed:{deploy.ok}"
async with Client(mcp, elicitation_callback=_never) as client:
first = await client.session.call_tool("act", {}, allow_input_required=True)
assert isinstance(first, InputRequiredResult)
pended = json.loads(_unseal_inner(first.request_state))["asked"]
assert pended == {_wire_key(ask_deploy): _question_digest(Elicit("Deploy to prod?", Confirm))}
wording["deploy"] = "Deploy to staging?"
second = await client.session.call_tool(
"act",
{},
input_responses={_wire_key(ask_deploy): ElicitResult(action="accept", content={"ok": True})},
request_state=first.request_state,
allow_input_required=True,
)
# The stale answer to the old wording is not consumed; the reworded question is asked.
assert isinstance(second, InputRequiredResult)
assert second.input_requests is not None
question = second.input_requests[_wire_key(ask_deploy)].params
assert isinstance(question, ElicitRequestFormParams)
assert question.message == "Deploy to staging?"
final = await client.session.call_tool(
"act",
{},
input_responses={_wire_key(ask_deploy): ElicitResult(action="accept", content={"ok": True})},
request_state=second.request_state,
allow_input_required=True,
)
assert isinstance(final, CallToolResult)
assert isinstance(final.content[0], TextContent)
assert final.content[0].text == "deployed:True"
@pytest.mark.anyio
async def test_an_answer_without_the_echoed_state_is_reasked_not_consumed():
# Without the echoed state there is no record of which question the client was
# shown, so an answer arriving stateless re-asks instead of being consumed.
mcp = MCPServer(name="Stateless", request_state_security=RequestStateSecurity(keys=[_PIN_KEY]))
async def ask(ctx: Context) -> Elicit[Confirm]:
return Elicit("Proceed?", Confirm)
@mcp.tool()
async def act(go: Annotated[Confirm, Resolve(ask)]) -> str:
return f"went:{go.ok}"
async with Client(mcp, elicitation_callback=_never) as client:
first = await client.session.call_tool("act", {}, allow_input_required=True)
assert isinstance(first, InputRequiredResult)
second = await client.session.call_tool(
"act",
{},
input_responses={_wire_key(ask): ElicitResult(action="accept", content={"ok": True})},
allow_input_required=True,
)
assert isinstance(second, InputRequiredResult)
final = await client.session.call_tool(
"act",
{},
input_responses={_wire_key(ask): ElicitResult(action="accept", content={"ok": True})},
request_state=second.request_state,
allow_input_required=True,
)
assert isinstance(final, CallToolResult)
assert isinstance(final.content[0], TextContent)
assert final.content[0].text == "went:True"
@pytest.mark.anyio
async def test_recorded_answer_containing_a_lone_surrogate_survives_to_later_rounds():
# The state encoder escapes lone surrogates, so the decoder must parse them back:
# a recorded answer with one must restore on the next round, not silently re-ask.
mcp = MCPServer(name="Surrogate", request_state_security=RequestStateSecurity(keys=[_PIN_KEY]))
async def ask_name(ctx: Context) -> Elicit[Login]:
return Elicit("Name?", Login)
async def ask_confirm(ctx: Context) -> Elicit[Confirm]:
return Elicit("Confirm?", Confirm)
@mcp.tool()
async def act(
name: Annotated[Login, Resolve(ask_name)],
go: Annotated[Confirm, Resolve(ask_confirm)],
) -> str:
return f"{name.username}:{go.ok}"
async with Client(mcp, elicitation_callback=_never) as client:
first = await client.session.call_tool("act", {}, allow_input_required=True)
assert isinstance(first, InputRequiredResult)
second = await client.session.call_tool(
"act",
{},
input_responses={_wire_key(ask_name): ElicitResult(action="accept", content={"username": "oc\ud800t"})},
request_state=first.request_state,
allow_input_required=True,
)
# The surrogate-bearing answer is recorded; only the unanswered question remains.
assert isinstance(second, InputRequiredResult)
assert second.input_requests is not None
assert set(second.input_requests) == {_wire_key(ask_confirm)}
final = await client.session.call_tool(
"act",
{},
input_responses={_wire_key(ask_confirm): ElicitResult(action="accept", content={"ok": True})},
request_state=second.request_state,
allow_input_required=True,
)
assert isinstance(final, CallToolResult)
assert isinstance(final.content[0], TextContent)
assert final.content[0].text == "oc\ud800t:True"
@pytest.mark.anyio
async def test_resolver_elicitation_seals_and_completes_on_a_fully_default_server():
# The headline default-posture invariant: a resolver tool on a bare MCPServer() -
# no name, no security configuration - mints sealed state and completes the round.
mcp = MCPServer()
async def ask(ctx: Context) -> Elicit[Confirm]:
return Elicit("Go?", Confirm)
@mcp.tool()
async def act(go: Annotated[Confirm, Resolve(ask)]) -> str:
return f"went:{go.ok}"
async with Client(mcp, elicitation_callback=_never) as client:
first = await client.session.call_tool("act", {}, allow_input_required=True)
assert isinstance(first, InputRequiredResult)
assert first.request_state is not None
assert first.request_state.startswith("v1.")
final = await client.session.call_tool(
"act",
{},
input_responses={_wire_key(ask): ElicitResult(action="accept", content={"ok": True})},
request_state=first.request_state,
allow_input_required=True,
)
assert isinstance(final, CallToolResult)
assert isinstance(final.content[0], TextContent)
assert final.content[0].text == "went:True"
# --- Sample / ListRoots markers ---
async def _sample_never( # pragma: no cover - declares the capability; never invoked
context: ClientRequestContext, params: CreateMessageRequestParams
) -> CreateMessageResult:
raise AssertionError("should not be called")
async def _roots_never(context: ClientRequestContext) -> ListRootsResult: # pragma: no cover - see _sample_never
raise AssertionError("should not be called")
def _sample_capital(ctx: Context) -> Sample:
return Sample(
[SamplingMessage(role="user", content=TextContent(type="text", text="Capital of France?"))],
max_tokens=16,
)
@pytest.mark.anyio
@pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")
@pytest.mark.parametrize("mode", ["legacy", "auto"])
async def test_sample_resolver_injects_result(mode: Literal["legacy", "auto"]):
# The marker form is the 2026-blessed carrier: no SEP-2577 deprecation warning on either mode.
mcp = MCPServer(name="Sampler", request_state_security=RequestStateSecurity.ephemeral())
prompts: list[str] = []
async def sampler(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult:
content = params.messages[0].content
assert isinstance(content, TextContent)
prompts.append(content.text)
return CreateMessageResult(role="assistant", content=TextContent(type="text", text="Paris"), model="m")
@mcp.tool()
async def capital(answer: Annotated[CreateMessageResult, Resolve(_sample_capital)]) -> str:
assert isinstance(answer.content, TextContent)
return answer.content.text
async with Client(mcp, mode=mode, sampling_callback=sampler) as client:
assert await _text(client, "capital", {}) == "Paris"
assert prompts == ["Capital of France?"]
@pytest.mark.anyio
@pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")
@pytest.mark.parametrize("mode", ["legacy", "auto"])
async def test_list_roots_resolver_injects_result(mode: Literal["legacy", "auto"]):
mcp = MCPServer(name="Rooted", request_state_security=RequestStateSecurity.ephemeral())
async def client_roots(context: ClientRequestContext) -> ListRootsResult:
return ListRootsResult(roots=[Root(uri=FileUrl("file:///workspace"))])
def fetch_roots(ctx: Context) -> ListRoots:
return ListRoots()
@mcp.tool()
async def workspace(roots: Annotated[ListRootsResult, Resolve(fetch_roots)]) -> str:
return str(len(roots.roots))
async with Client(mcp, mode=mode, list_roots_callback=client_roots) as client:
assert await _text(client, "workspace", {}) == "1"
@pytest.mark.anyio
async def test_mixed_kinds_batch_into_one_round():
mcp = MCPServer(name="Mixed", request_state_security=RequestStateSecurity.ephemeral())
async def ask_name(ctx: Context) -> Elicit[Login]:
return Elicit("user?", Login)
async def fetch_roots(ctx: Context) -> ListRoots:
return ListRoots()
@mcp.tool()
async def combo(
login: Annotated[Login, Resolve(ask_name)],
answer: Annotated[CreateMessageResult, Resolve(_sample_capital)],
roots: Annotated[ListRootsResult, Resolve(fetch_roots)],
) -> str:
assert isinstance(answer.content, TextContent)
return f"{login.username}/{answer.content.text}/{len(roots.roots)}"
async with Client(
mcp, elicitation_callback=_never, sampling_callback=_sample_never, list_roots_callback=_roots_never
) as client:
first = await client.session.call_tool("combo", {}, allow_input_required=True)
assert isinstance(first, InputRequiredResult)
assert first.input_requests is not None
kinds = sorted(type(req).__name__ for req in first.input_requests.values())
assert kinds == ["CreateMessageRequest", "ElicitRequest", "ListRootsRequest"]
responses: InputResponses = {}
for key, req in first.input_requests.items():
if isinstance(req, ElicitRequest):
responses[key] = ElicitResult(action="accept", content={"username": "octocat"})
elif isinstance(req, CreateMessageRequest):
responses[key] = CreateMessageResult(
role="assistant", content=TextContent(type="text", text="hey"), model="m"
)
else:
responses[key] = ListRootsResult(roots=[])
final = await client.session.call_tool(
"combo", {}, input_responses=responses, request_state=first.request_state, allow_input_required=True
)
assert isinstance(final, CallToolResult)
assert isinstance(final.content[0], TextContent)
assert final.content[0].text == "octocat/hey/0"
@pytest.mark.anyio
async def test_sampling_tool_without_client_capability_is_a_protocol_error():
mcp = MCPServer(name="NoSamplingCapability", request_state_security=RequestStateSecurity.ephemeral())
@mcp.tool()
async def capital(answer: Annotated[CreateMessageResult, Resolve(_sample_capital)]) -> str:
return "unreachable" # pragma: no cover
async with Client(mcp) as client:
with pytest.raises(MCPError) as exc_info:
await client.session.call_tool("capital", {}, allow_input_required=True)
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
assert exc_info.value.error.data is not None
assert "sampling" in exc_info.value.error.data["requiredCapabilities"]
@pytest.mark.anyio
async def test_roots_tool_without_client_capability_is_a_protocol_error():
mcp = MCPServer(name="NoRootsCapability", request_state_security=RequestStateSecurity.ephemeral())
def fetch_roots(ctx: Context) -> ListRoots:
return ListRoots()
@mcp.tool()
async def workspace(roots: Annotated[ListRootsResult, Resolve(fetch_roots)]) -> str:
return "unreachable" # pragma: no cover
async with Client(mcp) as client:
with pytest.raises(MCPError) as exc_info:
await client.session.call_tool("workspace", {}, allow_input_required=True)
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
assert exc_info.value.error.data is not None
assert "roots" in exc_info.value.error.data["requiredCapabilities"]
@pytest.mark.anyio
async def test_legacy_eliciting_tool_without_capability_is_a_protocol_error():
# Same egress gate as the input_requests leg; the session stays usable after the refusal.
mcp = MCPServer(name="LegacyGate", request_state_security=RequestStateSecurity.ephemeral())
async def ask(ctx: Context) -> Elicit[Login]:
return Elicit("user?", Login)
@mcp.tool()
async def tool(login: Annotated[Login, Resolve(ask)]) -> str:
return login.username # pragma: no cover
@mcp.tool()
def plain() -> str:
return "ok"
async with Client(mcp, mode="legacy") as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("tool", {})
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
assert await _text(client, "plain", {}) == "ok"
def _ask_with_tools(ctx: Context) -> Sample:
return Sample(
[SamplingMessage(role="user", content=TextContent(type="text", text="2+2?"))],
max_tokens=16,
tools=[SamplingTool(name="calc", input_schema={"type": "object"})],
)
def _ask_with_tool_choice(ctx: Context) -> Sample:
return Sample(
[SamplingMessage(role="user", content=TextContent(type="text", text="2+2?"))],
max_tokens=16,
tool_choice=ToolChoice(mode="none"),
)
@pytest.mark.anyio
@pytest.mark.parametrize("ask", [_ask_with_tools, _ask_with_tool_choice])
async def test_sample_tools_require_the_tools_subcapability(ask: Callable[[Context], Sample]):
mcp = MCPServer(name="NoToolsSubcapability", request_state_security=RequestStateSecurity.ephemeral())
@mcp.tool()
async def calc(answer: Annotated[CreateMessageResultWithTools, Resolve(ask)]) -> str:
return "unreachable" # pragma: no cover
# The callback declares base `sampling` but not `sampling.tools`.
async with Client(mcp, sampling_callback=_sample_never) as client:
with pytest.raises(MCPError) as exc_info:
await client.session.call_tool("calc", {}, allow_input_required=True)
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
assert exc_info.value.error.data is not None
assert exc_info.value.error.data["requiredCapabilities"] == {"sampling": {"tools": {}}}
@pytest.mark.anyio
async def test_sample_with_tools_round_trips_with_declared_subcapability():
mcp = MCPServer(name="ToolsSampling", request_state_security=RequestStateSecurity.ephemeral())
async def sampler(
context: ClientRequestContext, params: CreateMessageRequestParams
) -> CreateMessageResultWithTools:
assert params.tools is not None and params.tools[0].name == "calc"
return CreateMessageResultWithTools(role="assistant", content=[TextContent(type="text", text="4")], model="m")
@mcp.tool()
async def calc(answer: Annotated[CreateMessageResultWithTools, Resolve(_ask_with_tools)]) -> str:
assert isinstance(answer.content, list) and isinstance(answer.content[0], TextContent)
return answer.content[0].text
async with Client(
mcp,
sampling_callback=sampler,
sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability()),
) as client:
assert await _text(client, "calc", {}) == "4"
@pytest.mark.anyio
async def test_no_tool_use_answer_to_a_tools_request_is_accepted():
# The answer parses off the wire as plain CreateMessageResult but must inject as CreateMessageResultWithTools.
mcp = MCPServer(name="NoToolUse", request_state_security=RequestStateSecurity.ephemeral())
@mcp.tool()
async def calc(answer: Annotated[CreateMessageResultWithTools, Resolve(_ask_with_tools)]) -> str:
assert isinstance(answer, CreateMessageResultWithTools)
assert isinstance(answer.content, TextContent)
return answer.content.text
async with Client(
mcp,
sampling_callback=_sample_never,
sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability()),
) as client:
first = await client.session.call_tool("calc", {}, allow_input_required=True)
assert isinstance(first, InputRequiredResult)
assert first.input_requests is not None
(key,) = first.input_requests
final = await client.session.call_tool(
"calc",
{},
input_responses={
key: CreateMessageResult(role="assistant", content=TextContent(type="text", text="4"), model="m")
},
request_state=first.request_state,
allow_input_required=True,
)
assert isinstance(final, CallToolResult)
assert not final.is_error
assert isinstance(final.content[0], TextContent)
assert final.content[0].text == "4"
@pytest.mark.anyio
async def test_sample_outcome_persists_across_rounds():
# The confirm arm depends on the sample, forcing extra rounds that restore the result instead of re-sampling.
mcp = MCPServer(name="Chain", request_state_security=RequestStateSecurity.ephemeral())
samples = 0
async def sampler(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult:
nonlocal samples
samples += 1
return CreateMessageResult(role="assistant", content=TextContent(type="text", text="Paris"), model="m")
async def confirm(
answer: Annotated[CreateMessageResult, Resolve(_sample_capital)], ctx: Context
) -> Elicit[Confirm]:
return Elicit("Accept the model's answer?", Confirm)
@mcp.tool()
async def tool(
ok: Annotated[Confirm, Resolve(confirm)],
answer: Annotated[CreateMessageResult, Resolve(_sample_capital)],
) -> str:
assert isinstance(answer.content, TextContent)
return f"{answer.content.text}:{ok.ok}"
async with Client(mcp, sampling_callback=sampler, elicitation_callback=_accept({"ok": True})) as client:
assert await _text(client, "tool", {}) == "Paris:True"
assert samples == 1
@pytest.mark.anyio
async def test_wrong_kind_response_for_sample_raises():
mcp = MCPServer(name="WrongKind", request_state_security=RequestStateSecurity.ephemeral())
@mcp.tool()
async def capital(answer: Annotated[CreateMessageResult, Resolve(_sample_capital)]) -> str:
return "unreachable" # pragma: no cover
async with Client(mcp, sampling_callback=_sample_never) as client:
first = await client.session.call_tool("capital", {}, allow_input_required=True)
assert isinstance(first, InputRequiredResult)
assert first.input_requests is not None
(key,) = first.input_requests
final = await client.session.call_tool(
"capital",
{},
input_responses={key: ElicitResult(action="accept", content={"x": "y"})},
request_state=first.request_state,
allow_input_required=True,
)
assert isinstance(final, CallToolResult)
assert final.is_error
assert isinstance(final.content[0], TextContent)
assert "wrong kind" in final.content[0].text
def test_mixed_marker_arms_raise_at_registration():
async def ambiguous(ctx: Context) -> Sample | Elicit[Login]:
raise NotImplementedError # pragma: no cover
async def tool(login: Annotated[Login, Resolve(ambiguous)]) -> str:
return login.username # pragma: no cover
with pytest.raises(InvalidSignature, match="multiple Elicit/Sample/ListRoots arms"):
Tool.from_function(tool)
def test_marker_union_with_generic_alias_member_registers():
# dict[str, Any] passes isinstance(c, type) on Python 3.10; the arm filter must not feed it to issubclass.
async def maybe_ask(ctx: Context) -> Sample | dict[str, Any]:
raise NotImplementedError # pragma: no cover
async def tool(answer: Annotated[CreateMessageResult, Resolve(maybe_ask)]) -> str:
return "ok" # pragma: no cover
Tool.from_function(tool)
def test_decline_entry_for_a_sample_marker_is_invalid():
# Decline outcomes exist only for elicitations; for a Sample the entry's None data fails validation.
with pytest.raises(ValidationError):
_outcome_from_state(_StateEntry(action="decline"), _sample_capital(cast(Context, None)))
@pytest.mark.anyio
async def test_bare_initialized_session_is_still_gated():
# notifications/initialized alone commits the handshake: a live back-channel, no declared capabilities.
mcp = MCPServer(name="BareInit", request_state_security=RequestStateSecurity.ephemeral())
async def ask(ctx: Context) -> Elicit[Login]:
return Elicit("user?", Login)
@mcp.tool()
async def tool(login: Annotated[Login, Resolve(ask)]) -> str:
return login.username # pragma: no cover
async with InMemoryTransport(mcp) as (read, write):
await write.send(SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized")))
await write.send(
SessionMessage(
JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={"name": "tool", "arguments": {}})
)
)
with anyio.fail_after(5):
message = await read.receive()
assert isinstance(message, SessionMessage)
assert isinstance(message.message, JSONRPCError)
assert message.message.error.code == MISSING_REQUIRED_CLIENT_CAPABILITY
@pytest.mark.anyio
@pytest.mark.parametrize("mode", ["legacy", "auto"])
async def test_tool_choice_only_sample_validates_as_tools_mode(mode: Literal["legacy", "auto"]):
# Gate and answer model share one predicate: tool_choice alone is tools-mode,
# so a single-content answer still validates (WithTools accepts both shapes).
mcp = MCPServer(name="ToolChoiceOnly", request_state_security=RequestStateSecurity.ephemeral())
async def sampler(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult:
assert params.tool_choice is not None and params.tools is None
return CreateMessageResult(role="assistant", content=TextContent(type="text", text="4"), model="m")
@mcp.tool()
async def calc(answer: Annotated[CreateMessageResultWithTools, Resolve(_ask_with_tool_choice)]) -> str:
assert isinstance(answer, CreateMessageResultWithTools)
assert isinstance(answer.content, TextContent)
return answer.content.text
async with Client(
mcp,
mode=mode,
sampling_callback=sampler,
sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability()),
) as client:
assert await _text(client, "calc", {}) == "4"
# --- Feature walk: interaction-level tests (public API only, one behaviour per test) ---
@pytest.mark.anyio
async def test_a_resolver_fills_its_parameter_without_any_client_interaction():
"""A resolver that computes its value server-side injects it with no client
callbacks involved. SDK-defined resolver contract."""
mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral())
def price_of(title: str) -> int:
return 42 if title == "Dune" else 0
@mcp.tool()
async def quote(title: str, price: Annotated[int, Resolve(price_of)]) -> str:
return f"{title}: {price}"
async with Client(mcp) as client:
result = await client.call_tool("quote", {"title": "Dune"})
assert not result.is_error
assert result.content == [TextContent(type="text", text="Dune: 42")]
@pytest.mark.anyio
async def test_a_resolver_may_depend_on_another_resolvers_value():
"""A resolver declares its own Resolve dependency and receives that resolver's
value before the tool body runs. SDK-defined resolver contract."""
mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral())
def base_price(title: str) -> int:
return 10 if title == "Dune" else 1
def with_tax(price: Annotated[int, Resolve(base_price)]) -> int:
return price * 2
@mcp.tool()
async def quote(title: str, total: Annotated[int, Resolve(with_tax)]) -> str:
return f"{title} costs {total}"
async with Client(mcp) as client:
result = await client.call_tool("quote", {"title": "Dune"})
assert not result.is_error
assert result.content == [TextContent(type="text", text="Dune costs 20")]
@pytest.mark.anyio
async def test_a_client_supplied_value_for_a_resolved_parameter_is_discarded():
"""A value the client sends under a resolved parameter's name never reaches the
tool; the resolver's value wins. SDK-defined: resolved parameters are server-side only."""
mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral())
def price_of(title: str) -> int:
return 42
@mcp.tool()
async def quote(title: str, price: Annotated[int, Resolve(price_of)]) -> str:
return f"{title}: {price}"
async with Client(mcp) as client:
result = await client.call_tool("quote", {"title": "Dune", "price": 999})
assert not result.is_error
assert result.content == [TextContent(type="text", text="Dune: 42")]
@pytest.mark.anyio
async def test_resolved_parameters_are_absent_from_the_advertised_tool_schema():
"""tools/list advertises only the model-facing parameters; resolved ones are
invisible to the client. SDK-defined: resolvers are server-side dependency injection."""
mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral())
def price_of(title: str) -> int:
raise NotImplementedError # pragma: no cover - only the schema is inspected
@mcp.tool()
async def quote(title: str, price: Annotated[int, Resolve(price_of)]) -> str:
raise NotImplementedError # pragma: no cover - only the schema is inspected
async with Client(mcp) as client:
(advertised,) = (await client.list_tools()).tools
assert advertised.input_schema == snapshot(
{
"type": "object",
"properties": {"title": {"title": "Title", "type": "string"}},
"required": ["title"],
"title": "quoteArguments",
}
)
@pytest.mark.anyio
@pytest.mark.parametrize("mode", ["legacy", "auto"])
async def test_an_elicit_marker_asks_the_client_and_injects_the_accepted_answer(mode: Literal["legacy", "auto"]):
"""A resolver returning Elicit puts its question to the client's elicitation
callback and the tool receives the validated model, identically over the 2025
back-channel and the 2026 multi-round-trip flow. SDK-defined injection contract."""
mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral())
def ask(ctx: Context) -> Elicit[Login]:
return Elicit("GitHub username?", Login)
@mcp.tool()
async def whoami(login: Annotated[Login, Resolve(ask)]) -> str:
return login.username
asked: list[str] = []
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
asked.append(params.message)
return ElicitResult(action="accept", content={"username": "octocat"})
async with Client(mcp, mode=mode, elicitation_callback=on_elicit) as client:
# The parametrize is only a real era matrix while the arms negotiate different revisions.
assert client.protocol_version == ("2025-11-25" if mode == "legacy" else "2026-07-28")
result = await client.call_tool("whoami", {})
assert not result.is_error
assert result.content == [TextContent(type="text", text="octocat")]
assert asked == ["GitHub username?"]
@pytest.mark.anyio
@pytest.mark.parametrize("mode", ["legacy", "auto"])
async def test_a_declined_elicitation_fails_the_call_for_a_plain_model_consumer(mode: Literal["legacy", "auto"]):
"""Declining the question aborts a tool whose consumer asked for the unwrapped
model, on either protocol era. Decline semantics are spec-defined; the abort is
the SDK's contract for plain-model consumers."""
mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral())
def ask(ctx: Context) -> Elicit[Login]:
return Elicit("GitHub username?", Login)
@mcp.tool()
async def whoami(login: Annotated[Login, Resolve(ask)]) -> str:
raise NotImplementedError # pragma: no cover - the decline aborts before the body
async with Client(mcp, mode=mode, elicitation_callback=_decline) as client:
result = await client.call_tool("whoami", {})
assert result.is_error
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == snapshot(
"Error executing tool whoami: Resolver for parameter 'login' could not resolve: elicitation was decline"
)
@pytest.mark.anyio
@pytest.mark.parametrize("mode", ["legacy", "auto"])
async def test_a_declined_elicitation_reaches_a_consumer_that_asked_for_the_outcome_union(
mode: Literal["legacy", "auto"],
):
"""Annotating the outcome union hands the tool the decline to branch on instead
of aborting the call. SDK-defined consumer-annotation contract."""
mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral())
def ask(ctx: Context) -> Elicit[Login]:
return Elicit("GitHub username?", Login)
@mcp.tool()
async def whoami(login: Annotated[ElicitationResult[Login], Resolve(ask)]) -> str:
if isinstance(login, AcceptedElicitation):
return login.data.username # pragma: no cover - declined in this test
return "anonymous"
async with Client(mcp, mode=mode, elicitation_callback=_decline) as client:
result = await client.call_tool("whoami", {})
assert not result.is_error
assert result.content == [TextContent(type="text", text="anonymous")]
@pytest.mark.anyio
@pytest.mark.parametrize("mode", ["legacy", "auto"])
async def test_an_undeclared_capability_refuses_the_call_instead_of_asking(mode: Literal["legacy", "auto"]):
"""A client that never declared the elicitation capability is refused with the
missing-capability protocol error rather than sent a question it cannot handle.
The egress rule is spec-mandated; the SDK applies it on both eras."""
mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral())
def ask(ctx: Context) -> Elicit[Login]:
return Elicit("GitHub username?", Login)
@mcp.tool()
async def whoami(login: Annotated[Login, Resolve(ask)]) -> str:
raise NotImplementedError # pragma: no cover - the gate refuses before the body
async with Client(mcp, mode=mode) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("whoami", {})
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
assert exc_info.value.error.data == {"requiredCapabilities": {"elicitation": {"form": {}}}}