"""Tests for the unified Client class."""

from __future__ import annotations

import contextvars
from collections.abc import AsyncIterator, Iterator
from contextlib import asynccontextmanager, contextmanager
from unittest.mock import patch

import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
    CallToolResult,
    EmptyResult,
    GetPromptResult,
    ListPromptsResult,
    ListResourcesResult,
    ListResourceTemplatesResult,
    ListToolsResult,
    Prompt,
    PromptArgument,
    PromptMessage,
    PromptsCapability,
    ReadResourceResult,
    Resource,
    ResourcesCapability,
    ServerCapabilities,
    TextContent,
    TextResourceContents,
    Tool,
    ToolsCapability,
)
from mcp_types.version import LATEST_HANDSHAKE_VERSION
from pydantic import FileUrl

from mcp import MCPDeprecationWarning, MCPError
from mcp.client._memory import InMemoryTransport
from mcp.client._transport import TransportStreams
from mcp.client.client import Client
from mcp.client.session import ClientRequestContext
from mcp.client.streamable_http import streamable_http_client
from mcp.server import Server, ServerRequestContext
from mcp.server.mcpserver import Context, MCPServer
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
from mcp.shared.message import SessionMessage
from tests.interaction._connect import BASE_URL, mounted_app

pytestmark = pytest.mark.anyio


@pytest.fixture
def simple_server() -> Server:
    """Create a simple MCP server for testing."""

    async def handle_list_resources(
        ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
    ) -> ListResourcesResult:
        return ListResourcesResult(
            resources=[Resource(uri="memory://test", name="Test Resource", description="A test resource")]
        )

    async def handle_subscribe_resource(ctx: ServerRequestContext, params: types.SubscribeRequestParams) -> EmptyResult:
        return EmptyResult()

    async def handle_unsubscribe_resource(
        ctx: ServerRequestContext, params: types.UnsubscribeRequestParams
    ) -> EmptyResult:
        return EmptyResult()

    async def handle_set_logging_level(ctx: ServerRequestContext, params: types.SetLevelRequestParams) -> EmptyResult:
        return EmptyResult()

    async def handle_completion(ctx: ServerRequestContext, params: types.CompleteRequestParams) -> types.CompleteResult:
        return types.CompleteResult(completion=types.Completion(values=[]))

    return Server(  # pyright: ignore[reportDeprecated]
        name="test_server",
        on_list_resources=handle_list_resources,
        on_subscribe_resource=handle_subscribe_resource,
        on_unsubscribe_resource=handle_unsubscribe_resource,
        on_set_logging_level=handle_set_logging_level,
        on_completion=handle_completion,
    )


@pytest.fixture
def app() -> MCPServer:
    """Create an MCPServer server for testing."""
    server = MCPServer("test")

    @server.tool()
    def greet(name: str) -> str:
        """Greet someone by name."""
        return f"Hello, {name}!"

    @server.resource("test://resource")
    def test_resource() -> str:
        """A test resource."""
        return "Test content"

    @server.prompt()
    def greeting_prompt(name: str) -> str:
        """A greeting prompt."""
        return f"Please greet {name} warmly."

    return server


async def test_client_is_initialized(app: MCPServer):
    """Test that the client is initialized after entering context."""
    async with Client(app, mode="legacy") as client:
        assert client.server_capabilities == snapshot(
            ServerCapabilities(
                experimental={},
                prompts=PromptsCapability(list_changed=False),
                resources=ResourcesCapability(subscribe=False, list_changed=False),
                tools=ToolsCapability(list_changed=False),
            )
        )
        assert client.server_info.name == "test"


async def test_client_exposes_negotiated_protocol_version(app: MCPServer):
    """The negotiated protocol version is readable after initialization."""
    async with Client(app, mode="legacy") as client:
        assert client.protocol_version == LATEST_HANDSHAKE_VERSION


async def test_client_with_simple_server(simple_server: Server):
    """Test that from_server works with a basic Server instance."""
    async with Client(simple_server) as client:
        resources = await client.list_resources()
        assert resources == snapshot(
            ListResourcesResult(
                resources=[Resource(name="Test Resource", uri="memory://test", description="A test resource")]
            )
        )


async def test_client_send_ping(app: MCPServer):
    async with Client(app, mode="legacy") as client:
        result = await client.send_ping()  # pyright: ignore[reportDeprecated]
        assert result == snapshot(EmptyResult())


