Ctrl K
ComunidadGuías…

test/acp/test_tool_gateway.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

import asyncio
import base64
import signal
import socket
from contextlib import asynccontextmanager, suppress
from typing import Any

import httpx
import pytest
from acp import schema
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client
from mcp.server.streamable_http import CONTENT_TYPE_JSON, CONTENT_TYPE_SSE, MCP_SESSION_ID_HEADER
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
from mcp.types import CallToolResult
from mcp_types.version import LATEST_HANDSHAKE_VERSION

from ag2.acp.bridge import BridgeState
from ag2.acp.config import ACPConfig
from ag2.acp.tool_gateway import (
    HUMAN_INPUT_GATEWAY_TOOL_ERROR,
    GatewayAddress,
    MCPCapabilityError,
    ToolGateway,
    partition_tools,
)
from ag2.events import BinaryInput, ClientToolCallEvent, ToolErrorEvent, ToolResultEvent
from ag2.events.tool_events import ToolResult
from ag2.exceptions import HumanInputFailedError, HumanInputNotProvidedError, UnsupportedToolError
from ag2.tools.builtin.mcp_server import MCPServerToolSchema
from ag2.tools.builtin.web_search import WebSearchToolSchema
from ag2.tools.final import FunctionToolSchema
from ag2.tools.final.function_tool import FunctionDefinition


def _fn(name: str) -> FunctionToolSchema:
    return FunctionToolSchema(
        function=FunctionDefinition(name=name, description="d", parameters={"type": "object", "properties": {}})
    )


def test_partition_keeps_function_tools() -> None:
    functions, external = partition_tools([_fn("a"), _fn("b")])
    assert [f.function.name for f in functions] == ["a", "b"]
    assert external == []


def test_partition_translates_mcp_server_tool() -> None:
    tool = MCPServerToolSchema(
        server_url="https://mcp.example.com/mcp",
        server_label="ext",
        authorization_token="tok123",
        headers={"X-Env": "prod"},
    )
    functions, external = partition_tools([tool])
    assert functions == []
    (server,) = external
    assert isinstance(server, schema.HttpMcpServer)
    assert server.name == "ext"
    assert server.url == "https://mcp.example.com/mcp"
    header_map = {h.name: h.value for h in server.headers}
    assert header_map["X-Env"] == "prod"
    assert header_map["Authorization"] == "Bearer tok123"


def test_partition_sends_one_authorization_header() -> None:
    tool = MCPServerToolSchema(
        server_url="https://mcp.example.com/mcp",
        server_label="ext",
        authorization_token="fallback",
        headers={"authorization": "Bearer explicit"},
    )
    _, (server,) = partition_tools([tool])
    assert [(h.name, h.value) for h in server.headers] == [("authorization", "Bearer explicit")]


@pytest.mark.parametrize(
    "filters",
    [
        {"allowed_tools": ["a"]},
        {"blocked_tools": ["b"]},
        {"allowed_tools": []},  # empty list is a filter ("allow nothing"), not an absent one
    ],
)
def test_partition_rejects_mcp_server_tool_filters(filters: dict[str, Any]) -> None:
    tool = MCPServerToolSchema(server_url="https://x/mcp", server_label="ext", **filters)
    with pytest.raises(ValueError, match="allowed_tools"):
        partition_tools([tool])


def test_partition_rejects_provider_builtin() -> None:
    with pytest.raises(UnsupportedToolError, match="web_search"):
        partition_tools([_fn("a"), WebSearchToolSchema()])


def test_capability_error_message_names_agent() -> None:
    err = MCPCapabilityError("codex-acp")
    assert "codex-acp" in str(err)
    assert "expose_tools" in str(err)


_MCP_HEADERS = {"Accept": f"{CONTENT_TYPE_JSON}, {CONTENT_TYPE_SSE}", "Content-Type": CONTENT_TYPE_JSON}
_INITIALIZE = {
    "jsonrpc": "2.0",
    "id": 1,
    "method": "initialize",
    "params": {
        "protocolVersion": LATEST_HANDSHAKE_VERSION,
        "capabilities": {},
        "clientInfo": {"name": "t", "version": "1"},
    },
}
_CALL_ADD = {
    "jsonrpc": "2.0",
    "id": 2,
    "method": "tools/call",
    "params": {"name": "add", "arguments": {"a": 2, "b": 3}},
}


