1e4d87ea61
Delete the Posture enum, its export, and the posture= constructor parameter on Server and MCPServer. The lowlevel Server is a registry of handlers and carries no serving configuration; a server offers both the 2025 handshake era and the 2026 per-request era on every transport, and each stream connection's era is decided by the client's opening message, as the default already did. _StreamConnection no longer takes a starting era; it starts undecided and pins on the first era-distinctive message. The streamable-HTTP manager's header routing is unchanged: streams it has already routed to the handshake era are entered born-legacy through the transport- internal serve_legacy_stream, so the legacy leg keeps refusing enveloped requests with -32600 exactly as before. The MODERN_ONLY / LEGACY_ONLY tests and doc sections go with it, and the streamable-HTTP orphan test file is renamed now that its posture half is gone.
2869 lines
117 KiB
Python
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 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": {}}}}
|