async def test_client_list_tools(app: MCPServer):
    async with Client(app) as client:
        result = await client.list_tools()
        assert result == snapshot(
            ListToolsResult(
                tools=[
                    Tool(
                        name="greet",
                        description="Greet someone by name.",
                        input_schema={
                            "properties": {"name": {"title": "Name", "type": "string"}},
                            "required": ["name"],
                            "title": "greetArguments",
                            "type": "object",
                        },
                        output_schema={
                            "properties": {"result": {"title": "Result", "type": "string"}},
                            "required": ["result"],
                            "title": "greetOutput",
                            "type": "object",
                        },
                    )
                ]
            )
        )


async def test_client_call_tool(app: MCPServer):
    async with Client(app) as client:
        result = await client.call_tool("greet", {"name": "World"})
        assert result == snapshot(
            CallToolResult(
                content=[TextContent(text="Hello, World!")],
                structured_content={"result": "Hello, World!"},
            )
        )


async def test_read_resource(app: MCPServer):
    """Test reading a resource."""
    async with Client(app) as client:
        result = await client.read_resource("test://resource")
        assert result == snapshot(
            ReadResourceResult(
                contents=[TextResourceContents(uri="test://resource", mime_type="text/plain", text="Test content")]
            )
        )


async def test_read_resource_error_propagates():
    """MCPError raised by a server handler propagates to the client with its code intact."""

    async def handle_read_resource(
        ctx: ServerRequestContext, params: types.ReadResourceRequestParams
    ) -> ReadResourceResult:
        raise MCPError(code=404, message="no resource with that URI was found")

    server = Server("test", on_read_resource=handle_read_resource)
    async with Client(server) as client:
        with pytest.raises(MCPError) as exc_info:
            await client.read_resource("unknown://example")
        assert exc_info.value.error.code == 404


async def test_raise_exceptions_propagates_handler_error_on_modern_inproc_path():
    """`raise_exceptions=True` on the modern in-process path: an unmapped handler
    exception reaches the client with its original type chained, instead of being
    sanitized to an opaque `INTERNAL_ERROR`."""

    async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
        raise ValueError("boom")

    server = Server("test", on_call_tool=handle_call_tool)
    async with Client(server, mode="2026-07-28", raise_exceptions=True) as client:
        with pytest.raises(MCPError) as exc_info:
            await client.call_tool("explode", {})
    # The original exception is chained — not swallowed into a generic "Internal server error".
    assert isinstance(exc_info.value.__cause__, ValueError)
    assert str(exc_info.value.__cause__) == "boom"


async def test_raise_exceptions_false_sanitizes_handler_error_on_modern_inproc_path():
    """`raise_exceptions=False` (the default) on the modern in-process path: an
    unmapped handler exception is sanitized to an opaque `INTERNAL_ERROR` so the
    in-process path matches the wire path's leak guard."""

    async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
        raise ValueError("boom")

    server = Server("test", on_call_tool=handle_call_tool)
    async with Client(server, mode="2026-07-28", raise_exceptions=False) as client:
        with pytest.raises(MCPError) as exc_info:
            await client.call_tool("explode", {})
    assert exc_info.value.error.code == types.INTERNAL_ERROR
    assert exc_info.value.error.message == "Internal server error"
    assert exc_info.value.__cause__ is None


async def test_modern_inproc_path_refuses_server_initiated_requests():
    """The in-process modern entry enforces the same prohibition as the other
    modern entries: a handler's request-scoped server-initiated request is
    refused server-side with the no-back-channel contract, instead of the
    protocol-forbidden frame being delivered to the client."""

    async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
        schema = types.ElicitRequestedSchema(type="object", properties={"x": {"type": "string"}})
        await ctx.session.elicit_form("question", schema, related_request_id=ctx.request_id)
        raise AssertionError("unreachable: elicit_form must refuse")  # pragma: no cover

    server = Server("test", on_call_tool=handle_call_tool)
    async with Client(server, mode="2026-07-28") as client:
        with pytest.raises(MCPError) as exc_info:
            await client.call_tool("asker", {})
    assert exc_info.value.error.code == types.INVALID_REQUEST
    assert "no back-channel" in exc_info.value.error.message
    assert "elicitation/create" in exc_info.value.error.message


async def test_get_prompt(app: MCPServer):
    """Test getting a prompt."""
    async with Client(app) as client:
        result = await client.get_prompt("greeting_prompt", {"name": "Alice"})
        assert result == snapshot(
            GetPromptResult(
                description="A greeting prompt.",
                messages=[PromptMessage(role="user", content=TextContent(text="Please greet Alice warmly."))],
            )
        )