def _fn_add() -> FunctionToolSchema:
    return FunctionToolSchema(
        function=FunctionDefinition(
            name="add",
            description="Add two integers",
            parameters={
                "type": "object",
                "properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
                "required": ["a", "b"],
            },
        )
    )


@pytest.mark.asyncio
async def test_gateway_serves_tools_list_over_http() -> None:
    state = BridgeState(ACPConfig())
    gateway = ToolGateway(state, [_fn_add()])
    url = await gateway.start()
    try:
        assert url.startswith("http://127.0.0.1:") and "/mcp/" in url
        assert gateway.as_acp_server().url == url
        async with streamable_http_client(url) as (read, write), ClientSession(read, write) as session:
            await session.initialize()
            listed = await session.list_tools()
        (tool,) = listed.tools
        assert tool.name == "add"
        assert tool.description == "Add two integers"
        assert tool.input_schema["required"] == ["a", "b"]
    finally:
        await gateway.close()


@pytest.mark.asyncio
async def test_gateway_issues_no_session_and_still_serves_a_call() -> None:
    """The gateway is per-turn and per-request, so it hands out no MCP session.

    A session would be pure overhead on a server created for one turn: nothing
    outlives a request, so there is no cross-call state for a session id to name.
    The tool call in the same test keeps the header assertion honest — a server
    that answered nothing would issue no session id either.
    """
    state = BridgeState(ACPConfig())
    state.context = _FakeContext(lambda call: ToolResultEvent.from_call(call, "sum is 5"))
    gateway = ToolGateway(state, [_fn_add()])
    url = await gateway.start()
    try:
        async with httpx.AsyncClient() as client:
            initialize = await client.post(f"{url}/", headers=_MCP_HEADERS, json=_INITIALIZE)
            called = await client.post(
                f"{url}/",
                headers={**_MCP_HEADERS, MCP_PROTOCOL_VERSION_HEADER: LATEST_HANDSHAKE_VERSION},
                json=_CALL_ADD,
            )
    finally:
        await gateway.close()

    assert initialize.status_code == 200
    assert MCP_SESSION_ID_HEADER not in initialize.headers
    assert called.status_code == 200
    assert MCP_SESSION_ID_HEADER not in called.headers
    assert called.json()["result"]["content"] == [{"type": "text", "text": "sum is 5"}]


@pytest.mark.asyncio
class TestGatewayAddress:
    """The gateway's binding, and what it takes to widen it.

    The loopback default, the DNS-rebinding protection and the host allowlist
    are one deliberate arrangement; a caller who advertises an address for a
    remote agent loosens all three at once, so what exactly moves — and what
    does not — is worth pinning down.
    """

    async def test_default_binds_loopback_only(self) -> None:
        gateway = ToolGateway(BridgeState(ACPConfig()), [_fn_add()])
        url = await gateway.start()
        try:
            assert gateway.address == GatewayAddress()
            assert url.startswith("http://127.0.0.1:")
        finally:
            await gateway.close()

    async def test_default_rejects_a_forged_host_header(self) -> None:
        gateway = ToolGateway(BridgeState(ACPConfig()), [_fn_add()])
        url = await gateway.start()
        try:
            async with httpx.AsyncClient() as client:
                # Trailing slash: Starlette would otherwise answer the Mount's
                # redirect before the transport security check ever runs.
                response = await client.post(f"{url}/", json={}, headers={"Host": "attacker.example"})
        finally:
            await gateway.close()
        assert response.status_code >= 400  # DNS-rebinding protection is on

    async def test_advertised_address_binds_there_and_serves_it(self, routable_host: str) -> None:
        host = routable_host
        gateway = ToolGateway(BridgeState(ACPConfig()), [_fn_add()], address=GatewayAddress(host=host))
        url = await gateway.start()
        try:
            assert url.startswith(f"http://{host}:")
            async with streamable_http_client(url) as (read, write), ClientSession(read, write) as session:
                await session.initialize()
                listed = await session.list_tools()
            assert [t.name for t in listed.tools] == ["add"]
        finally:
            await gateway.close()

    async def test_advertised_address_still_rejects_a_forged_host_header(self, routable_host: str) -> None:
        """Widened to match the advertised address, and no further."""
        host = routable_host
        gateway = ToolGateway(BridgeState(ACPConfig()), [_fn_add()], address=GatewayAddress(host=host))
        url = await gateway.start()
        try:
            async with httpx.AsyncClient() as client:
                # Trailing slash: Starlette would otherwise answer the Mount's
                # redirect before the transport security check ever runs.
                response = await client.post(f"{url}/", json={}, headers={"Host": "attacker.example"})
        finally:
            await gateway.close()
        assert response.status_code >= 400

    async def test_ipv6_loopback_serves_the_url_it_hands_out(self) -> None:
        gateway = ToolGateway(BridgeState(ACPConfig()), [_fn_add()], address=GatewayAddress(host="::1"))
        try:
            url = await gateway.start()
        except OSError:  # pragma: no cover - a host without IPv6
            pytest.skip("no IPv6 loopback on this host")
        try:
            assert url.startswith("http://[::1]:")
            async with streamable_http_client(url) as (read, write), ClientSession(read, write) as session:
                await session.initialize()
                listed = await session.list_tools()
            assert [t.name for t in listed.tools] == ["add"]
        finally:
            await gateway.close()

    async def test_advertised_port_is_the_port_it_binds(self, routable_host: str) -> None:
        host = routable_host
        with socket.socket() as probe:
            probe.bind((host, 0))
            port = probe.getsockname()[1]

        gateway = ToolGateway(BridgeState(ACPConfig()), [_fn_add()], address=GatewayAddress(host=host, port=port))
        url = await gateway.start()
        try:
            assert url.startswith(f"http://{host}:{port}/")
        finally:
            await gateway.close()


