Ctrl K
ComunidadGuías…

test/tools/test_mcp_answering.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Copyright (c) 2026, AG2ai, Inc., AG2ai open-source projects maintainers and core contributors
#
# SPDX-License-Identifier: Apache-2.0

"""What an agent gives a third-party MCP server that asks it for something.

A proxied tool call can come back asking for input instead of returning a
result. Everything the agent might supply — its user's attention, its model, its
filesystem layout — is the operator's to give, so each is off until enabled and
advertised only when enabled.

The server here is a real ``mcp`` low-level server driven over the SDK's own
client, not a hand-written double of the session: the capability declaration, the
round-trip driver and the answer routing are all the SDK's, and a test that stubs
them would assert its own stub. Only the *server's* behaviour is scripted, which
is the part a third party owns.
"""

from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any

import pytest

pytest.importorskip("mcp")

from mcp.client import Client
from mcp.client.session import ClientRequestContext
from mcp.server.lowlevel import Server
from mcp.types import (
    CallToolRequestParams,
    CallToolResult,
    ClientCapabilities,
    CreateMessageRequest,
    CreateMessageRequestParams,
    ElicitRequest,
    ElicitRequestFormParams,
    ElicitResult,
    ImageContent,
    InputRequest,
    InputRequiredResult,
    ListRootsRequest,
    ListToolsResult,
    PaginatedRequestParams,
    SamplingMessage,
    TextContent,
)
from mcp.types import Tool as MCPTool
from mcp_types.version import LATEST_MODERN_VERSION

from ag2 import Agent, Context
from ag2.events import HumanInputRequest, HumanMessage, TextInput, ToolCallEvent, ToolErrorEvent, ToolResultEvent
from ag2.exceptions import HumanInputNotProvidedError
from ag2.stream import MemoryStream
from ag2.testing import TestConfig
from ag2.tools import MCPAnswerPolicy, MCPStdioServerConfig, MCPToolkit
from ag2.tools.toolkits.mcp_server import toolkit as _toolkit_module
from ag2.tools.toolkits.mcp_server.answering import InputRequestAnswerer
from ag2.utils import MODEL_CONFIG_CONTEXT_DEPENDENCY_KEY

TOOL = "needs_input"


def elicitation(message: str, *, field: str = "answer") -> ElicitRequest:
    """A one-property form, which is the shape a free-text answer fits."""
    return ElicitRequest(
        params=ElicitRequestFormParams(
            message=message,
            requested_schema={"type": "object", "properties": {field: {"type": "string"}}, "required": [field]},
        )
    )


def sampling(prompt: str) -> CreateMessageRequest:
    return CreateMessageRequest(
        params=CreateMessageRequestParams(
            messages=[SamplingMessage(role="user", content=TextContent(type="text", text=prompt))],
            max_tokens=64,
        )
    )


def sampling_an_image(data: str) -> CreateMessageRequest:
    """A completion over an image block, whose ``data`` the server chooses."""
    return CreateMessageRequest(
        params=CreateMessageRequestParams(
            messages=[
                SamplingMessage(role="user", content=ImageContent(type="image", data=data, mimeType="image/png"))
            ],
            max_tokens=64,
        )
    )


class ThirdPartyServer:
    """A server that answers ``needs_input`` with input requests, then a result.

    Each entry in ``rounds`` is one ``InputRequiredResult`` worth of requests;
    once they are exhausted the call returns a result naming what came back, so a
    test can read the answers *the server actually received* rather than the ones
    the client believes it sent.
    """

    __slots__ = ("_rounds", "received", "capabilities", "server")

    def __init__(self, *rounds: dict[str, InputRequest]) -> None:
        self._rounds = list(rounds)
        self.received: list[dict[str, Any]] = []
        self.capabilities: ClientCapabilities | None = None
        self.server = Server(
            name="third-party",
            version="1.0.0",
            on_list_tools=self._on_list_tools,
            on_call_tool=self._on_call_tool,
        )

    async def _on_list_tools(self, ctx: Any, params: PaginatedRequestParams | None) -> ListToolsResult:
        self.capabilities = ctx.session.client_capabilities
        return ListToolsResult(
            tools=[MCPTool(name=TOOL, description="Asks for things", inputSchema={"type": "object"})]
        )

    async def _on_call_tool(self, ctx: Any, params: CallToolRequestParams) -> Any:
        self.capabilities = ctx.session.client_capabilities
        if params.input_responses:
            self.received.append({
                key: response.model_dump(mode="json", exclude_none=True)
                for key, response in params.input_responses.items()
            })
        if self._rounds:
            return InputRequiredResult(input_requests=self._rounds.pop(0), request_state=f"state-{len(self._rounds)}")
        return CallToolResult(content=[TextContent(type="text", text=f"served after {len(self.received)} answers")])


