Spaces:
Sleeping
Pin the MCP tool surface and give every tool an output schema
Browse filesmcp_server.py had no tests, and it is the file least able to afford that:
`_tool_names` walks the route tree through three version-dependent shapes
(DefaultPlaceholder unwrapping, nested `.routes`, FastAPI >= 0.141's
`original_router`), each a reasonable guess about a library's internals.
When one stops resolving, tools quietly revert to generated operationIds
like `search_arxiv_serp_search_arxiv_post`, tool selection degrades, and
nothing fails.
tests/test_mcp_server.py pins the surface an agent actually sees: the
curated six-tool set, handler names rather than operationIds, the
/ops/* and single-backend exclusions holding, and anchored exclusion
patterns.
It also caught a live defect. FastMCP derives tool schemas from the app's
OpenAPI document, and three handlers had no return annotation, so FastAPI
emitted an empty 200 schema and those tools advertised no output schema at
all - `search`, `search_arxiv` and `search_google_scholar`. `search` is the
tool MCP_INSTRUCTIONS pushes agents toward first. Adding the three
`-> SerpResults` annotations types all six, and the tests keep them typed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSNSYnceqvdVz7Csis4K9e
- app.py +3 -3
- tests/test_mcp_server.py +102 -0
|
@@ -154,7 +154,7 @@ async def _run_serp_queries(fn, params: SerpQuery, context: str) -> SerpResults:
|
|
| 154 |
|
| 155 |
|
| 156 |
@serp_router.post("/search_scholar")
|
| 157 |
-
async def search_google_scholar(params: SerpQuery):
|
| 158 |
"""Queries google scholar for the specified query"""
|
| 159 |
logging.info(f"Searching Google Scholar for queries: {params.queries}")
|
| 160 |
return await _run_serp_queries(
|
|
@@ -162,7 +162,7 @@ async def search_google_scholar(params: SerpQuery):
|
|
| 162 |
|
| 163 |
|
| 164 |
@serp_router.post("/search_arxiv")
|
| 165 |
-
async def search_arxiv(params: SerpQuery):
|
| 166 |
"""Searches arxiv for the specified queries and returns the found documents."""
|
| 167 |
logging.info(f"Searching Arxiv for queries: {params.queries}")
|
| 168 |
return await _run_serp_queries(
|
|
@@ -256,7 +256,7 @@ async def _search_one(q: str, n_results: int) -> tuple[str, list[dict], Optional
|
|
| 256 |
|
| 257 |
|
| 258 |
@serp_router.post("/search")
|
| 259 |
-
async def search(params: SerpQuery):
|
| 260 |
"""Attempts to search the specified queries using ALL backends"""
|
| 261 |
outcomes = await asyncio.gather(*[_search_one(q, params.n_results) for q in params.queries])
|
| 262 |
|
|
|
|
| 154 |
|
| 155 |
|
| 156 |
@serp_router.post("/search_scholar")
|
| 157 |
+
async def search_google_scholar(params: SerpQuery) -> SerpResults:
|
| 158 |
"""Queries google scholar for the specified query"""
|
| 159 |
logging.info(f"Searching Google Scholar for queries: {params.queries}")
|
| 160 |
return await _run_serp_queries(
|
|
|
|
| 162 |
|
| 163 |
|
| 164 |
@serp_router.post("/search_arxiv")
|
| 165 |
+
async def search_arxiv(params: SerpQuery) -> SerpResults:
|
| 166 |
"""Searches arxiv for the specified queries and returns the found documents."""
|
| 167 |
logging.info(f"Searching Arxiv for queries: {params.queries}")
|
| 168 |
return await _run_serp_queries(
|
|
|
|
| 256 |
|
| 257 |
|
| 258 |
@serp_router.post("/search")
|
| 259 |
+
async def search(params: SerpQuery) -> SerpResults:
|
| 260 |
"""Attempts to search the specified queries using ALL backends"""
|
| 261 |
outcomes = await asyncio.gather(*[_search_one(q, params.n_results) for q in params.queries])
|
| 262 |
|
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The MCP tool surface.
|
| 2 |
+
|
| 3 |
+
mcp_server.py had no tests, and it degrades silently. `_tool_names` walks
|
| 4 |
+
the route tree through three version-dependent shapes - DefaultPlaceholder
|
| 5 |
+
unwrapping, nested `.routes`, and FastAPI >= 0.141's `original_router` -
|
| 6 |
+
each a reasonable guess about a library's internals. If any stops
|
| 7 |
+
resolving, tools quietly revert to names like
|
| 8 |
+
`search_arxiv_serp_search_arxiv_post`, tool selection degrades, and
|
| 9 |
+
nothing goes red.
|
| 10 |
+
|
| 11 |
+
These pin the surface an agent actually sees, so a dependency bump reports
|
| 12 |
+
the breakage instead of shipping it.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import pytest
|
| 16 |
+
|
| 17 |
+
import app as app_module
|
| 18 |
+
from mcp_server import EXCLUDED_ROUTES, _tool_names
|
| 19 |
+
|
| 20 |
+
# The curated set. /ops/* and the single-backend search endpoints are
|
| 21 |
+
# deliberately excluded (see EXCLUDED_ROUTES): they stay available over
|
| 22 |
+
# REST, but exposing them as tools invites a model to pick a path that
|
| 23 |
+
# skips the fallback chains.
|
| 24 |
+
EXPECTED_TOOLS = {
|
| 25 |
+
"search",
|
| 26 |
+
"search_arxiv",
|
| 27 |
+
"search_google_scholar",
|
| 28 |
+
"search_patents",
|
| 29 |
+
"scrap_patent",
|
| 30 |
+
"scrap_patents",
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@pytest.fixture
|
| 35 |
+
async def tools():
|
| 36 |
+
return {t.name: t for t in await app_module.mcp.list_tools()}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
async def test_the_exposed_tool_set_is_the_curated_one(tools):
|
| 40 |
+
assert set(tools) == EXPECTED_TOOLS
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
async def test_tool_names_are_handler_names_not_generated_operation_ids(tools):
|
| 44 |
+
"""FastAPI's generated operationIds (`search_arxiv_serp_search_arxiv_post`)
|
| 45 |
+
are what an LLM would otherwise see. _tool_names maps them back to the
|
| 46 |
+
Python handler name.
|
| 47 |
+
"""
|
| 48 |
+
for name in tools:
|
| 49 |
+
assert "_post" not in name and "_get" not in name
|
| 50 |
+
assert "_serp_" not in name and "_scrap_" not in name
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
async def test_operation_ids_all_resolve_to_a_handler_name():
|
| 54 |
+
"""If the route walk stops finding routes, this mapping silently
|
| 55 |
+
empties and every tool falls back to its generated operationId."""
|
| 56 |
+
names = _tool_names(app_module.app)
|
| 57 |
+
|
| 58 |
+
assert len(names) >= len(EXPECTED_TOOLS)
|
| 59 |
+
assert "search_arxiv_serp_search_arxiv_post" in names
|
| 60 |
+
assert names["search_arxiv_serp_search_arxiv_post"] == "search_arxiv"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@pytest.mark.parametrize("excluded", [
|
| 64 |
+
"ops_keyword_search", "ops_get_patent", "ops_get_patents_bulk",
|
| 65 |
+
"search_brave", "search_bing", "search_duck",
|
| 66 |
+
])
|
| 67 |
+
async def test_excluded_endpoints_are_not_exposed_as_tools(tools, excluded):
|
| 68 |
+
assert excluded not in tools
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_excluded_routes_patterns_are_anchored():
|
| 72 |
+
"""An unanchored pattern would silently exclude more than intended."""
|
| 73 |
+
for pattern in EXCLUDED_ROUTES:
|
| 74 |
+
assert pattern.startswith("^")
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# ---------------------------------- output schemas ----------------------------------
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@pytest.mark.parametrize("tool_name", sorted(EXPECTED_TOOLS))
|
| 81 |
+
async def test_every_tool_declares_its_output_schema(tools, tool_name):
|
| 82 |
+
"""FastMCP builds tool schemas from the app's OpenAPI document, so a
|
| 83 |
+
handler with no return annotation advertises an untyped result to the
|
| 84 |
+
model. `search` - the tool the instructions push agents toward first -
|
| 85 |
+
was one of three that did.
|
| 86 |
+
"""
|
| 87 |
+
assert tools[tool_name].output_schema, (
|
| 88 |
+
f"tool '{tool_name}' advertises no output schema; "
|
| 89 |
+
"the handler is probably missing a return annotation")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@pytest.mark.parametrize("path", [
|
| 93 |
+
"/serp/search", "/serp/search_arxiv", "/serp/search_scholar",
|
| 94 |
+
"/serp/search_patents", "/scrap/scrap_patent/{patent_id}",
|
| 95 |
+
"/scrap/scrap_patents_bulk",
|
| 96 |
+
])
|
| 97 |
+
def test_every_exposed_route_declares_a_200_response_model(path):
|
| 98 |
+
schema = app_module.app.openapi()
|
| 99 |
+
for operation in schema["paths"][path].values():
|
| 100 |
+
content = (operation.get("responses", {}).get("200", {})
|
| 101 |
+
.get("content", {}).get("application/json", {}).get("schema", {}))
|
| 102 |
+
assert content, f"{path} has no 200 response schema"
|