SERPent / tests /test_mcp_server.py
Claude
Pin the MCP tool surface and give every tool an output schema
060118a unverified
Raw
History Blame Contribute Delete
3.79 kB
"""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"