"""MCP (Model Context Protocol) server for SERPent. Every route registered on the FastAPI app is automatically re-exposed as an MCP tool, so a single deployment serves both audiences: REST -> POST https:///serp/search_arxiv MCP -> POST https:///mcp (streamable HTTP transport) Nothing here duplicates business logic. FastMCP reads the app's OpenAPI schema and, when a tool is called, dispatches the request back into the same FastAPI app in-process (via an ASGI transport, no network round-trip), so tools always stay in sync with the endpoints. Usage (see the bottom of `app.py`): from mcp_server import mount_mcp_server mcp = mount_mcp_server(app) Configuration (all optional, read from the environment): MCP_ENABLED "0"/"false" to disable the MCP server entirely. MCP_PATH Mount path. Default "/mcp". MCP_STATELESS "0" to keep per-session state. Default stateless, which is what you want behind the HF Spaces proxy. MCP_ALLOWED_HOSTS Comma-separated Host allow-list. Default "*". MCP_ALLOWED_ORIGINS Comma-separated Origin allow-list. Default "*". MCP_EXPOSE_ALL "1" to expose every endpoint as a tool instead of the curated set (see EXCLUDED_ROUTES below). """ from __future__ import annotations import logging import os from typing import Optional from fastapi import FastAPI from fastapi.routing import APIRoute from fastapi.utils import generate_unique_id from fastmcp import FastMCP from fastmcp.server.providers.openapi import MCPType, RouteMap from fastmcp.utilities.lifespan import combine_lifespans logger = logging.getLogger(__name__) MCP_SERVER_NAME = "SERPent" # Endpoints kept out of the MCP tool surface. They stay fully available over # REST — this only controls what an LLM sees. # # Fewer, well-differentiated tools measurably improve tool selection, and both # groups below are redundant *for an agent*: # # /ops/* The regular `scrap_patent` / `search_patents` tools already # fall back to EPO OPS on their own. Calling the OPS tools # directly skips that fallback, so a patent missing from OPS # becomes a hard error instead of a Google Patents hit. # search_brave These are `search` with the fallback chain removed. `search` # search_bing already tries DuckDuckGo, then Brave, then Bing, so exposing # search_duck them separately only invites the model to pick a worse path. # # Set MCP_EXPOSE_ALL=1 to expose everything again, or edit this list. EXCLUDED_ROUTES = [ r"^/ops/.*", r"^/serp/search_(brave|bing|duck)$", ] MCP_INSTRUCTIONS = """\ SERPent gives you web, academic and patent search plus full-text patent retrieval. Typical workflow: 1. Search with the backend that matches the question: - `search` for general web results (tries DuckDuckGo, then Brave, then Bing) - `search_arxiv` for preprints, `search_google_scholar` for academic papers - `search_patents` for prior art (falls back to the EPO OPS API) 2. Feed the patent ids you found to `scrap_patent` (one) or `scrap_patents` (many, and much faster than looping) to get title, abstract, claims and description. Every search tool accepts a LIST of queries and runs them concurrently, so send all your query variations in one call rather than making several calls. Set `n_results` to control how many hits come back per query. `search` and `search_patents` already fall back across several backends internally, including the official EPO OPS patent API, so a single call is the most thorough option available — there is no lower-level tool to reach for when one comes back empty. A patent that returns "not found" is genuinely absent from every backend; move on to the next id rather than retrying. """ def _env_flag(name: str, default: bool) -> bool: raw = os.getenv(name) if raw is None: return default return raw.strip().lower() not in {"0", "false", "no", "off", ""} def _env_list(name: str, default: list[str]) -> list[str]: raw = os.getenv(name) if not raw: return default return [item.strip() for item in raw.split(",") if item.strip()] def _tool_names(app: FastAPI) -> dict[str, str]: """Map each route's OpenAPI operationId to a clean MCP tool name. FastAPI's generated operationIds look like `search_arxiv_serp_search_arxiv_post`, which is what an LLM would otherwise see. The Python handler name (`search_arxiv`) is a far better tool name, so use that instead. Adding an explicit `operation_id=` to a route decorator overrides this. """ names: dict[str, str] = {} def walk(routes) -> None: for route in routes: # Depending on the FastAPI version, included routers either flatten # into the parent or stay nested, so recurse. if isinstance(route, APIRoute): generate = getattr(route, "generate_unique_id_function", None) # FastAPI stores an unset callable as a DefaultPlaceholder. generate = getattr(generate, "value", generate) if not callable(generate): generate = generate_unique_id operation_id = route.operation_id or generate(route) names[operation_id] = route.name # Starlette Mounts nest under `.routes`; FastAPI >= 0.141 wraps # included routers in `_IncludedRouter`, which nests under # `.original_router`. sub = getattr(route, "routes", None) if sub: walk(sub) included = getattr(route, "original_router", None) if included is not None and getattr(included, "routes", None): walk(included.routes) walk(app.routes) return names def build_mcp_server(app: FastAPI) -> FastMCP: """Build an MCP server exposing the app's routes as tools.""" if _env_flag("MCP_EXPOSE_ALL", False): route_maps = None logger.info("MCP exposing all endpoints (MCP_EXPOSE_ALL=1).") else: route_maps = [ RouteMap(pattern=pattern, mcp_type=MCPType.EXCLUDE) for pattern in EXCLUDED_ROUTES ] mcp = FastMCP.from_fastapi( app=app, name=MCP_SERVER_NAME, mcp_names=_tool_names(app), route_maps=route_maps, # Requests are dispatched straight back into this same app in-process. httpx_client_kwargs={"timeout": 300.0}, ) mcp.instructions = MCP_INSTRUCTIONS return mcp def mount_mcp_server(app: FastAPI, path: Optional[str] = None) -> Optional[FastMCP]: """Mount the MCP server onto `app` (default: `/mcp`). Must be called after every router has been included, otherwise the missing routes will not show up as tools. Returns None when MCP is disabled. """ if not _env_flag("MCP_ENABLED", True): logger.info("MCP server disabled (MCP_ENABLED=0).") return None path = path or os.getenv("MCP_PATH", "/mcp") path = "/" + path.strip("/") mcp = build_mcp_server(app) mcp_app = mcp.http_app( path=path, stateless_http=_env_flag("MCP_STATELESS", True), allowed_hosts=_env_list("MCP_ALLOWED_HOSTS", ["*"]), allowed_origins=_env_list("MCP_ALLOWED_ORIGINS", ["*"]), ) # The MCP session manager needs its own lifespan to run. The app already has # one (Playwright), so run both. app.router.lifespan_context = combine_lifespans( app.router.lifespan_context, mcp_app.lifespan, ) # The MCP endpoint is `path` *inside* mcp_app, and mcp_app is mounted at the # root. Mounting it at `path` instead would make Starlette answer a bare # `POST /mcp` with a 307 to `/mcp/`, which several MCP clients choke on. # This mount is added last, so all the API routes above still match first. app.mount("/", mcp_app) logger.info("MCP server mounted at %s (streamable HTTP).", path) return mcp