File size: 8,058 Bytes
2fb998d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2a5065e
 
2fb998d
 
 
 
 
 
 
 
 
 
 
 
 
2a5065e
2fb998d
 
 
 
 
 
2a5065e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2fb998d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2a5065e
 
 
 
 
2fb998d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2a5065e
 
 
 
 
 
 
 
 
 
2fb998d
 
 
 
2a5065e
2fb998d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
"""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://<host>/serp/search_arxiv
    MCP   ->  POST https://<host>/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