@pytest.mark.asyncio
async def test_gateway_close_is_idempotent_and_frees_port() -> None:
    state = BridgeState(ACPConfig())
    gateway = ToolGateway(state, [_fn_add()])
    url = await gateway.start()
    port = int(url.removeprefix("http://127.0.0.1:").split("/")[0])
    await gateway.close()
    await gateway.close()  # idempotent
    assert gateway.url is None
    # the port no longer accepts connections at all
    with pytest.raises(ConnectionRefusedError), socket.socket() as probe:
        probe.connect(("127.0.0.1", port))


class _FakeStream:
    def __init__(self) -> None:
        self.pending: asyncio.Future | None = None

    def get(self, _expr):
        stream = self

        @asynccontextmanager
        async def cm():
            fut: asyncio.Future = asyncio.get_running_loop().create_future()
            stream.pending = fut
            try:
                yield fut
            finally:
                stream.pending = None

        return cm()


class _FakeContext:
    """Stands in for ConversationContext: send() answers the pending stream.get().

    ``respond=None`` models a tool call that never completes (no subscriber
    answers the event) — used to exercise bounded shutdown.
    """

    def __init__(self, respond=None) -> None:
        self.stream = _FakeStream()
        self.sent: list = []
        self.first_send = asyncio.Event()
        self._respond = respond

    async def send(self, event) -> None:
        self.sent.append(event)
        self.first_send.set()
        # A turn that failed sends once more on its way out, to close the call
        # off in history; by then nothing is waiting on the stream any more.
        if self.stream.pending is None or self.stream.pending.done():
            return
        if self._respond is not None:
            self.stream.pending.set_result(self._respond(event))