def test_client_session_property_before_enter(app: MCPServer):
    """Test that accessing session before context manager raises RuntimeError."""
    client = Client(app)
    with pytest.raises(RuntimeError, match="Client must be used within an async context manager"):
        client.session


async def test_client_reentry_raises_runtime_error(app: MCPServer):
    """Test that reentering a client raises RuntimeError."""
    async with Client(app) as client:
        with pytest.raises(RuntimeError, match="Client is already entered"):
            await client.__aenter__()


async def test_client_send_progress_notification():
    """Test sending progress notification."""
    received_from_client = None
    event = anyio.Event()

    async def handle_progress(ctx: ServerRequestContext, params: types.ProgressNotificationParams) -> None:
        nonlocal received_from_client
        received_from_client = {"progress_token": params.progress_token, "progress": params.progress}
        event.set()

    server = Server(name="test_server", on_progress=handle_progress)  # pyright: ignore[reportDeprecated]

    with anyio.fail_after(5):
        async with Client(server, mode="legacy") as client:
            await client.send_progress_notification(progress_token="token123", progress=50.0)  # pyright: ignore[reportDeprecated]
            await event.wait()
            assert received_from_client == snapshot({"progress_token": "token123", "progress": 50.0})


async def test_client_subscribe_resource(simple_server: Server):
    async with Client(simple_server, mode="legacy") as client:
        with pytest.warns(MCPDeprecationWarning, match="use Client.listen"):
            result = await client.subscribe_resource("memory://test")  # pyright: ignore[reportDeprecated]
        assert result == snapshot(EmptyResult())


async def test_client_unsubscribe_resource(simple_server: Server):
    async with Client(simple_server, mode="legacy") as client:
        with pytest.warns(MCPDeprecationWarning, match="use Client.listen"):
            result = await client.unsubscribe_resource("memory://test")  # pyright: ignore[reportDeprecated]
        assert result == snapshot(EmptyResult())


async def test_client_set_logging_level(simple_server: Server):
    """Test setting logging level."""
    async with Client(simple_server, mode="legacy") as client:
        result = await client.set_logging_level("debug")  # pyright: ignore[reportDeprecated]
        assert result == snapshot(EmptyResult())


async def test_client_list_resources_with_params(app: MCPServer):
    """Test listing resources with params parameter."""
    async with Client(app) as client:
        result = await client.list_resources()
        assert result == snapshot(
            ListResourcesResult(
                resources=[
                    Resource(
                        name="test_resource",
                        uri="test://resource",
                        description="A test resource.",
                        mime_type="text/plain",
                    )
                ]
            )
        )


async def test_client_list_resource_templates(app: MCPServer):
    """Test listing resource templates with params parameter."""
    async with Client(app) as client:
        result = await client.list_resource_templates()
        assert result == snapshot(ListResourceTemplatesResult(resource_templates=[]))


async def test_list_prompts(app: MCPServer):
    """Test listing prompts with params parameter."""
    async with Client(app) as client:
        result = await client.list_prompts()
        assert result == snapshot(
            ListPromptsResult(
                prompts=[
                    Prompt(
                        name="greeting_prompt",
                        description="A greeting prompt.",
                        arguments=[PromptArgument(name="name", required=True)],
                    )
                ]
            )
        )


async def test_complete_with_prompt_reference(simple_server: Server):
    """Test getting completions for a prompt argument."""
    async with Client(simple_server) as client:
        ref = types.PromptReference(type="ref/prompt", name="test_prompt")
        result = await client.complete(ref=ref, argument={"name": "arg", "value": "test"})
        assert result == snapshot(types.CompleteResult(completion=types.Completion(values=[])))


def test_client_with_url_initializes_streamable_http_transport():
    with patch("mcp.client.client.streamable_http_client") as mock:
        _ = Client("http://localhost:8000/mcp")
    mock.assert_called_once_with("http://localhost:8000/mcp")


async def test_client_uses_transport_directly(app: MCPServer):
    transport = InMemoryTransport(app)
    async with Client(transport, mode="legacy") as client:
        result = await client.call_tool("greet", {"name": "Transport"})
        assert result == snapshot(
            CallToolResult(
                content=[TextContent(text="Hello, Transport!")],
                structured_content={"result": "Hello, Transport!"},
            )
        )


_TEST_CONTEXTVAR = contextvars.ContextVar("test_var", default="initial")


@contextmanager
def _set_test_contextvar(value: str) -> Iterator[None]:
    token = _TEST_CONTEXTVAR.set(value)
    try:
        yield
    finally:
        _TEST_CONTEXTVAR.reset(token)


