Claude Claude Opus 5 commited on
Commit
060118a
·
unverified ·
1 Parent(s): 232eb80

Pin the MCP tool surface and give every tool an output schema

Browse files

mcp_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

Files changed (2) hide show
  1. app.py +3 -3
  2. tests/test_mcp_server.py +102 -0
app.py CHANGED
@@ -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
 
tests/test_mcp_server.py ADDED
@@ -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"