Spaces:
Running
Running
| """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", | |
| } | |
| 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" | |
| 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 ---------------------------------- | |
| 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") | |
| 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" | |