async def test_context_propagation():
    """Sender's contextvars.Context is propagated to the server handler."""
    server = MCPServer("test")

    @server.tool()
    async def check_context() -> str:
        """Return the contextvar value visible to the handler."""
        return _TEST_CONTEXTVAR.get()

    async with Client(server) as client:
        with _set_test_contextvar("client_value"):
            result = await client.call_tool("check_context", {})

    assert result.content[0].text == "client_value", (  # type: ignore[union-attr]
        "Server handler did not see the sender's contextvars.Context"
    )


async def test_client_auto_mode_probes_discover_then_adopts(simple_server: Server) -> None:
    """`mode='auto'` over an in-process HTTP transport: the `server/discover` probe
    reaches the modern entry and the negotiated protocol version is adopted without
    an `initialize` handshake."""
    with anyio.fail_after(5):
        async with (
            mounted_app(simple_server) as (http, _),
            Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode="auto") as client,
        ):
            assert client.protocol_version == "2026-07-28"
            assert (await client.list_resources()).resources[0].name == "Test Resource"


@asynccontextmanager
async def _stream_loop_transport(server: Server) -> AsyncIterator[TransportStreams]:
    """A Transport whose far end is `Server.run` over crossed memory streams - the stdio shape, in process."""
    async with (
        create_client_server_memory_streams() as ((client_read, client_write), (server_read, server_write)),
        anyio.create_task_group() as tg,
    ):
        tg.start_soon(server.run, server_read, server_write, server.create_initialization_options())
        yield client_read, client_write
        tg.cancel_scope.cancel()


async def test_client_auto_mode_negotiates_modern_over_a_stream_loop(simple_server: Server) -> None:
    """`mode='auto'` against a real `Server.run` stream loop: the probe reaches the
    dual-era driver, the connection locks modern, and feature requests are served
    at 2026-07-28 with no `initialize` handshake."""
    with anyio.fail_after(5):
        async with Client(_stream_loop_transport(simple_server), mode="auto") as client:
            assert client.protocol_version == "2026-07-28"
            assert (await client.list_resources()).resources[0].name == "Test Resource"


async def test_client_pinned_modern_mode_works_over_a_stream_loop(simple_server: Server) -> None:
    """A pinned-modern client sends no probe: its first envelope-bearing request
    locks the stream-loop connection modern and is served."""
    with anyio.fail_after(5):
        async with Client(_stream_loop_transport(simple_server), mode="2026-07-28") as client:
            assert client.protocol_version == "2026-07-28"
            assert (await client.list_resources()).resources[0].name == "Test Resource"


async def test_client_legacy_mode_still_handshakes_over_a_stream_loop(simple_server: Server) -> None:
    """`mode='legacy'` against the dual-era stream loop is byte-identical legacy:
    the handshake runs and the session lands at a handshake-era version."""
    with anyio.fail_after(5):
        async with Client(_stream_loop_transport(simple_server), mode="legacy") as client:
            assert client.protocol_version == LATEST_HANDSHAKE_VERSION
            assert (await client.list_resources()).resources[0].name == "Test Resource"


async def test_client_auto_mode_recovers_from_a_timed_out_probe_over_a_stream_loop(
    simple_server: Server, monkeypatch: pytest.MonkeyPatch
) -> None:
    """A probe that outlives the client's discover timeout still succeeds on the
    (slow-starting) server and locks the connection modern; the fallback
    handshake's -32022 is modern evidence, so one corrective re-probe completes
    the connect instead of stranding `mode='auto'`."""
    monkeypatch.setattr("mcp.client.session.DISCOVER_TIMEOUT_SECONDS", 0.05)
    c2relay_send, c2relay_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
    relay2s_send, relay2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
    s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)

    async def relay() -> None:
        # Hold the client's first frame (the probe) until its second frame (the
        # post-timeout initialize) arrives - the deterministic stand-in for a
        # server too slow to answer before the client's discover timeout.
        held: SessionMessage | Exception | None = None
        first = True
        async for item in c2relay_recv:
            if first:
                held, first = item, False
                continue
            if held is not None:
                await relay2s_send.send(held)
                held = None
            await relay2s_send.send(item)

    @asynccontextmanager
    async def transport() -> AsyncIterator[TransportStreams]:
        async with c2relay_send, c2relay_recv, relay2s_send, relay2s_recv, s2c_send, s2c_recv:
            async with anyio.create_task_group() as tg:
                tg.start_soon(simple_server.run, relay2s_recv, s2c_send, simple_server.create_initialization_options())
                tg.start_soon(relay)
                yield s2c_recv, c2relay_send
                tg.cancel_scope.cancel()

    with anyio.fail_after(10):
        async with Client(transport(), mode="auto") as client:
            assert client.protocol_version == "2026-07-28"
            assert (await client.list_resources()).resources[0].name == "Test Resource"