class Human:
    """The calling agent's own human-input channel."""

    __slots__ = ("_answer", "asked")

    def __init__(self, answer: str = "blue") -> None:
        self._answer = answer
        self.asked: list[str] = []

    async def __call__(self, event: HumanInputRequest, context: Context) -> None:
        self.asked.append(event.content)
        await context.send(HumanMessage.ensure_message(self._answer, parent_id=event.id))
        return None


@pytest.fixture
def calling_agent(monkeypatch: pytest.MonkeyPatch) -> "CallingAgent":
    return CallingAgent(monkeypatch)


class CallingAgent:
    """The AG2 side: a toolkit pointed at ``server``, called the way an agent calls it."""

    __slots__ = ("_monkeypatch",)

    def __init__(self, monkeypatch: pytest.MonkeyPatch) -> None:
        self._monkeypatch = monkeypatch

    def install(self, server: ThirdPartyServer) -> None:
        """Point the toolkit's session factory at ``server``, over the modern era.

        Only the transport is substituted: a real ``ClientSession`` still speaks
        to a real low-level server, so what the client advertises and how it
        routes an input request are the SDK's own behaviour.
        """

        @asynccontextmanager
        async def connected(_config: Any, **session_kwargs: Any) -> AsyncGenerator[Any]:
            async with Client(
                server.server,
                mode=LATEST_MODERN_VERSION,
                raise_exceptions=True,
                **session_kwargs,
            ) as client:
                yield client.session

        self._monkeypatch.setattr(_toolkit_module, "_mcp_session", connected)

    async def call(
        self,
        server: ThirdPartyServer,
        *,
        answering: MCPAnswerPolicy,
        human: Human | None = None,
        config: TestConfig | None = None,
    ) -> ToolResultEvent | ToolErrorEvent:
        self.install(server)
        dependencies = {MODEL_CONFIG_CONTEXT_DEPENDENCY_KEY: config} if config is not None else {}
        context = Context(stream=MemoryStream(), dependencies=dependencies)
        toolkit = MCPToolkit(MCPStdioServerConfig(command="unused"), answering=answering)
        await toolkit.schemas(context)
        proxy = next(t for t in toolkit.tools if t.name == TOOL)
        call = ToolCallEvent(name=TOOL, arguments="{}")
        if human is None:
            return await proxy(call, context)
        with context.stream.where(HumanInputRequest).sub_scope(human, interrupt=True):
            return await proxy(call, context)

    async def ask_as_an_agent(self, server: ThirdPartyServer, *, answering: MCPAnswerPolicy) -> str:
        """Drive the same call through a real ``Agent.ask``.

        The agent is what installs the default human-input hook, so this is the
        only setup in which "nobody could be asked" is the real failure rather
        than a context a test forgot to furnish.
        """
        self.install(server)
        agent = Agent(
            "caller",
            tools=[MCPToolkit(MCPStdioServerConfig(command="unused"), answering=answering)],
            config=TestConfig(ToolCallEvent(name=TOOL, arguments="{}"), "done"),
        )
        reply = await agent.ask("go")
        return reply.body


def text_of(result: ToolResultEvent) -> str:
    return "".join(p.content for p in result.result.parts if isinstance(p, TextInput))