@pytest.mark.asyncio
class TestCallTool:
    """``tools/call`` end to end: a real MCP HTTP client against the gateway."""

    async def _call(self, state: BridgeState, arguments: dict[str, int]) -> CallToolResult:
        gateway = ToolGateway(state, [_fn_add()])
        url = await gateway.start()
        try:
            async with streamable_http_client(url) as (read, write), ClientSession(read, write) as session:
                await session.initialize()
                return await session.call_tool("add", arguments)
        finally:
            await gateway.close()

    async def test_executes_via_event_stream(self) -> None:
        state = BridgeState(ACPConfig())
        state.context = _FakeContext(lambda call: ToolResultEvent.from_call(call, "sum is 5"))

        result = await self._call(state, {"a": 2, "b": 3})

        assert result.is_error is not True
        assert result.content[0].text == "sum is 5"
        (call,) = state.context.sent
        assert call.name == "add"
        assert call.serialized_arguments == {"a": 2, "b": 3}

    async def test_maps_tool_error_to_is_error(self) -> None:
        state = BridgeState(ACPConfig())
        state.context = _FakeContext(lambda call: ToolErrorEvent.from_call(call, RuntimeError("boom")))

        result = await self._call(state, {"a": 1, "b": 1})

        assert result.is_error is True
        assert "boom" in result.content[0].text

    async def test_without_active_run_is_error(self) -> None:
        state = BridgeState(ACPConfig())  # state.context is None

        result = await self._call(state, {"a": 1, "b": 1})

        assert result.is_error is True  # the lowlevel server converts the raised RuntimeError
        assert "no active AG2 run" in result.content[0].text

    async def test_rejects_client_tool(self) -> None:
        state = BridgeState(ACPConfig())
        state.context = _FakeContext(lambda call: ClientToolCallEvent.from_call(call))

        result = await self._call(state, {"a": 1, "b": 1})

        assert result.is_error is True
        assert "client-side execution" in result.content[0].text

    async def test_serializes_data_result_as_json(self) -> None:
        state = BridgeState(ACPConfig())
        state.context = _FakeContext(lambda call: ToolResultEvent.from_call(call, {"sum": 5}))

        result = await self._call(state, {"a": 2, "b": 3})

        assert result.is_error is not True
        assert result.content[0].text == '{"sum": 5}'

    async def test_maps_image_result_to_image_content(self) -> None:
        png = b"\x89PNG\r\n\x1a\nfake"
        state = BridgeState(ACPConfig())
        state.context = _FakeContext(
            lambda call: ToolResultEvent.from_call(call, ToolResult(BinaryInput(png, media_type="image/png")))
        )

        result = await self._call(state, {"a": 1, "b": 1})

        assert result.is_error is not True
        (block,) = result.content
        assert block.type == "image"
        assert block.mime_type == "image/png"
        assert base64.b64decode(block.data) == png


@pytest.mark.asyncio
async def test_start_and_close_leave_signal_handlers_untouched() -> None:
    before = {sig: signal.getsignal(sig) for sig in (signal.SIGINT, signal.SIGTERM)}
    state = BridgeState(ACPConfig())
    gateway = ToolGateway(state, [_fn_add()])
    await gateway.start()
    try:
        # uvicorn's stock serve() would have swapped these for its own handler
        assert {sig: signal.getsignal(sig) for sig in before} == before
    finally:
        await gateway.close()
    assert {sig: signal.getsignal(sig) for sig in before} == before


@pytest.mark.asyncio
async def test_request_with_foreign_host_header_is_rejected() -> None:
    state = BridgeState(ACPConfig())
    gateway = ToolGateway(state, [_fn_add()])
    url = await gateway.start()
    try:
        async with httpx.AsyncClient() as client:
            # url + "/" skips Starlette's /mcp -> /mcp/ redirect, which fires
            # before the transport security check.
            response = await client.post(
                f"{url}/",
                headers={
                    "Host": "evil.example.com:9999",
                    "Content-Type": "application/json",
                    "Accept": "application/json, text/event-stream",
                },
                json={"jsonrpc": "2.0", "id": 1, "method": "ping"},
            )
    finally:
        await gateway.close()
    assert response.status_code == 421  # DNS-rebinding protection: Host not in the allowlist


@pytest.mark.asyncio
async def test_guessable_path_does_not_reach_the_tools() -> None:
    """Knowing the port is not enough: the random path segment is the credential."""
    state = BridgeState(ACPConfig())
    gateway = ToolGateway(state, [_fn_add()])
    url = await gateway.start()
    try:
        port = url.removeprefix("http://127.0.0.1:").split("/")[0]
        async with httpx.AsyncClient() as client:
            response = await client.post(
                f"http://127.0.0.1:{port}/mcp/",  # what a port scan would try
                headers={"Content-Type": "application/json", "Accept": "application/json, text/event-stream"},
                json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"},
            )
    finally:
        await gateway.close()
    assert response.status_code == 404