@pytest.mark.parametrize("code", [types.METHOD_NOT_FOUND, types.REQUEST_TIMEOUT, types.INTERNAL_ERROR])
async def test_client_auto_mode_falls_back_to_initialize_on_legacy_signal(code: int) -> None:
    """`mode='auto'`: any JSON-RPC error from `server/discover` makes
    `Client.__aenter__` run the legacy `initialize()` handshake and land at a
    handshake-era protocol version. The denylist policy treats every server-sent
    rpc-error as "not modern" — including INTERNAL_ERROR, since a legacy server
    may crash on the unknown method before reaching its router. A real `Server`
    always implements `server/discover`, so the server side is hand-played."""
    methods_seen: list[str] = []

    async def scripted_server(streams: MessageStream) -> None:
        server_read, server_write = streams
        async for message in server_read:
            assert isinstance(message, SessionMessage)
            frame = message.message
            assert isinstance(frame, types.JSONRPCRequest | types.JSONRPCNotification)
            methods_seen.append(frame.method)
            if isinstance(frame, types.JSONRPCNotification):
                continue
            if frame.method == "server/discover":
                error = types.ErrorData(code=code, message="nope")
                await server_write.send(SessionMessage(types.JSONRPCError(jsonrpc="2.0", id=frame.id, error=error)))
            elif frame.method == "initialize":  # pragma: no branch
                result = types.InitializeResult(
                    protocol_version=LATEST_HANDSHAKE_VERSION,
                    capabilities=ServerCapabilities(),
                    server_info=types.Implementation(name="legacy-only", version="0.0.1"),
                )
                await server_write.send(
                    SessionMessage(
                        types.JSONRPCResponse(
                            jsonrpc="2.0",
                            id=frame.id,
                            result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
                        )
                    )
                )

    @asynccontextmanager
    async def scripted_transport() -> AsyncIterator[TransportStreams]:
        async with (
            create_client_server_memory_streams() as ((client_read, client_write), server_streams),
            anyio.create_task_group() as tg,
        ):
            tg.start_soon(scripted_server, server_streams)
            yield client_read, client_write
            tg.cancel_scope.cancel()

    with anyio.fail_after(5):
        async with Client(scripted_transport(), mode="auto") as client:
            assert client.protocol_version == LATEST_HANDSHAKE_VERSION
            assert client.server_info.name == "legacy-only"
    assert methods_seen == ["server/discover", "initialize", "notifications/initialized"]