@pytest.mark.asyncio
class TestAnsweringAQuestion:
    async def test_the_agents_human_answers_and_the_call_completes(self, calling_agent: CallingAgent) -> None:
        server = ThirdPartyServer({"q": elicitation("What colour?")})
        human = Human("blue")

        result = await calling_agent.call(server, answering=MCPAnswerPolicy(elicitation="ask"), human=human)

        assert human.asked == ["What colour?"], "the server's question never reached the agent's human"
        assert server.received == [{"q": {"action": "accept", "content": {"answer": "blue"}}}]
        assert isinstance(result, ToolResultEvent), f"the call did not complete: {result}"
        assert text_of(result) == "served after 1 answers"

    async def test_a_form_of_more_than_one_field_is_declined_rather_than_invented(
        self, calling_agent: CallingAgent
    ) -> None:
        """Splitting one free-text answer across fields would be fabricating data."""
        server = ThirdPartyServer({
            "q": ElicitRequest(
                params=ElicitRequestFormParams(
                    message="Name and address?",
                    requested_schema={
                        "type": "object",
                        "properties": {"name": {"type": "string"}, "address": {"type": "string"}},
                    },
                )
            )
        })
        human = Human()

        result = await calling_agent.call(server, answering=MCPAnswerPolicy(elicitation="ask"), human=human)

        assert human.asked == [], "an unanswerable form still reached the human"
        assert server.received == [{"q": {"action": "decline"}}]
        assert isinstance(result, ToolResultEvent), "a decline is an answer; the call should still complete"

    async def test_a_question_answered_on_the_form_s_own_field(self, calling_agent: CallingAgent) -> None:
        """A third-party form names its property whatever it likes."""
        server = ThirdPartyServer({"q": elicitation("City?", field="city")})

        result = await calling_agent.call(server, answering=MCPAnswerPolicy(elicitation="ask"), human=Human("Kraków"))

        assert server.received == [{"q": {"action": "accept", "content": {"city": "Kraków"}}}]
        assert isinstance(result, ToolResultEvent)

    async def test_with_no_human_hook_the_existing_failure_surfaces_rather_than_a_decline(
        self, calling_agent: CallingAgent
    ) -> None:
        """An absent channel is not a refusal, and must not be reported as one."""
        server = ThirdPartyServer({"q": elicitation("What colour?")})

        with pytest.raises(HumanInputNotProvidedError):
            await calling_agent.ask_as_an_agent(server, answering=MCPAnswerPolicy(elicitation="ask"))

        assert server.received == [], "the server was told 'declined' by an operator who never declined"

    async def test_a_question_asked_anyway_is_declined(self) -> None:
        """A conforming server never asks — this is what a non-conforming one gets.

        Driven against the answerer directly: the whole point of the policy is
        that the callback is not wired at all, so there is no route through a
        conforming session to reach this.
        """
        answerer = InputRequestAnswerer(MCPAnswerPolicy(elicitation="decline"), Context(stream=MemoryStream()))

        answer = await answerer.on_elicitation(
            ClientRequestContext(session=None, request_id="q", meta=None),  # type: ignore[arg-type]
            ElicitRequestFormParams(
                message="What colour?",
                requested_schema={"type": "object", "properties": {"answer": {"type": "string"}}},
            ),
        )

        assert isinstance(answer, ElicitResult)
        assert answer.action == "decline"


@pytest.mark.asyncio
class TestWhatIsAdvertised:
    async def test_nothing_is_advertised_by_default(self, calling_agent: CallingAgent) -> None:
        """The default policy hands over nothing, so a conforming server asks for nothing."""
        server = ThirdPartyServer()

        await calling_agent.call(server, answering=MCPAnswerPolicy())

        assert server.capabilities is not None
        assert server.capabilities.elicitation is None
        assert server.capabilities.sampling is None
        assert server.capabilities.roots is None

    async def test_only_the_enabled_capability_is_advertised(self, calling_agent: CallingAgent) -> None:
        server = ThirdPartyServer()

        await calling_agent.call(server, answering=MCPAnswerPolicy(elicitation="ask"), human=Human())

        assert server.capabilities is not None
        assert server.capabilities.elicitation is not None
        assert server.capabilities.sampling is None
        assert server.capabilities.roots is None


