File size: 3,788 Bytes
060118a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
"""The MCP tool surface.

mcp_server.py had no tests, and it degrades silently. `_tool_names` walks
the route tree through three version-dependent shapes - DefaultPlaceholder
unwrapping, nested `.routes`, and FastAPI >= 0.141's `original_router` -
each a reasonable guess about a library's internals. If any stops
resolving, tools quietly revert to names like
`search_arxiv_serp_search_arxiv_post`, tool selection degrades, and
nothing goes red.

These pin the surface an agent actually sees, so a dependency bump reports
the breakage instead of shipping it.
"""

import pytest

import app as app_module
from mcp_server import EXCLUDED_ROUTES, _tool_names

# The curated set. /ops/* and the single-backend search endpoints are
# deliberately excluded (see EXCLUDED_ROUTES): they stay available over
# REST, but exposing them as tools invites a model to pick a path that
# skips the fallback chains.
EXPECTED_TOOLS = {
    "search",
    "search_arxiv",
    "search_google_scholar",
    "search_patents",
    "scrap_patent",
    "scrap_patents",
}


@pytest.fixture
async def tools():
    return {t.name: t for t in await app_module.mcp.list_tools()}


async def test_the_exposed_tool_set_is_the_curated_one(tools):
    assert set(tools) == EXPECTED_TOOLS


async def test_tool_names_are_handler_names_not_generated_operation_ids(tools):
    """FastAPI's generated operationIds (`search_arxiv_serp_search_arxiv_post`)
    are what an LLM would otherwise see. _tool_names maps them back to the
    Python handler name.
    """
    for name in tools:
        assert "_post" not in name and "_get" not in name
        assert "_serp_" not in name and "_scrap_" not in name


async def test_operation_ids_all_resolve_to_a_handler_name():
    """If the route walk stops finding routes, this mapping silently
    empties and every tool falls back to its generated operationId."""
    names = _tool_names(app_module.app)

    assert len(names) >= len(EXPECTED_TOOLS)
    assert "search_arxiv_serp_search_arxiv_post" in names
    assert names["search_arxiv_serp_search_arxiv_post"] == "search_arxiv"


@pytest.mark.parametrize("excluded", [
    "ops_keyword_search", "ops_get_patent", "ops_get_patents_bulk",
    "search_brave", "search_bing", "search_duck",
])
async def test_excluded_endpoints_are_not_exposed_as_tools(tools, excluded):
    assert excluded not in tools


def test_excluded_routes_patterns_are_anchored():
    """An unanchored pattern would silently exclude more than intended."""
    for pattern in EXCLUDED_ROUTES:
        assert pattern.startswith("^")


# ---------------------------------- output schemas ----------------------------------


@pytest.mark.parametrize("tool_name", sorted(EXPECTED_TOOLS))
async def test_every_tool_declares_its_output_schema(tools, tool_name):
    """FastMCP builds tool schemas from the app's OpenAPI document, so a
    handler with no return annotation advertises an untyped result to the
    model. `search` - the tool the instructions push agents toward first -
    was one of three that did.
    """
    assert tools[tool_name].output_schema, (
        f"tool '{tool_name}' advertises no output schema; "
        "the handler is probably missing a return annotation")


@pytest.mark.parametrize("path", [
    "/serp/search", "/serp/search_arxiv", "/serp/search_scholar",
    "/serp/search_patents", "/scrap/scrap_patent/{patent_id}",
    "/scrap/scrap_patents_bulk",
])
def test_every_exposed_route_declares_a_200_response_model(path):
    schema = app_module.app.openapi()
    for operation in schema["paths"][path].values():
        content = (operation.get("responses", {}).get("200", {})
                   .get("content", {}).get("application/json", {}).get("schema", {}))
        assert content, f"{path} has no 200 response schema"