49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Has been cancelled
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-posture invariant: a resolver tool on a bare MCPServer() -
|
|
# no name, no security configuration - mints sealed state and completes the round.
|
|
mcp = MCPServer()
|
|
|
|
async def ask(ctx: Context) -> Elicit[Confirm]:
|
|
return Elicit("Go?", Confirm)
|
|
|
|
@mcp.tool()
|
|
async def act(go: Annotated[Confirm, Resolve(ask)]) -> str:
|
|
return f"went:{go.ok}"
|
|
|
|
async with Client(mcp, elicitation_callback=_never) as client:
|
|
first = await client.session.call_tool("act", {}, allow_input_required=True)
|
|
assert isinstance(first, InputRequiredResult)
|
|
assert first.request_state is not None
|
|
assert first.request_state.startswith("v1.")
|
|
final = await client.session.call_tool(
|
|
"act",
|
|
{},
|
|
input_responses={_wire_key(ask): ElicitResult(action="accept", content={"ok": True})},
|
|
request_state=first.request_state,
|
|
allow_input_required=True,
|
|
)
|
|
assert isinstance(final, CallToolResult)
|
|
assert isinstance(final.content[0], TextContent)
|
|
assert final.content[0].text == "went:True"
|
|
|
|
|
|
# --- Sample / ListRoots markers ---
|
|
|
|
|
|
async def _sample_never( # pragma: no cover - declares the capability; never invoked
|
|
context: ClientRequestContext, params: CreateMessageRequestParams
|
|
) -> CreateMessageResult:
|
|
raise AssertionError("should not be called")
|
|
|
|
|
|
async def _roots_never(context: ClientRequestContext) -> ListRootsResult: # pragma: no cover - see _sample_never
|
|
raise AssertionError("should not be called")
|
|
|
|
|
|
def _sample_capital(ctx: Context) -> Sample:
|
|
return Sample(
|
|
[SamplingMessage(role="user", content=TextContent(type="text", text="Capital of France?"))],
|
|
max_tokens=16,
|
|
)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")
|
|
@pytest.mark.parametrize("mode", ["legacy", "auto"])
|
|
async def test_sample_resolver_injects_result(mode: Literal["legacy", "auto"]):
|
|
# The marker form is the 2026-blessed carrier: no SEP-2577 deprecation warning on either mode.
|
|
mcp = MCPServer(name="Sampler", request_state_security=RequestStateSecurity.ephemeral())
|
|
prompts: list[str] = []
|
|
|
|
async def sampler(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult:
|
|
content = params.messages[0].content
|
|
assert isinstance(content, TextContent)
|
|
prompts.append(content.text)
|
|
return CreateMessageResult(role="assistant", content=TextContent(type="text", text="Paris"), model="m")
|
|
|
|
@mcp.tool()
|
|
async def capital(answer: Annotated[CreateMessageResult, Resolve(_sample_capital)]) -> str:
|
|
assert isinstance(answer.content, TextContent)
|
|
return answer.content.text
|
|
|
|
async with Client(mcp, mode=mode, sampling_callback=sampler) as client:
|
|
assert await _text(client, "capital", {}) == "Paris"
|
|
assert prompts == ["Capital of France?"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")
|
|
@pytest.mark.parametrize("mode", ["legacy", "auto"])
|
|
async def test_list_roots_resolver_injects_result(mode: Literal["legacy", "auto"]):
|
|
mcp = MCPServer(name="Rooted", request_state_security=RequestStateSecurity.ephemeral())
|
|
|
|
async def client_roots(context: ClientRequestContext) -> ListRootsResult:
|
|
return ListRootsResult(roots=[Root(uri=FileUrl("file:///workspace"))])
|
|
|
|
def fetch_roots(ctx: Context) -> ListRoots:
|
|
return ListRoots()
|
|
|
|
@mcp.tool()
|
|
async def workspace(roots: Annotated[ListRootsResult, Resolve(fetch_roots)]) -> str:
|
|
return str(len(roots.roots))
|
|
|
|
async with Client(mcp, mode=mode, list_roots_callback=client_roots) as client:
|
|
assert await _text(client, "workspace", {}) == "1"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mixed_kinds_batch_into_one_round():
|
|
mcp = MCPServer(name="Mixed", request_state_security=RequestStateSecurity.ephemeral())
|
|
|
|
async def ask_name(ctx: Context) -> Elicit[Login]:
|
|
return Elicit("user?", Login)
|
|
|
|
async def fetch_roots(ctx: Context) -> ListRoots:
|
|
return ListRoots()
|
|
|
|
@mcp.tool()
|
|
async def combo(
|
|
login: Annotated[Login, Resolve(ask_name)],
|
|
answer: Annotated[CreateMessageResult, Resolve(_sample_capital)],
|
|
roots: Annotated[ListRootsResult, Resolve(fetch_roots)],
|
|
) -> str:
|
|
assert isinstance(answer.content, TextContent)
|
|
return f"{login.username}/{answer.content.text}/{len(roots.roots)}"
|
|
|
|
async with Client(
|
|
mcp, elicitation_callback=_never, sampling_callback=_sample_never, list_roots_callback=_roots_never
|
|
) as client:
|
|
first = await client.session.call_tool("combo", {}, allow_input_required=True)
|
|
assert isinstance(first, InputRequiredResult)
|
|
assert first.input_requests is not None
|
|
kinds = sorted(type(req).__name__ for req in first.input_requests.values())
|
|
assert kinds == ["CreateMessageRequest", "ElicitRequest", "ListRootsRequest"]
|
|
responses: InputResponses = {}
|
|
for key, req in first.input_requests.items():
|
|
if isinstance(req, ElicitRequest):
|
|
responses[key] = ElicitResult(action="accept", content={"username": "octocat"})
|
|
elif isinstance(req, CreateMessageRequest):
|
|
responses[key] = CreateMessageResult(
|
|
role="assistant", content=TextContent(type="text", text="hey"), model="m"
|
|
)
|
|
else:
|
|
responses[key] = ListRootsResult(roots=[])
|
|
final = await client.session.call_tool(
|
|
"combo", {}, input_responses=responses, request_state=first.request_state, allow_input_required=True
|
|
)
|
|
assert isinstance(final, CallToolResult)
|
|
assert isinstance(final.content[0], TextContent)
|
|
assert final.content[0].text == "octocat/hey/0"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_sampling_tool_without_client_capability_is_a_protocol_error():
|
|
mcp = MCPServer(name="NoSamplingCapability", request_state_security=RequestStateSecurity.ephemeral())
|
|
|
|
@mcp.tool()
|
|
async def capital(answer: Annotated[CreateMessageResult, Resolve(_sample_capital)]) -> str:
|
|
return "unreachable" # pragma: no cover
|
|
|
|
async with Client(mcp) as client:
|
|
with pytest.raises(MCPError) as exc_info:
|
|
await client.session.call_tool("capital", {}, allow_input_required=True)
|
|
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
|
|
assert exc_info.value.error.data is not None
|
|
assert "sampling" in exc_info.value.error.data["requiredCapabilities"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_roots_tool_without_client_capability_is_a_protocol_error():
|
|
mcp = MCPServer(name="NoRootsCapability", request_state_security=RequestStateSecurity.ephemeral())
|
|
|
|
def fetch_roots(ctx: Context) -> ListRoots:
|
|
return ListRoots()
|
|
|
|
@mcp.tool()
|
|
async def workspace(roots: Annotated[ListRootsResult, Resolve(fetch_roots)]) -> str:
|
|
return "unreachable" # pragma: no cover
|
|
|
|
async with Client(mcp) as client:
|
|
with pytest.raises(MCPError) as exc_info:
|
|
await client.session.call_tool("workspace", {}, allow_input_required=True)
|
|
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
|
|
assert exc_info.value.error.data is not None
|
|
assert "roots" in exc_info.value.error.data["requiredCapabilities"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_legacy_eliciting_tool_without_capability_is_a_protocol_error():
|
|
# Same egress gate as the input_requests leg; the session stays usable after the refusal.
|
|
mcp = MCPServer(name="LegacyGate", request_state_security=RequestStateSecurity.ephemeral())
|
|
|
|
async def ask(ctx: Context) -> Elicit[Login]:
|
|
return Elicit("user?", Login)
|
|
|
|
@mcp.tool()
|
|
async def tool(login: Annotated[Login, Resolve(ask)]) -> str:
|
|
return login.username # pragma: no cover
|
|
|
|
@mcp.tool()
|
|
def plain() -> str:
|
|
return "ok"
|
|
|
|
async with Client(mcp, mode="legacy") as client:
|
|
with pytest.raises(MCPError) as exc_info:
|
|
await client.call_tool("tool", {})
|
|
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
|
|
assert await _text(client, "plain", {}) == "ok"
|
|
|
|
|
|
def _ask_with_tools(ctx: Context) -> Sample:
|
|
return Sample(
|
|
[SamplingMessage(role="user", content=TextContent(type="text", text="2+2?"))],
|
|
max_tokens=16,
|
|
tools=[SamplingTool(name="calc", input_schema={"type": "object"})],
|
|
)
|
|
|
|
|
|
def _ask_with_tool_choice(ctx: Context) -> Sample:
|
|
return Sample(
|
|
[SamplingMessage(role="user", content=TextContent(type="text", text="2+2?"))],
|
|
max_tokens=16,
|
|
tool_choice=ToolChoice(mode="none"),
|
|
)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.parametrize("ask", [_ask_with_tools, _ask_with_tool_choice])
|
|
async def test_sample_tools_require_the_tools_subcapability(ask: Callable[[Context], Sample]):
|
|
mcp = MCPServer(name="NoToolsSubcapability", request_state_security=RequestStateSecurity.ephemeral())
|
|
|
|
@mcp.tool()
|
|
async def calc(answer: Annotated[CreateMessageResultWithTools, Resolve(ask)]) -> str:
|
|
return "unreachable" # pragma: no cover
|
|
|
|
# The callback declares base `sampling` but not `sampling.tools`.
|
|
async with Client(mcp, sampling_callback=_sample_never) as client:
|
|
with pytest.raises(MCPError) as exc_info:
|
|
await client.session.call_tool("calc", {}, allow_input_required=True)
|
|
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
|
|
assert exc_info.value.error.data is not None
|
|
assert exc_info.value.error.data["requiredCapabilities"] == {"sampling": {"tools": {}}}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_sample_with_tools_round_trips_with_declared_subcapability():
|
|
mcp = MCPServer(name="ToolsSampling", request_state_security=RequestStateSecurity.ephemeral())
|
|
|
|
async def sampler(
|
|
context: ClientRequestContext, params: CreateMessageRequestParams
|
|
) -> CreateMessageResultWithTools:
|
|
assert params.tools is not None and params.tools[0].name == "calc"
|
|
return CreateMessageResultWithTools(role="assistant", content=[TextContent(type="text", text="4")], model="m")
|
|
|
|
@mcp.tool()
|
|
async def calc(answer: Annotated[CreateMessageResultWithTools, Resolve(_ask_with_tools)]) -> str:
|
|
assert isinstance(answer.content, list) and isinstance(answer.content[0], TextContent)
|
|
return answer.content[0].text
|
|
|
|
async with Client(
|
|
mcp,
|
|
sampling_callback=sampler,
|
|
sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability()),
|
|
) as client:
|
|
assert await _text(client, "calc", {}) == "4"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_no_tool_use_answer_to_a_tools_request_is_accepted():
|
|
# The answer parses off the wire as plain CreateMessageResult but must inject as CreateMessageResultWithTools.
|
|
mcp = MCPServer(name="NoToolUse", request_state_security=RequestStateSecurity.ephemeral())
|
|
|
|
@mcp.tool()
|
|
async def calc(answer: Annotated[CreateMessageResultWithTools, Resolve(_ask_with_tools)]) -> str:
|
|
assert isinstance(answer, CreateMessageResultWithTools)
|
|
assert isinstance(answer.content, TextContent)
|
|
return answer.content.text
|
|
|
|
async with Client(
|
|
mcp,
|
|
sampling_callback=_sample_never,
|
|
sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability()),
|
|
) as client:
|
|
first = await client.session.call_tool("calc", {}, allow_input_required=True)
|
|
assert isinstance(first, InputRequiredResult)
|
|
assert first.input_requests is not None
|
|
(key,) = first.input_requests
|
|
final = await client.session.call_tool(
|
|
"calc",
|
|
{},
|
|
input_responses={
|
|
key: CreateMessageResult(role="assistant", content=TextContent(type="text", text="4"), model="m")
|
|
},
|
|
request_state=first.request_state,
|
|
allow_input_required=True,
|
|
)
|
|
assert isinstance(final, CallToolResult)
|
|
assert not final.is_error
|
|
assert isinstance(final.content[0], TextContent)
|
|
assert final.content[0].text == "4"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_sample_outcome_persists_across_rounds():
|
|
# The confirm arm depends on the sample, forcing extra rounds that restore the result instead of re-sampling.
|
|
mcp = MCPServer(name="Chain", request_state_security=RequestStateSecurity.ephemeral())
|
|
samples = 0
|
|
|
|
async def sampler(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult:
|
|
nonlocal samples
|
|
samples += 1
|
|
return CreateMessageResult(role="assistant", content=TextContent(type="text", text="Paris"), model="m")
|
|
|
|
async def confirm(
|
|
answer: Annotated[CreateMessageResult, Resolve(_sample_capital)], ctx: Context
|
|
) -> Elicit[Confirm]:
|
|
return Elicit("Accept the model's answer?", Confirm)
|
|
|
|
@mcp.tool()
|
|
async def tool(
|
|
ok: Annotated[Confirm, Resolve(confirm)],
|
|
answer: Annotated[CreateMessageResult, Resolve(_sample_capital)],
|
|
) -> str:
|
|
assert isinstance(answer.content, TextContent)
|
|
return f"{answer.content.text}:{ok.ok}"
|
|
|
|
async with Client(mcp, sampling_callback=sampler, elicitation_callback=_accept({"ok": True})) as client:
|
|
assert await _text(client, "tool", {}) == "Paris:True"
|
|
assert samples == 1
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_wrong_kind_response_for_sample_raises():
|
|
mcp = MCPServer(name="WrongKind", request_state_security=RequestStateSecurity.ephemeral())
|
|
|
|
@mcp.tool()
|
|
async def capital(answer: Annotated[CreateMessageResult, Resolve(_sample_capital)]) -> str:
|
|
return "unreachable" # pragma: no cover
|
|
|
|
async with Client(mcp, sampling_callback=_sample_never) as client:
|
|
first = await client.session.call_tool("capital", {}, allow_input_required=True)
|
|
assert isinstance(first, InputRequiredResult)
|
|
assert first.input_requests is not None
|
|
(key,) = first.input_requests
|
|
final = await client.session.call_tool(
|
|
"capital",
|
|
{},
|
|
input_responses={key: ElicitResult(action="accept", content={"x": "y"})},
|
|
request_state=first.request_state,
|
|
allow_input_required=True,
|
|
)
|
|
assert isinstance(final, CallToolResult)
|
|
assert final.is_error
|
|
assert isinstance(final.content[0], TextContent)
|
|
assert "wrong kind" in final.content[0].text
|
|
|
|
|
|
def test_mixed_marker_arms_raise_at_registration():
|
|
async def ambiguous(ctx: Context) -> Sample | Elicit[Login]:
|
|
raise NotImplementedError # pragma: no cover
|
|
|
|
async def tool(login: Annotated[Login, Resolve(ambiguous)]) -> str:
|
|
return login.username # pragma: no cover
|
|
|
|
with pytest.raises(InvalidSignature, match="multiple Elicit/Sample/ListRoots arms"):
|
|
Tool.from_function(tool)
|
|
|
|
|
|
def test_marker_union_with_generic_alias_member_registers():
|
|
# dict[str, Any] passes isinstance(c, type) on Python 3.10; the arm filter must not feed it to issubclass.
|
|
async def maybe_ask(ctx: Context) -> Sample | dict[str, Any]:
|
|
raise NotImplementedError # pragma: no cover
|
|
|
|
async def tool(answer: Annotated[CreateMessageResult, Resolve(maybe_ask)]) -> str:
|
|
return "ok" # pragma: no cover
|
|
|
|
Tool.from_function(tool)
|
|
|
|
|
|
def test_decline_entry_for_a_sample_marker_is_invalid():
|
|
# Decline outcomes exist only for elicitations; for a Sample the entry's None data fails validation.
|
|
with pytest.raises(ValidationError):
|
|
_outcome_from_state(_StateEntry(action="decline"), _sample_capital(cast(Context, None)))
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_bare_initialized_session_is_still_gated():
|
|
# notifications/initialized alone commits the handshake: a live back-channel, no declared capabilities.
|
|
mcp = MCPServer(name="BareInit", request_state_security=RequestStateSecurity.ephemeral())
|
|
|
|
async def ask(ctx: Context) -> Elicit[Login]:
|
|
return Elicit("user?", Login)
|
|
|
|
@mcp.tool()
|
|
async def tool(login: Annotated[Login, Resolve(ask)]) -> str:
|
|
return login.username # pragma: no cover
|
|
|
|
async with InMemoryTransport(mcp) as (read, write):
|
|
await write.send(SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized")))
|
|
await write.send(
|
|
SessionMessage(
|
|
JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={"name": "tool", "arguments": {}})
|
|
)
|
|
)
|
|
with anyio.fail_after(5):
|
|
message = await read.receive()
|
|
assert isinstance(message, SessionMessage)
|
|
assert isinstance(message.message, JSONRPCError)
|
|
assert message.message.error.code == MISSING_REQUIRED_CLIENT_CAPABILITY
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.parametrize("mode", ["legacy", "auto"])
|
|
async def test_tool_choice_only_sample_validates_as_tools_mode(mode: Literal["legacy", "auto"]):
|
|
# Gate and answer model share one predicate: tool_choice alone is tools-mode,
|
|
# so a single-content answer still validates (WithTools accepts both shapes).
|
|
mcp = MCPServer(name="ToolChoiceOnly", request_state_security=RequestStateSecurity.ephemeral())
|
|
|
|
async def sampler(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult:
|
|
assert params.tool_choice is not None and params.tools is None
|
|
return CreateMessageResult(role="assistant", content=TextContent(type="text", text="4"), model="m")
|
|
|
|
@mcp.tool()
|
|
async def calc(answer: Annotated[CreateMessageResultWithTools, Resolve(_ask_with_tool_choice)]) -> str:
|
|
assert isinstance(answer, CreateMessageResultWithTools)
|
|
assert isinstance(answer.content, TextContent)
|
|
return answer.content.text
|
|
|
|
async with Client(
|
|
mcp,
|
|
mode=mode,
|
|
sampling_callback=sampler,
|
|
sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability()),
|
|
) as client:
|
|
assert await _text(client, "calc", {}) == "4"
|
|
|
|
|
|
# --- Feature walk: interaction-level tests (public API only, one behaviour per test) ---
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_a_resolver_fills_its_parameter_without_any_client_interaction():
|
|
"""A resolver that computes its value server-side injects it with no client
|
|
callbacks involved. SDK-defined resolver contract."""
|
|
mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral())
|
|
|
|
def price_of(title: str) -> int:
|
|
return 42 if title == "Dune" else 0
|
|
|
|
@mcp.tool()
|
|
async def quote(title: str, price: Annotated[int, Resolve(price_of)]) -> str:
|
|
return f"{title}: {price}"
|
|
|
|
async with Client(mcp) as client:
|
|
result = await client.call_tool("quote", {"title": "Dune"})
|
|
|
|
assert not result.is_error
|
|
assert result.content == [TextContent(type="text", text="Dune: 42")]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_a_resolver_may_depend_on_another_resolvers_value():
|
|
"""A resolver declares its own Resolve dependency and receives that resolver's
|
|
value before the tool body runs. SDK-defined resolver contract."""
|
|
mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral())
|
|
|
|
def base_price(title: str) -> int:
|
|
return 10 if title == "Dune" else 1
|
|
|
|
def with_tax(price: Annotated[int, Resolve(base_price)]) -> int:
|
|
return price * 2
|
|
|
|
@mcp.tool()
|
|
async def quote(title: str, total: Annotated[int, Resolve(with_tax)]) -> str:
|
|
return f"{title} costs {total}"
|
|
|
|
async with Client(mcp) as client:
|
|
result = await client.call_tool("quote", {"title": "Dune"})
|
|
|
|
assert not result.is_error
|
|
assert result.content == [TextContent(type="text", text="Dune costs 20")]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_a_client_supplied_value_for_a_resolved_parameter_is_discarded():
|
|
"""A value the client sends under a resolved parameter's name never reaches the
|
|
tool; the resolver's value wins. SDK-defined: resolved parameters are server-side only."""
|
|
mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral())
|
|
|
|
def price_of(title: str) -> int:
|
|
return 42
|
|
|
|
@mcp.tool()
|
|
async def quote(title: str, price: Annotated[int, Resolve(price_of)]) -> str:
|
|
return f"{title}: {price}"
|
|
|
|
async with Client(mcp) as client:
|
|
result = await client.call_tool("quote", {"title": "Dune", "price": 999})
|
|
|
|
assert not result.is_error
|
|
assert result.content == [TextContent(type="text", text="Dune: 42")]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_resolved_parameters_are_absent_from_the_advertised_tool_schema():
|
|
"""tools/list advertises only the model-facing parameters; resolved ones are
|
|
invisible to the client. SDK-defined: resolvers are server-side dependency injection."""
|
|
mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral())
|
|
|
|
def price_of(title: str) -> int:
|
|
raise NotImplementedError # pragma: no cover - only the schema is inspected
|
|
|
|
@mcp.tool()
|
|
async def quote(title: str, price: Annotated[int, Resolve(price_of)]) -> str:
|
|
raise NotImplementedError # pragma: no cover - only the schema is inspected
|
|
|
|
async with Client(mcp) as client:
|
|
(advertised,) = (await client.list_tools()).tools
|
|
|
|
assert advertised.input_schema == snapshot(
|
|
{
|
|
"type": "object",
|
|
"properties": {"title": {"title": "Title", "type": "string"}},
|
|
"required": ["title"],
|
|
"title": "quoteArguments",
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.parametrize("mode", ["legacy", "auto"])
|
|
async def test_an_elicit_marker_asks_the_client_and_injects_the_accepted_answer(mode: Literal["legacy", "auto"]):
|
|
"""A resolver returning Elicit puts its question to the client's elicitation
|
|
callback and the tool receives the validated model, identically over the 2025
|
|
back-channel and the 2026 multi-round-trip flow. SDK-defined injection contract."""
|
|
mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral())
|
|
|
|
def ask(ctx: Context) -> Elicit[Login]:
|
|
return Elicit("GitHub username?", Login)
|
|
|
|
@mcp.tool()
|
|
async def whoami(login: Annotated[Login, Resolve(ask)]) -> str:
|
|
return login.username
|
|
|
|
asked: list[str] = []
|
|
|
|
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
|
|
asked.append(params.message)
|
|
return ElicitResult(action="accept", content={"username": "octocat"})
|
|
|
|
async with Client(mcp, mode=mode, elicitation_callback=on_elicit) as client:
|
|
# The parametrize is only a real era matrix while the arms negotiate different revisions.
|
|
assert client.protocol_version == ("2025-11-25" if mode == "legacy" else "2026-07-28")
|
|
result = await client.call_tool("whoami", {})
|
|
|
|
assert not result.is_error
|
|
assert result.content == [TextContent(type="text", text="octocat")]
|
|
assert asked == ["GitHub username?"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.parametrize("mode", ["legacy", "auto"])
|
|
async def test_a_declined_elicitation_fails_the_call_for_a_plain_model_consumer(mode: Literal["legacy", "auto"]):
|
|
"""Declining the question aborts a tool whose consumer asked for the unwrapped
|
|
model, on either protocol era. Decline semantics are spec-defined; the abort is
|
|
the SDK's contract for plain-model consumers."""
|
|
mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral())
|
|
|
|
def ask(ctx: Context) -> Elicit[Login]:
|
|
return Elicit("GitHub username?", Login)
|
|
|
|
@mcp.tool()
|
|
async def whoami(login: Annotated[Login, Resolve(ask)]) -> str:
|
|
raise NotImplementedError # pragma: no cover - the decline aborts before the body
|
|
|
|
async with Client(mcp, mode=mode, elicitation_callback=_decline) as client:
|
|
result = await client.call_tool("whoami", {})
|
|
|
|
assert result.is_error
|
|
assert isinstance(result.content[0], TextContent)
|
|
assert result.content[0].text == snapshot(
|
|
"Error executing tool whoami: Resolver for parameter 'login' could not resolve: elicitation was decline"
|
|
)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.parametrize("mode", ["legacy", "auto"])
|
|
async def test_a_declined_elicitation_reaches_a_consumer_that_asked_for_the_outcome_union(
|
|
mode: Literal["legacy", "auto"],
|
|
):
|
|
"""Annotating the outcome union hands the tool the decline to branch on instead
|
|
of aborting the call. SDK-defined consumer-annotation contract."""
|
|
mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral())
|
|
|
|
def ask(ctx: Context) -> Elicit[Login]:
|
|
return Elicit("GitHub username?", Login)
|
|
|
|
@mcp.tool()
|
|
async def whoami(login: Annotated[ElicitationResult[Login], Resolve(ask)]) -> str:
|
|
if isinstance(login, AcceptedElicitation):
|
|
return login.data.username # pragma: no cover - declined in this test
|
|
return "anonymous"
|
|
|
|
async with Client(mcp, mode=mode, elicitation_callback=_decline) as client:
|
|
result = await client.call_tool("whoami", {})
|
|
|
|
assert not result.is_error
|
|
assert result.content == [TextContent(type="text", text="anonymous")]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.parametrize("mode", ["legacy", "auto"])
|
|
async def test_an_undeclared_capability_refuses_the_call_instead_of_asking(mode: Literal["legacy", "auto"]):
|
|
"""A client that never declared the elicitation capability is refused with the
|
|
missing-capability protocol error rather than sent a question it cannot handle.
|
|
The egress rule is spec-mandated; the SDK applies it on both eras."""
|
|
mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral())
|
|
|
|
def ask(ctx: Context) -> Elicit[Login]:
|
|
return Elicit("GitHub username?", Login)
|
|
|
|
@mcp.tool()
|
|
async def whoami(login: Annotated[Login, Resolve(ask)]) -> str:
|
|
raise NotImplementedError # pragma: no cover - the gate refuses before the body
|
|
|
|
async with Client(mcp, mode=mode) as client:
|
|
with pytest.raises(MCPError) as exc_info:
|
|
await client.call_tool("whoami", {})
|
|
|
|
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
|
|
assert exc_info.value.error.data == {"requiredCapabilities": {"elicitation": {"form": {}}}}
|