@pytest.mark.asyncio
async def test_each_gateway_gets_a_distinct_path() -> None:
    state = BridgeState(ACPConfig())
    first, second = ToolGateway(state, [_fn_add()]), ToolGateway(state, [_fn_add()])
    first_url = await first.start()
    try:
        second_url = await second.start()
        try:
            assert first_url != second_url
            # not merely a different port -- the secret differs too
            assert first_url.split("/mcp/")[1] != second_url.split("/mcp/")[1]
        finally:
            await second.close()
    finally:
        await first.close()


@pytest.mark.asyncio
async def test_close_is_bounded_with_a_stuck_call_in_flight() -> None:
    state = BridgeState(ACPConfig())
    state.context = _FakeContext(respond=None)  # the tool call never completes
    gateway = ToolGateway(state, [_fn_add()], close_timeout=0.5)
    url = await gateway.start()

    async def stuck_call() -> None:
        async with streamable_http_client(url) as (read, write), ClientSession(read, write) as session:
            await session.initialize()
            await session.call_tool("add", {"a": 1, "b": 1})

    task = asyncio.ensure_future(stuck_call())
    # wait until the call is in flight inside the gateway
    await asyncio.wait_for(state.context.first_send.wait(), timeout=5)

    # Without bounded shutdown this would wait forever on the in-flight request.
    await asyncio.wait_for(gateway.close(), timeout=10)

    task.cancel()
    with suppress(BaseException):
        await task


def _raise(error: BaseException):
    """A ``_FakeContext`` responder that fails instead of answering.

    ``send`` calls the responder, so raising here is what a tool raising out of
    the stream looks like from the gateway's side.
    """

    def respond(_call):
        raise error

    return respond


@pytest.mark.asyncio
class TestAHumanInputFailureIsNotToolOutput:
    """A tool that asked a person and got nowhere has not produced a tool result.

    Caught as an ordinary exception here it becomes the CLI agent's tool output,
    and the agent — told only that a tool broke — is free to look for another way
    to do what an approval refused it. The gateway cannot raise into the task
    awaiting ``session/prompt``, so it records the failure on the run's state
    instead; the client raises it from there.
    """

    async def _call(self, state: BridgeState) -> CallToolResult:
        gateway = ToolGateway(state, [_fn_add()])
        url = await gateway.start()
        try:
            async with streamable_http_client(url) as (read, write), ClientSession(read, write) as session:
                await session.initialize()
                return await session.call_tool("add", {"a": 1, "b": 1})
        finally:
            await gateway.close()

    async def test_the_failure_is_recorded_and_the_turn_is_stopped(self) -> None:
        state = BridgeState(ACPConfig())
        error = HumanInputNotProvidedError()
        state.context = _FakeContext(_raise(error))

        result = await self._call(state)

        assert state.channel_failure is error
        assert state.channel_failed.is_set()
        assert result.is_error is True

    async def test_ag2s_own_advice_does_not_become_the_agents_tool_output(self) -> None:
        """The request still has to be answered, but not with the host's internals.

        ``HumanInputNotProvidedError`` reads "pass hitl_hook=..." — advice for
        whoever wired this agent up, landing in someone else's conversation as
        though the tool had said it.
        """
        state = BridgeState(ACPConfig())
        state.context = _FakeContext(_raise(HumanInputNotProvidedError()))

        result = await self._call(state)

        text = result.content[0].text
        assert text == HUMAN_INPUT_GATEWAY_TOOL_ERROR
        assert "hitl_hook" not in text

    async def test_the_first_failure_is_the_one_reported(self) -> None:
        """Later calls in the same turn are being cancelled, not diagnosing anything."""
        state = BridgeState(ACPConfig())
        first = HumanInputNotProvidedError()
        state.context = _FakeContext(_raise(first))
        await self._call(state)

        state.context = _FakeContext(_raise(HumanInputFailedError(RuntimeError("queue down"))))
        await self._call(state)

        assert state.channel_failure is first

    async def test_an_ordinary_tool_failure_still_reads_as_one(self) -> None:
        state = BridgeState(ACPConfig())
        state.context = _FakeContext(_raise(RuntimeError("boom")))

        result = await self._call(state)

        assert result.is_error is True
        assert "boom" in result.content[0].text
        assert state.channel_failure is None
        assert not state.channel_failed.is_set()