@pytest.mark.anyio
async def test_modern_list_tools_drops_tools_with_invalid_x_mcp_header_but_legacy_does_not() -> None:
    """At 2026-07-28 the spec requires clients to exclude tools whose `x-mcp-header`
    annotation is malformed; handshake-era sessions surface them unchanged. Two
    tools are advertised — one valid, one with a non-RFC-9110-token header name —
    and the modern client sees only the valid one."""
    valid = types.Tool(
        name="ok",
        input_schema={"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "Region"}}},
    )
    bad = types.Tool(
        name="dropme",
        input_schema={"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "bad name"}}},
    )

    async def on_list_tools(
        ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
    ) -> types.ListToolsResult:
        return types.ListToolsResult(tools=[valid, bad])

    server = Server("test", on_list_tools=on_list_tools)

    with anyio.fail_after(5):
        async with Client(server) as client:
            result = await client.list_tools()
        assert [t.name for t in result.tools] == ["ok"]

        async with Client(server, mode="legacy") as client:
            result = await client.list_tools()
        assert [t.name for t in result.tools] == ["ok", "dropme"]


_RETIRED_TOOL = Tool(
    name="retired",
    input_schema={"type": "object", "properties": {"region": {"type": "string", "x-mcp-header": "Region"}}},
    output_schema={"type": "object"},
)
_SURVIVOR_TOOL = Tool(name="survivor", input_schema={"type": "object"})


def _scripted_listing_server(listings: list[ListToolsResult]) -> Server:
    """Serves the given listings in order, one per tools/list request."""

    async def on_list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
        return listings.pop(0)

    return Server("test", on_list_tools=on_list_tools)


async def test_a_complete_listing_prunes_per_tool_state_for_tools_it_no_longer_contains() -> None:
    """SDK-defined: a complete (uncursored, cursorless) listing is the full tool universe, so the
    header map and output schema derived from an earlier listing of a now-absent tool are dropped."""
    server = _scripted_listing_server(
        [
            ListToolsResult(tools=[_RETIRED_TOOL, _SURVIVOR_TOOL]),
            ListToolsResult(tools=[_SURVIVOR_TOOL]),
        ]
    )

    with anyio.fail_after(5):
        async with Client(server) as client:
            await client.session.list_tools()
            assert set(client.session._x_mcp_header_maps) == {"retired", "survivor"}
            assert set(client.session._tool_output_schemas) == {"retired", "survivor"}

            await client.session.list_tools()
            assert set(client.session._x_mcp_header_maps) == {"survivor"}
            assert set(client.session._tool_output_schemas) == {"survivor"}


async def test_a_complete_listing_prunes_output_schemas_on_a_legacy_session_too() -> None:
    """SDK-defined: the prune is era-independent -- legacy sessions cache output schemas the same
    way (their header-map dict just stays empty, since the x-mcp-header filter is 2026-only)."""
    server = _scripted_listing_server(
        [
            ListToolsResult(tools=[_RETIRED_TOOL, _SURVIVOR_TOOL]),
            ListToolsResult(tools=[_SURVIVOR_TOOL]),
        ]
    )

    with anyio.fail_after(5):
        async with Client(server, mode="legacy") as client:
            await client.session.list_tools()
            assert set(client.session._tool_output_schemas) == {"retired", "survivor"}
            assert client.session._x_mcp_header_maps == {}

            await client.session.list_tools()
            assert set(client.session._tool_output_schemas) == {"survivor"}


async def test_a_listing_with_a_next_cursor_prunes_no_per_tool_state() -> None:
    """SDK-defined: a first page carrying next_cursor is not the full universe -- state for tools
    expected on later pages must survive it."""
    server = _scripted_listing_server(
        [
            ListToolsResult(tools=[_RETIRED_TOOL, _SURVIVOR_TOOL]),
            ListToolsResult(tools=[_SURVIVOR_TOOL], next_cursor="2"),
        ]
    )

    with anyio.fail_after(5):
        async with Client(server) as client:
            await client.session.list_tools()
            await client.session.list_tools()
            assert set(client.session._x_mcp_header_maps) == {"retired", "survivor"}
            assert set(client.session._tool_output_schemas) == {"retired", "survivor"}


async def test_a_cursor_page_fetch_prunes_no_per_tool_state() -> None:
    """SDK-defined: a continuation page is partial even when it ends the pagination (no
    next_cursor) -- only an uncursored single-page listing prunes."""
    server = _scripted_listing_server(
        [
            ListToolsResult(tools=[_RETIRED_TOOL, _SURVIVOR_TOOL]),
            ListToolsResult(tools=[_SURVIVOR_TOOL]),
        ]
    )

    with anyio.fail_after(5):
        async with Client(server) as client:
            await client.session.list_tools()
            await client.session.list_tools(params=types.PaginatedRequestParams(cursor="2"))
            assert set(client.session._x_mcp_header_maps) == {"retired", "survivor"}
            assert set(client.session._tool_output_schemas) == {"retired", "survivor"}


def test_client_rejects_handshake_era_mode_at_construction() -> None:
    """A handshake-era protocol-version string passed as `mode=` is rejected by
    `__post_init__` with a hint to use `mode='legacy'` — the version-pin path is
    modern-only."""
    server = MCPServer("test")
    with pytest.raises(ValueError, match=r"handshake-era version; use mode='legacy'"):
        Client(server, mode="2025-06-18")
    with pytest.raises(ValueError, match=r"mode must be 'legacy', 'auto', or one of"):
        Client(server, mode="not-a-version")


# ── SEP-2322 multi-round-trip auto-loop ────────────────────────────────────────


_NAME_SCHEMA = {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}


def _name_elicitation(message: str = "What is your name?") -> types.ElicitRequest:
    return types.ElicitRequest(params=types.ElicitRequestFormParams(message=message, requested_schema=_NAME_SCHEMA))


async def test_call_tool_auto_loop_dispatches_elicitation_then_returns_final_result() -> None:
    """When the server returns `InputRequiredResult` carrying an elicitation,
    `Client.call_tool` routes it to `elicitation_callback` and retries
    automatically — the caller sees only the terminal `CallToolResult`."""
    server = MCPServer("test")

    @server.tool()
    async def greet(ctx: Context) -> str | types.InputRequiredResult:
        responses = ctx.input_responses
        if responses and "user_name" in responses:
            answer = responses["user_name"]
            assert isinstance(answer, types.ElicitResult)
            assert answer.content is not None
            return f"Hello, {answer.content['name']}!"
        return types.InputRequiredResult(input_requests={"user_name": _name_elicitation()})

    callback_params: list[types.ElicitRequestParams] = []

    async def elicitation_callback(
        context: ClientRequestContext, params: types.ElicitRequestParams
    ) -> types.ElicitResult | types.ErrorData:
        callback_params.append(params)
        assert context.request_id == "user_name"  # the inputRequests key is the request id
        return types.ElicitResult(action="accept", content={"name": "Ada"})

    with anyio.fail_after(5):
        async with Client(server, elicitation_callback=elicitation_callback) as client:
            result = await client.call_tool("greet")

    assert result == snapshot(
        CallToolResult(content=[TextContent(text="Hello, Ada!")], structured_content={"result": "Hello, Ada!"})
    )
    assert len(callback_params) == 1
    assert isinstance(callback_params[0], types.ElicitRequestFormParams)
    assert callback_params[0].message == "What is your name?"
    assert callback_params[0].requested_schema == _NAME_SCHEMA


async def test_call_tool_auto_loop_dispatches_sampling_then_returns_final_result() -> None:
    """`InputRequiredResult` with an embedded `CreateMessageRequest` is routed
    to `sampling_callback` and the call retried with the model's reply."""
    server = MCPServer("test")

    @server.tool()
    async def ask(ctx: Context) -> str | types.InputRequiredResult:
        responses = ctx.input_responses
        if responses and "q" in responses:
            answer = responses["q"]
            assert isinstance(answer, types.CreateMessageResult)
            assert answer.content.type == "text"
            return f"Model said: {answer.content.text}"
        return types.InputRequiredResult(
            input_requests={
                "q": types.CreateMessageRequest(
                    params=types.CreateMessageRequestParams(
                        messages=[types.SamplingMessage(role="user", content=TextContent(text="Capital of France?"))],
                        max_tokens=10,
                    )
                )
            }
        )

    callback_params: list[types.CreateMessageRequestParams] = []

    async def sampling_callback(
        context: ClientRequestContext, params: types.CreateMessageRequestParams
    ) -> types.CreateMessageResult | types.ErrorData:
        callback_params.append(params)
        return types.CreateMessageResult(role="assistant", content=TextContent(text="Paris"), model="echo")

    with anyio.fail_after(5):
        async with Client(server, sampling_callback=sampling_callback) as client:
            result = await client.call_tool("ask")

    assert result == snapshot(
        CallToolResult(
            content=[TextContent(text="Model said: Paris")], structured_content={"result": "Model said: Paris"}
        )
    )
    assert len(callback_params) == 1
    assert callback_params[0].messages[0].content == TextContent(text="Capital of France?")


async def test_call_tool_auto_loop_dispatches_list_roots_then_returns_final_result() -> None:
    """`InputRequiredResult` with an embedded `ListRootsRequest` is routed to
    `list_roots_callback` and the call retried with the returned roots."""
    server = MCPServer("test")

    @server.tool()
    async def count_roots(ctx: Context) -> str | types.InputRequiredResult:
        responses = ctx.input_responses
        if responses and "roots" in responses:
            answer = responses["roots"]
            assert isinstance(answer, types.ListRootsResult)
            return f"Client exposed {len(answer.roots)} root(s)."
        return types.InputRequiredResult(input_requests={"roots": types.ListRootsRequest()})

    callback_called: list[ClientRequestContext] = []

    async def list_roots_callback(context: ClientRequestContext) -> types.ListRootsResult | types.ErrorData:
        callback_called.append(context)
        return types.ListRootsResult(roots=[types.Root(uri=FileUrl("file:///workspace"))])

    with anyio.fail_after(5):
        async with Client(server, list_roots_callback=list_roots_callback) as client:
            result = await client.call_tool("count_roots")

    assert result == snapshot(
        CallToolResult(
            content=[TextContent(text="Client exposed 1 root(s).")],
            structured_content={"result": "Client exposed 1 root(s)."},
        )
    )
    assert len(callback_called) == 1
    assert callback_called[0].request_id == "roots"


async def test_call_tool_auto_loop_round_trips_evolving_request_state_across_three_rounds() -> None:
    """A three-round flow where each `InputRequiredResult.request_state`
    encodes the round number: the driver echoes it back byte-exact, the server
    advances per round, and the elicitation callback runs once per round."""
    server = MCPServer("test")

    @server.tool()
    async def multi(ctx: Context) -> str | types.InputRequiredResult:
        # Round number is the integer the server stashed in `request_state` last leg.
        round_num = int(ctx.request_state) if ctx.request_state else 0
        if round_num == 3:
            return "done after 3 rounds"
        next_round = round_num + 1
        return types.InputRequiredResult(
            input_requests={f"step{next_round}": _name_elicitation(f"Round {next_round}?")},
            request_state=str(next_round),
        )

    messages: list[str] = []

    async def elicitation_callback(
        context: ClientRequestContext, params: types.ElicitRequestParams
    ) -> types.ElicitResult | types.ErrorData:
        assert isinstance(params, types.ElicitRequestFormParams)
        messages.append(params.message)
        return types.ElicitResult(action="accept", content={"name": "x"})

    with anyio.fail_after(5):
        async with Client(server, elicitation_callback=elicitation_callback) as client:
            result = await client.call_tool("multi")

    assert result.content == [TextContent(text="done after 3 rounds")]
    assert messages == ["Round 1?", "Round 2?", "Round 3?"]


async def test_call_tool_auto_loop_raises_mcp_error_when_no_callback_registered() -> None:
    """SDK-defined: with no `elicitation_callback`, the default returns
    `ErrorData(INVALID_REQUEST, ...)` and the driver raises it as `MCPError`
    rather than retrying."""
    server = MCPServer("test")

    @server.tool()
    async def needs_input(ctx: Context) -> str | types.InputRequiredResult:
        if ctx.input_responses:
            raise NotImplementedError  # unreachable: client errors before retrying
        return types.InputRequiredResult(input_requests={"ask": _name_elicitation()})

    async with Client(server) as client:
        with anyio.fail_after(5), pytest.raises(MCPError) as exc:
            await client.call_tool("needs_input")
    assert exc.value.error.code == types.INVALID_REQUEST


async def test_get_prompt_auto_loop_resolves_input_required_via_callbacks() -> None:
    """`Client.get_prompt` runs the same driver as `call_tool`: an
    `InputRequiredResult` from `prompts/get` is fulfilled and retried."""

    async def handler(
        ctx: ServerRequestContext, params: types.GetPromptRequestParams
    ) -> types.GetPromptResult | types.InputRequiredResult:
        assert params.name == "summary"
        if params.input_responses and "ask" in params.input_responses:
            return GetPromptResult(messages=[PromptMessage(role="user", content=TextContent(text="ok"))])
        return types.InputRequiredResult(input_requests={"ask": _name_elicitation()})

    server = Server("test")
    server.add_request_handler("prompts/get", types.GetPromptRequestParams, handler)

    async def elicitation_callback(
        context: ClientRequestContext, params: types.ElicitRequestParams
    ) -> types.ElicitResult | types.ErrorData:
        return types.ElicitResult(action="accept", content={"name": "x"})

    with anyio.fail_after(5):
        async with Client(server, mode="2026-07-28", elicitation_callback=elicitation_callback) as client:
            result = await client.get_prompt("summary")
    assert result == snapshot(GetPromptResult(messages=[PromptMessage(role="user", content=TextContent(text="ok"))]))


async def test_read_resource_auto_loop_resolves_input_required_via_callbacks() -> None:
    """`Client.read_resource` runs the same driver as `call_tool`: an
    `InputRequiredResult` from `resources/read` is fulfilled and retried."""

    async def handler(
        ctx: ServerRequestContext, params: types.ReadResourceRequestParams
    ) -> types.ReadResourceResult | types.InputRequiredResult:
        assert params.uri == "memory://gated"
        if params.input_responses and "ask" in params.input_responses:
            return ReadResourceResult(contents=[TextResourceContents(uri="memory://gated", text="unlocked")])
        return types.InputRequiredResult(input_requests={"ask": _name_elicitation()})

    server = Server("test")
    server.add_request_handler("resources/read", types.ReadResourceRequestParams, handler)

    async def elicitation_callback(
        context: ClientRequestContext, params: types.ElicitRequestParams
    ) -> types.ElicitResult | types.ErrorData:
        return types.ElicitResult(action="accept", content={"name": "x"})

    with anyio.fail_after(5):
        async with Client(server, mode="2026-07-28", elicitation_callback=elicitation_callback) as client:
            result = await client.read_resource("memory://gated")
    assert result == snapshot(
        ReadResourceResult(contents=[TextResourceContents(uri="memory://gated", text="unlocked")])
    )
