Ctrl K
ComunidadGuías…

test/tools/test_mcp_pagination.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

"""Discover a paginated third-party catalog through the real HTTP MCP transport."""

import asyncio
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from typing import Any

import pytest

pytest.importorskip("mcp")

from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from mcp.shared.exceptions import MCPError
from mcp.types import CallToolRequestParams, CallToolResult, ListToolsResult, PaginatedRequestParams, TextContent
from mcp.types import Tool as MCPTool
from starlette.applications import Starlette
from starlette.routing import Mount

from ag2 import Agent, Context
from ag2.events import ToolCallEvent
from ag2.testing import TestConfig
from ag2.tools import MCPServerConfig, MCPToolkit
from ag2.tools.toolkits.mcp_server.types import ProtocolMode
from test._serving import serving


def page(*names: str, next_cursor: str | None = None) -> ListToolsResult:
    return ListToolsResult(
        tools=[MCPTool(name=name, inputSchema={"type": "object"}) for name in names],
        nextCursor=next_cursor,
    )


class Catalog:
    def __init__(self, pages: dict[str | None, ListToolsResult | Exception]) -> None:
        self.pages = pages
        self.cursors: list[str | None] = []
        self.calls: list[str] = []
        self.server = Server("paginated-tools", on_list_tools=self.list_tools, on_call_tool=self.call_tool)

    async def list_tools(
        self, ctx: ServerRequestContext[Any, Any], params: PaginatedRequestParams | None
    ) -> ListToolsResult:
        cursor = params.cursor if params else None
        self.cursors.append(cursor)
        result = self.pages[cursor]
        if isinstance(result, Exception):
            raise result
        return result

    async def call_tool(self, ctx: ServerRequestContext[Any, Any], params: CallToolRequestParams) -> CallToolResult:
        self.calls.append(params.name)
        return CallToolResult(content=[TextContent(type="text", text=f"called {params.name}")])


class EndlessCatalog(Catalog):
    """A server with one more page every time, each behind a cursor never served before."""

    def __init__(self) -> None:
        super().__init__({})

    async def list_tools(
        self, ctx: ServerRequestContext[Any, Any], params: PaginatedRequestParams | None
    ) -> ListToolsResult:
        self.cursors.append(params.cursor if params else None)
        return page(f"tool{len(self.cursors)}", next_cursor=f"page{len(self.cursors)}")


@asynccontextmanager
async def serve_catalog(catalog: Catalog) -> AsyncGenerator[str]:
    manager = StreamableHTTPSessionManager(catalog.server, json_response=True)
    app = Starlette(routes=[Mount("/", app=manager.asgi_app)])
    async with manager.run(), serving(app) as url:
        yield f"{url}/"


@pytest.mark.asyncio
@pytest.mark.parametrize("mode", ["legacy", "auto"])
@pytest.mark.parametrize("empty_first_page", [False, True])
async def test_discovers_all_pages_and_caches_the_complete_catalog(
    context: Context, mode: ProtocolMode, empty_first_page: bool
) -> None:
    names = [] if empty_first_page else ["first"]
    catalog = Catalog({
        None: page(*names, next_cursor="opaque:/page+two="),
        "opaque:/page+two=": page(next_cursor=""),
        "": page("last"),
    })
    async with serve_catalog(catalog) as url:
        toolkit = MCPToolkit(MCPServerConfig(server_url=url, protocol_mode=mode))
        first, concurrent = await asyncio.gather(toolkit.schemas(context), toolkit.schemas(context))
        cached = await toolkit.schemas(context)

    assert [[s.function.name for s in schemas] for schemas in (first, concurrent, cached)] == [names + ["last"]] * 3
    assert catalog.cursors == [None, "opaque:/page+two=", ""]


@pytest.mark.asyncio
@pytest.mark.parametrize("mode", ["legacy", "auto"])
async def test_filters_and_calls_a_tool_from_a_later_page(context: Context, mode: ProtocolMode) -> None:
    catalog = Catalog({
        None: page("unlisted", next_cursor="next"),
        "next": page("blocked", "allowed"),
    })
    async with serve_catalog(catalog) as url:
        toolkit = MCPToolkit(
            MCPServerConfig(
                server_url=url,
                protocol_mode=mode,
                allowed_tools=["blocked", "allowed"],
                blocked_tools=["blocked"],
                tool_name_prefix="remote_",
            )
        )
        schemas = await toolkit.schemas(context)
        agent = Agent(
            "caller",
            tools=[toolkit],
            config=TestConfig(ToolCallEvent(name="remote_allowed", arguments="{}"), "done"),
        )
        reply = await agent.ask("Call the allowed tool")

    assert reply.body == "done"
    assert [schema.function.name for schema in schemas] == ["remote_allowed"]
    assert catalog.calls == ["allowed"]


@pytest.mark.asyncio
@pytest.mark.parametrize("mode", ["legacy", "auto"])
async def test_failed_later_page_leaves_discovery_retryable(context: Context, mode: ProtocolMode) -> None:
    catalog = Catalog({
        None: page("stale", next_cursor="next"),
        "next": MCPError(code=-32603, message="next page unavailable"),
    })
    async with serve_catalog(catalog) as url:
        toolkit = MCPToolkit(MCPServerConfig(server_url=url, protocol_mode=mode))
        with pytest.RaisesGroup(pytest.RaisesExc(MCPError, match="next page unavailable"), flatten_subgroups=True):
            await toolkit.schemas(context)
        assert list(toolkit.tools) == []

        catalog.pages = {None: page("fresh", next_cursor="next"), "next": page("last")}
        schemas = await toolkit.schemas(context)

    assert [schema.function.name for schema in schemas] == ["fresh", "last"]
    assert catalog.cursors == [None, "next", None, "next"]


@pytest.mark.asyncio
@pytest.mark.parametrize("mode", ["legacy", "auto"])
@pytest.mark.parametrize("cycle", [False, True])
async def test_repeated_cursor_does_not_loop(context: Context, mode: ProtocolMode, cycle: bool) -> None:
    catalog = Catalog({
        None: page("first", next_cursor="a"),
        "a": page("second", next_cursor="b" if cycle else "a"),
        "b": page("third", next_cursor="a"),
    })
    async with serve_catalog(catalog) as url:
        toolkit = MCPToolkit(MCPServerConfig(server_url=url, protocol_mode=mode))
        with pytest.raises(RuntimeError, match="repeated pagination cursor"):
            await toolkit.schemas(context)
        assert list(toolkit.tools) == []

    assert catalog.cursors == ([None, "a", "b"] if cycle else [None, "a"])


@pytest.mark.asyncio
async def test_endless_fresh_cursors_stop_at_the_page_cap(context: Context) -> None:
    catalog = EndlessCatalog()
    async with serve_catalog(catalog) as url:
        toolkit = MCPToolkit(MCPServerConfig(server_url=url))
        with pytest.raises(RuntimeError, match="did not end tools/list pagination"):
            await toolkit.schemas(context)
        assert list(toolkit.tools) == []

    # No cursor ever repeats, so only the cap ends the walk.
    assert len(set(catalog.cursors)) == len(catalog.cursors)