@pytest.mark.asyncio
class TestLendingTheAgentsModel:
    async def test_a_sampling_request_runs_on_the_agents_own_model(self, calling_agent: CallingAgent) -> None:
        server = ThirdPartyServer({"s": sampling("Summarise this.")})

        result = await calling_agent.call(
            server,
            answering=MCPAnswerPolicy(sampling=True),
            config=TestConfig("a summary"),
        )

        assert isinstance(result, ToolResultEvent), f"the call did not complete: {result}"
        [round_one] = server.received
        assert round_one["s"]["content"] == {"type": "text", "text": "a summary"}
        assert round_one["s"]["role"] == "assistant"

    async def test_sampling_is_advertised_only_when_enabled(self, calling_agent: CallingAgent) -> None:
        server = ThirdPartyServer()

        await calling_agent.call(server, answering=MCPAnswerPolicy(sampling=True), config=TestConfig("unused"))

        assert server.capabilities is not None
        assert server.capabilities.sampling is not None
        assert server.capabilities.elicitation is None

    async def test_an_un_enabled_sampling_request_is_refused_rather_than_served(
        self, calling_agent: CallingAgent
    ) -> None:
        """Sampling has no ``decline`` arm, so the refusal fails the call rather than reaching the server."""
        server = ThirdPartyServer({"s": sampling("Summarise this.")})

        result = await calling_agent.call(server, answering=MCPAnswerPolicy(), config=TestConfig("never used"))

        assert isinstance(result, ToolErrorEvent)
        assert server.received == []

    async def test_sampling_without_a_model_is_refused_rather_than_crashing(self, calling_agent: CallingAgent) -> None:
        server = ThirdPartyServer({"s": sampling("Summarise this.")})

        result = await calling_agent.call(server, answering=MCPAnswerPolicy(sampling=True), config=None)

        assert isinstance(result, ToolErrorEvent)

    async def test_a_media_block_that_is_not_base64_is_reported_rather_than_raised(
        self, calling_agent: CallingAgent
    ) -> None:
        """The payload is the *server's*, so malformed base64 is a thing one can send.

        Decoded unguarded it surfaces as a ``binascii.Error`` out of the sampling
        callback, naming neither the sender nor the field.
        """
        server = ThirdPartyServer({"s": sampling_an_image("not base64 at all!!")})

        result = await calling_agent.call(
            server, answering=MCPAnswerPolicy(sampling=True), config=TestConfig("never reached")
        )

        assert isinstance(result, ToolErrorEvent)
        assert "not base64" in str(result.error), result.error

    async def test_a_well_formed_media_block_reaches_the_model(self, calling_agent: CallingAgent) -> None:
        server = ThirdPartyServer({"s": sampling_an_image("aGVsbG8=")})

        result = await calling_agent.call(
            server, answering=MCPAnswerPolicy(sampling=True), config=TestConfig("a description")
        )

        assert isinstance(result, ToolResultEvent), f"the call did not complete: {result}"
        [round_one] = server.received
        assert round_one["s"]["content"] == {"type": "text", "text": "a description"}


@pytest.mark.asyncio
class TestReportingRoots:
    async def test_configured_roots_are_reported(self, calling_agent: CallingAgent, tmp_path: Path) -> None:
        server = ThirdPartyServer({"r": ListRootsRequest()})

        result = await calling_agent.call(server, answering=MCPAnswerPolicy(roots=[str(tmp_path)]))

        assert isinstance(result, ToolResultEvent), f"the call did not complete: {result}"
        [round_one] = server.received
        assert round_one["r"]["roots"] == [{"uri": tmp_path.as_uri(), "name": tmp_path.name}]

    async def test_roots_are_advertised_only_when_configured(self, calling_agent: CallingAgent, tmp_path: Path) -> None:
        server = ThirdPartyServer()

        await calling_agent.call(server, answering=MCPAnswerPolicy(roots=[str(tmp_path)]))

        assert server.capabilities is not None
        assert server.capabilities.roots is not None

    async def test_with_no_roots_configured_the_capability_is_absent_and_a_request_refused(
        self, calling_agent: CallingAgent
    ) -> None:
        """No roots is not an empty list of roots — the agent has nothing to report."""
        server = ThirdPartyServer({"r": ListRootsRequest()})

        result = await calling_agent.call(server, answering=MCPAnswerPolicy())

        assert server.capabilities is not None
        assert server.capabilities.roots is None
        assert isinstance(result, ToolErrorEvent)


@pytest.mark.asyncio
class TestTheRoundBound:
    async def test_a_server_that_re_asks_forever_is_stopped(self, calling_agent: CallingAgent) -> None:
        server = ThirdPartyServer(*[{"q": elicitation("Again?")} for _ in range(5)])

        result = await calling_agent.call(
            server,
            answering=MCPAnswerPolicy(elicitation="ask", max_rounds=2),
            human=Human(),
        )

        assert isinstance(result, ToolErrorEvent)
        assert "more than 2 rounds" in str(result.error)
        assert len(server.received) == 2, "the bound did not stop the loop where it said it would"

    async def test_the_bound_covers_sampling_and_roots_too(self, calling_agent: CallingAgent, tmp_path: Path) -> None:
        """One bound for the call, not one per request type."""
        server = ThirdPartyServer(*[{"s": sampling("again"), "r": ListRootsRequest()} for _ in range(5)])

        result = await calling_agent.call(
            server,
            answering=MCPAnswerPolicy(sampling=True, roots=[str(tmp_path)], max_rounds=2),
            config=TestConfig(*["a summary"] * 5),
        )

        assert isinstance(result, ToolErrorEvent)
        assert "more than 2 rounds" in str(result.error)