"""`ClientSession` result claims: construction validation, activation at modern adopts only, claimed-result routing, the version-aware capability ad, and the `allow_claimed` escape hatch.""" from collections.abc import Mapping from typing import Any, Literal import anyio import anyio.abc import mcp_types as types import pytest from inline_snapshot import snapshot from mcp_types import ( CLIENT_CAPABILITIES_META_KEY, CallToolResult, Implementation, InputRequiredResult, ListToolsResult, Result, ServerCapabilities, TextContent, Tool, ) from mcp_types.methods import validate_server_result from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION from pydantic import ValidationError from typing_extensions import assert_type from mcp.client.extension import ClaimContext, ResultClaim, UnexpectedClaimedResult from mcp.client.session import ClientSession, _CallToolResultAdapter from mcp.shared.dispatcher import CallOptions, OnNotify, OnNotifyIntercept, OnRequest _TASKS_EXT = "com.example/tasks" _AD_ONLY_EXT = "com.example/flags" class _TaskResult(Result): """A claimed result shape, tagged `task`.""" result_type: Literal["task"] = "task" task_id: str async def _resolve_task(result: _TaskResult, ctx: ClaimContext) -> CallToolResult: raise NotImplementedError # session-tier tests never drive a resolver; that is the Client's job def _task_claim(**kwargs: Any) -> ResultClaim[_TaskResult]: return ResultClaim(result_type="task", model=_TaskResult, resolve=_resolve_task, **kwargs) _COMPLETE_TOOL_RESULT = CallToolResult(content=[TextContent(type="text", text="ok")]).model_dump( by_alias=True, mode="json", exclude_none=True ) _CLAIMED_TASK_RESULT = {"resultType": "task", "taskId": "t-1"} _TOOL_LISTING = ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"})]).model_dump( by_alias=True, mode="json", exclude_none=True ) _INITIALIZE_RESULT = types.InitializeResult( protocol_version=LATEST_HANDSHAKE_VERSION, capabilities=ServerCapabilities(), server_info=Implementation(name="stub", version="0"), ).model_dump(by_alias=True, mode="json", exclude_none=True) class _RecordingDispatcher: """Records every send and answers each method with a canned result.""" def __init__(self, tool_result: dict[str, Any] | None = None) -> None: self.calls: list[tuple[str, Mapping[str, Any] | None, CallOptions]] = [] self.notifications: list[str] = [] self._tool_result = tool_result if tool_result is not None else _COMPLETE_TOOL_RESULT async def run( self, on_request: OnRequest, on_notify: OnNotify, on_notify_intercept: OnNotifyIntercept | None = None, *, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, ) -> None: task_status.started() await anyio.sleep_forever() async def send_raw_request( self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None ) -> dict[str, Any]: self.calls.append((method, params, opts or {})) if method == "tools/call": return self._tool_result if method == "tools/list": return _TOOL_LISTING if method == "initialize": return _INITIALIZE_RESULT return {} async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: self.notifications.append(method) def _claims_session(dispatcher: _RecordingDispatcher, *claims: ResultClaim[Any]) -> ClientSession: return ClientSession(dispatcher=dispatcher, extensions={_TASKS_EXT: {}}, result_claims={_TASKS_EXT: list(claims)}) def _adopt_modern(session: ClientSession) -> None: session.adopt( types.DiscoverResult( supported_versions=[LATEST_MODERN_VERSION], capabilities=ServerCapabilities(), server_info=Implementation(name="stub", version="0"), ) ) def _adopt_handshake(session: ClientSession) -> None: session.adopt( types.InitializeResult( protocol_version=LATEST_HANDSHAKE_VERSION, capabilities=ServerCapabilities(), server_info=Implementation(name="stub", version="0"), ) ) def test_duplicate_claim_tag_across_extensions_rejected() -> None: """SDK-defined: two claims on the same resultType cannot be routed apart, so construction fails.""" with pytest.raises(ValueError) as exc_info: ClientSession( dispatcher=_RecordingDispatcher(), extensions={_TASKS_EXT: {}, _AD_ONLY_EXT: {}}, result_claims={_TASKS_EXT: [_task_claim()], _AD_ONLY_EXT: [_task_claim()]}, ) assert str(exc_info.value) == snapshot("duplicate result claim for resultType 'task'") def test_claims_keyed_to_unadvertised_extension_rejected() -> None: """SDK-defined: a `result_claims` key with no `extensions` entry advertises nothing, so construction fails.""" messages: list[str] = [] for extensions in (None, {_AD_ONLY_EXT: {"flag": True}}): with pytest.raises(ValueError) as exc_info: ClientSession( dispatcher=_RecordingDispatcher(), extensions=extensions, result_claims={_TASKS_EXT: [_task_claim()]}, ) messages.append(str(exc_info.value)) assert messages == snapshot( [ "result_claims key 'com.example/tasks' has no extensions entry; a claim is only " "advertised through its extension's capability ad", "result_claims key 'com.example/tasks' has no extensions entry; a claim is only " "advertised through its extension's capability ad", ] ) def test_empty_claim_sequence_rejected() -> None: """SDK-defined: an empty claim list is rejected at construction; a claim-less extension omits the key.""" with pytest.raises(ValueError) as exc_info: ClientSession(dispatcher=_RecordingDispatcher(), extensions={_TASKS_EXT: {}}, result_claims={_TASKS_EXT: []}) assert str(exc_info.value) == snapshot( "result_claims['com.example/tasks'] is empty and would drop the extension from " "the capability ad at every version. Omit the key instead" ) def test_empty_settings_count_as_an_advertised_extension() -> None: """SDK-defined: empty settings ({}) still count as an ad, so claims keyed to the extension construct.""" session = _claims_session(_RecordingDispatcher(), _task_claim()) assert isinstance(session, ClientSession) def test_without_claims_the_call_tool_adapter_is_the_module_constant() -> None: """SDK-defined: with zero active claims the session holds the module-level adapter by identity.""" session = ClientSession(dispatcher=_RecordingDispatcher()) assert session._call_tool_adapter is _CallToolResultAdapter _adopt_modern(session) assert session._call_tool_adapter is _CallToolResultAdapter _adopt_handshake(session) assert session._call_tool_adapter is _CallToolResultAdapter @pytest.mark.anyio @pytest.mark.parametrize("protocol_versions", [None, frozenset({LATEST_MODERN_VERSION})]) async def test_modern_adopt_activates_claims_and_routes_claimed_results( protocol_versions: frozenset[str] | None, ) -> None: """SDK-defined: at a modern adopt, a claim active at the negotiated version routes the claimed raw to the claim model.""" dispatcher = _RecordingDispatcher(tool_result=_CLAIMED_TASK_RESULT) session = _claims_session(dispatcher, _task_claim(protocol_versions=protocol_versions)) with anyio.fail_after(5): async with session: _adopt_modern(session) result = await session.call_tool("t", {}, allow_claimed=True) assert isinstance(result, _TaskResult) assert result.task_id == "t-1" @pytest.mark.anyio async def test_legacy_adopt_clears_active_claims() -> None: """SDK-defined: a legacy adopt clears active claims and restores the module-level adapter.""" dispatcher = _RecordingDispatcher(tool_result=_CLAIMED_TASK_RESULT) session = _claims_session(dispatcher, _task_claim()) with anyio.fail_after(5): async with session: _adopt_modern(session) assert isinstance(await session.call_tool("t", {}, allow_claimed=True), _TaskResult) _adopt_handshake(session) assert session._call_tool_adapter is _CallToolResultAdapter with pytest.raises(ValidationError): await session.call_tool("t", {}, allow_claimed=True) # Rejected at response parsing; the request did reach the wire. assert dispatcher.calls[-1][0] == "tools/call" @pytest.mark.anyio async def test_modern_readopt_after_legacy_reactivates_claims() -> None: """SDK-defined: a modern re-adopt after legacy reactivates the claims.""" dispatcher = _RecordingDispatcher(tool_result=_CLAIMED_TASK_RESULT) session = _claims_session(dispatcher, _task_claim()) with anyio.fail_after(5): async with session: _adopt_modern(session) _adopt_handshake(session) assert session._call_tool_adapter is _CallToolResultAdapter _adopt_modern(session) result = await session.call_tool("t", {}, allow_claimed=True) assert isinstance(result, _TaskResult) assert session._call_tool_adapter is not _CallToolResultAdapter @pytest.mark.anyio async def test_legacy_initialize_ad_drops_claim_bearing_identifiers() -> None: """SDK-defined: the legacy initialize ad drops claim-bearing identifiers; ad-only ones ride along.""" dispatcher = _RecordingDispatcher() session = ClientSession( dispatcher=dispatcher, extensions={_TASKS_EXT: {}, _AD_ONLY_EXT: {"flag": True}}, result_claims={_TASKS_EXT: [_task_claim()]}, ) with anyio.fail_after(5): async with session: await session.initialize() [(_, params, _)] = [call for call in dispatcher.calls if call[0] == "initialize"] assert params is not None assert params["capabilities"]["extensions"] == {_AD_ONLY_EXT: {"flag": True}} @pytest.mark.anyio async def test_legacy_ad_omits_extensions_entirely_when_every_identifier_drops() -> None: """SDK-defined: when every identifier drops, the ad omits the `extensions` key entirely.""" dispatcher = _RecordingDispatcher() session = _claims_session(dispatcher, _task_claim()) with anyio.fail_after(5): async with session: await session.initialize() [(_, params, _)] = [call for call in dispatcher.calls if call[0] == "initialize"] assert params is not None assert "extensions" not in params["capabilities"] @pytest.mark.anyio async def test_modern_adopt_ad_includes_active_claim_identifiers() -> None: """SDK-defined: the modern per-request `_meta` ad includes identifiers whose claims are active.""" dispatcher = _RecordingDispatcher() session = ClientSession( dispatcher=dispatcher, extensions={_TASKS_EXT: {}, _AD_ONLY_EXT: {"flag": True}}, result_claims={_TASKS_EXT: [_task_claim()]}, ) with anyio.fail_after(5): async with session: _adopt_modern(session) await session.send_ping() [(_, params, _)] = dispatcher.calls assert params is not None capabilities = params["_meta"][CLIENT_CAPABILITIES_META_KEY] assert capabilities["extensions"] == {_TASKS_EXT: {}, _AD_ONLY_EXT: {"flag": True}} @pytest.mark.anyio async def test_discover_probe_ad_includes_claim_identifiers_at_the_probe_version() -> None: """SDK-defined: `send_discover` builds its `_meta` ad at the probe version, where claims are active.""" dispatcher = _RecordingDispatcher() session = _claims_session(dispatcher, _task_claim()) with anyio.fail_after(5): async with session: await session.send_discover(LATEST_MODERN_VERSION) [(_, params, _)] = dispatcher.calls assert params is not None capabilities = params["_meta"][CLIENT_CAPABILITIES_META_KEY] assert capabilities["extensions"] == {_TASKS_EXT: {}} @pytest.mark.anyio async def test_discover_probe_ad_drops_claim_identifiers_at_a_legacy_probe_version() -> None: """SDK-defined: at a legacy probe version no claim can be active, so the identifier drops.""" dispatcher = _RecordingDispatcher() session = _claims_session(dispatcher, _task_claim()) with anyio.fail_after(5): async with session: await session.send_discover(LATEST_HANDSHAKE_VERSION) [(_, params, _)] = dispatcher.calls assert params is not None capabilities = params["_meta"][CLIENT_CAPABILITIES_META_KEY] assert "extensions" not in capabilities class _CoreTaggedResult(Result): """A claim whose wire tag collides with the adapter's internal routing sentinel.""" result_type: Literal["core"] = "core" payload: str = "" async def _resolve_core_tagged(result: _CoreTaggedResult, ctx: ClaimContext) -> CallToolResult: raise NotImplementedError @pytest.mark.anyio async def test_claim_tagged_core_cannot_hijack_core_parsing() -> None: """SDK-defined: a claim may use "core" as its wire tag without colliding with core parsing.""" claim = ResultClaim(result_type="core", model=_CoreTaggedResult, resolve=_resolve_core_tagged) dispatcher = _RecordingDispatcher(tool_result={"resultType": "core", "payload": "p-1"}) session = ClientSession(dispatcher=dispatcher, extensions={_TASKS_EXT: {}}, result_claims={_TASKS_EXT: [claim]}) with anyio.fail_after(5): async with session: _adopt_modern(session) ordinary = session._call_tool_adapter.validate_python(_COMPLETE_TOOL_RESULT) claimed = await session.call_tool("t", {}, allow_claimed=True) assert isinstance(ordinary, CallToolResult) assert isinstance(claimed, _CoreTaggedResult) @pytest.mark.anyio @pytest.mark.parametrize("with_claims", [True, False]) async def test_unknown_result_type_fails_validation_with_and_without_claims(with_claims: bool) -> None: """SDK-defined: a resultType outside the active claim set fails core validation, claims or not.""" raw = {"resultType": "weird", "taskId": "t-1"} dispatcher = _RecordingDispatcher(tool_result=raw) session = _claims_session(dispatcher, _task_claim()) if with_claims else ClientSession(dispatcher=dispatcher) with anyio.fail_after(5): async with session: _adopt_modern(session) with pytest.raises(ValidationError): await session.call_tool("t", {}, allow_claimed=True) # Rejected at response parsing; the request did reach the wire. assert dispatcher.calls[-1][0] == "tools/call" @pytest.mark.anyio async def test_non_string_result_type_fails_core_validation_not_discrimination() -> None: """SDK-defined: a non-string resultType stays on the core arm and fails as ValidationError, not TypeError.""" raw: dict[str, Any] = {"resultType": {"nested": True}} dispatcher = _RecordingDispatcher(tool_result=raw) session = _claims_session(dispatcher, _task_claim()) with anyio.fail_after(5): async with session: _adopt_modern(session) with pytest.raises(ValidationError): await session.call_tool("t", {}, allow_claimed=True) # Rejected at response parsing; the request did reach the wire. assert dispatcher.calls[-1][0] == "tools/call" def test_adopt_built_adapter_revalidates_model_instances() -> None: """SDK-defined: the adopt-built adapter routes already-built model instances as well as raw dicts.""" session = _claims_session(_RecordingDispatcher(), _task_claim()) _adopt_modern(session) adapter = session._call_tool_adapter claimed = adapter.validate_python(_TaskResult(task_id="t-2")) assert isinstance(claimed, _TaskResult) core = adapter.validate_python(CallToolResult(content=[])) assert isinstance(core, CallToolResult) @pytest.mark.anyio async def test_input_required_routes_to_core_arm_with_claims_active() -> None: """Spec-mandated: `input_required` is core vocabulary; active claims leave that arm untouched.""" raw = {"resultType": "input_required", "requestState": "s-1"} session = _claims_session(_RecordingDispatcher(tool_result=raw), _task_claim()) with anyio.fail_after(5): async with session: _adopt_modern(session) result = await session.call_tool("t", {}, allow_input_required=True, allow_claimed=True) assert isinstance(result, InputRequiredResult) assert result.request_state == "s-1" @pytest.mark.anyio async def test_claimed_result_raises_unexpected_claimed_result_by_default() -> None: """SDK-defined: without `allow_claimed` a claimed shape raises, carrying the parsed result so the caller can clean up any server-side state it references.""" dispatcher = _RecordingDispatcher(tool_result=_CLAIMED_TASK_RESULT) session = _claims_session(dispatcher, _task_claim()) with anyio.fail_after(5): async with session: _adopt_modern(session) with pytest.raises(UnexpectedClaimedResult) as exc_info: await session.call_tool("t", {}) # The shape parsed and then raised; the request did reach the wire. assert dispatcher.calls[-1][0] == "tools/call" assert isinstance(exc_info.value.result, _TaskResult) assert exc_info.value.result.task_id == "t-1" assert str(exc_info.value) == snapshot( "Server returned a claimed result (_TaskResult); pass the owning extension to " "Client(extensions=[...]) for transparent resolution, or call with allow_claimed=True " "and handle the shape. The carried result may reference server-side state needing cleanup." ) @pytest.mark.anyio async def test_call_tool_result_path_identical_under_both_allow_claimed_values() -> None: """SDK-defined: `allow_claimed` only affects claimed shapes; ordinary results come back identical.""" dispatcher = _RecordingDispatcher() session = _claims_session(dispatcher, _task_claim()) with anyio.fail_after(5): async with session: _adopt_modern(session) r_default = await session.call_tool("t", {}) r_opted = await session.call_tool("t", {}, allow_claimed=True) assert isinstance(r_opted, CallToolResult) assert r_opted == r_default @pytest.mark.anyio async def test_call_tool_overload_matrix_narrows_statically() -> None: """SDK-defined: each flag combination narrows `call_tool` to its documented return union under pyright.""" dispatcher = _RecordingDispatcher() session = _claims_session(dispatcher, _task_claim()) with anyio.fail_after(5): async with session: _adopt_modern(session) r1 = await session.call_tool("t", {}) assert_type(r1, CallToolResult) r2 = await session.call_tool("t", {}, allow_input_required=True) assert_type(r2, CallToolResult | InputRequiredResult) r3 = await session.call_tool("t", {}, allow_claimed=True) assert_type(r3, CallToolResult | Result) r4 = await session.call_tool("t", {}, allow_input_required=True, allow_claimed=True) assert_type(r4, CallToolResult | InputRequiredResult | Result) assert [type(r) for r in (r1, r2, r3, r4)] == [CallToolResult] * 4 def test_claimed_raw_passes_v2026_tools_call_surface_validation() -> None: """Pins the claim path's dependency: an unknown resultType passes `validate_server_result` at 2026-07-28; this failing is the signal that mcp-types tightened the surface.""" validate_server_result("tools/call", LATEST_MODERN_VERSION, {"resultType": "task", "taskId": "t-1"})