Spaces:
Sleeping
Upload 3 files
Browse files# MCP server support
SERPent now speaks two protocols from the same deployment:
| Audience | Endpoint |
|---|---|
| REST clients / Swagger UI | `https://<host>/` (docs), `POST /serp/...`, `/scrap/...`, `/ops/...` |
| MCP clients (Claude, Cursor, agents) | `https://<host>/mcp` — streamable HTTP transport |
## What changed
| File | Change |
|---|---|
| `mcp_server.py` | **New.** Builds the MCP server from the FastAPI app and mounts it. |
| `app.py` | Two lines: `from mcp_server import mount_mcp_server` and `mcp = mount_mcp_server(app)` after the `include_router` calls. |
| `requirements.txt` | Added `fastmcp>=3.0,<4`. |
| `Dockerfile` | Unchanged — same image, same port 7860, same `CMD`. |
## How it works
`FastMCP.from_fastapi()` reads the app's OpenAPI schema and turns every route
into an MCP tool. When a tool is called, the request is dispatched **back into
the same FastAPI app in-process** over an ASGI transport — no network hop, no
second server, no duplicated business logic. Add an endpoint, get a tool.
The 12 tools generated today:
```
search search_arxiv search_google_scholar
search_patents search_brave search_bing
search_duck scrap_patent scrap_patents
ops_keyword_search ops_get_patent ops_get_patents_bulk
```
Tool names come from the Python handler names rather than FastAPI's generated
operation ids, so agents see `search_arxiv` instead of
`search_arxiv_serp_search_arxiv_post`. Descriptions and JSON schemas come from
your docstrings and Pydantic models — improving a docstring improves the tool.
To pin a specific tool name, set `operation_id="..."` on the route decorator.
## Connecting a client
**Claude Code / any streamable-HTTP client:**
```bash
claude mcp add --transport http serpent https://<your-space>.hf.space/mcp
```
**`.mcp.json` / `claude_desktop_config.json`:**
```json
{
"mcpServers": {
"serpent": {
"type": "http",
"url": "https://<your-space>.hf.space/mcp"
}
}
}
```
**Private HF Space** — pass your token:
```json
{
"mcpServers": {
"serpent": {
"type": "http",
"url": "https://<your-space>.hf.space/mcp",
"headers": { "Authorization": "Bearer hf_..." }
}
}
}
```
**Quick check without a client:**
```bash
curl -X POST http://localhost:7860/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-06-18","capabilities":{},
"clientInfo":{"name":"curl","version":"1"}}}'
```
Or with the FastMCP CLI: `fastmcp inspect http://localhost:7860/mcp`
## Configuration
All optional, all environment variables:
| Variable | Default | Purpose |
|---|---|---|
| `MCP_ENABLED` | `1` | Set to `0` to run REST-only. |
| `MCP_PATH` | `/mcp` | Where the MCP endpoint lives. |
| `MCP_STATELESS` | `1` | Stateless HTTP — no session affinity needed behind the HF Spaces proxy. Set `0` for resumable SSE streams. |
| `MCP_ALLOWED_HOSTS` | `*` | Comma-separated Host allow-list (DNS-rebinding protection). |
| `MCP_ALLOWED_ORIGINS` | `*` | Comma-separated Origin allow-list (browser clients). |
## Notes and caveats
- **Ordering matters.** `mount_mcp_server(app)` must come after every
`include_router()` call. Routes registered after it are not exposed as tools,
and are not reachable at all — the MCP app is mounted at the root so that a
bare `POST /mcp` answers directly instead of 307-redirecting to `/mcp/`
(a redirect some MCP clients mishandle).
- **Lifespans are combined**, so Playwright still starts up and shuts down
exactly as before, alongside the MCP session manager.
- **The `ops_*` tools** are exposed but return 503 without
`OPS_CONSUMER_KEY` / `OPS_CONSUMER_SECRET`. The server instructions tell
agents to prefer the regular tools, which already fall back to OPS.
- **Auth.** The MCP endpoint inherits whatever protects the Space. If you later
want per-token auth on MCP only, FastMCP takes an `auth=` provider in
`build_mcp_server()`.
- **Tool count.** 12 tools with six overlapping search backends is a lot of
surface for a model to choose from. If tool selection gets noisy, pass
`route_maps=[...]` to `FastMCP.from_fastapi()` in `mcp_server.py` to exclude
the per-backend tools and keep `search`, `search_arxiv`, `search_patents`,
`search_google_scholar` and the scrapers.
## Verified
Tested against `fastmcp 3.4.7` / `fastapi 0.141.1`: server boots, all 12 tools
list over HTTP with correct schemas, tool calls round-trip through the real
endpoints, and the existing REST routes (`/`, `/openapi.json`, `/redoc`,
`/serp/*`) are unaffected.
- app.py +6 -0
- mcp_server.py +168 -0
- requirements.txt +2 -1
|
@@ -14,6 +14,7 @@ from scrap import PatentScrapBulkResponse, PatentScrapResult, scrap_patent_async
|
|
| 14 |
from serp import SerpQuery, SerpResults, query_arxiv, query_bing_search, query_brave_search, query_ddg_search, query_google_patents, query_google_scholar
|
| 15 |
from ops import OPSBulkResponse, OPSNotConfigured, ops_scrap_patent, ops_scrap_patent_bulk, ops_search, token_manager as ops_token_manager
|
| 16 |
from utils import log_gathered_exceptions
|
|
|
|
| 17 |
|
| 18 |
logging.basicConfig(
|
| 19 |
level=logging.INFO,
|
|
@@ -333,5 +334,10 @@ app.include_router(serp_router)
|
|
| 333 |
app.include_router(scrap_router)
|
| 334 |
app.include_router(ops_router)
|
| 335 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
if __name__ == "__main__":
|
| 337 |
uvicorn.run(app, host="0.0.0.0", port=7860)
|
|
|
|
| 14 |
from serp import SerpQuery, SerpResults, query_arxiv, query_bing_search, query_brave_search, query_ddg_search, query_google_patents, query_google_scholar
|
| 15 |
from ops import OPSBulkResponse, OPSNotConfigured, ops_scrap_patent, ops_scrap_patent_bulk, ops_search, token_manager as ops_token_manager
|
| 16 |
from utils import log_gathered_exceptions
|
| 17 |
+
from mcp_server import mount_mcp_server
|
| 18 |
|
| 19 |
logging.basicConfig(
|
| 20 |
level=logging.INFO,
|
|
|
|
| 334 |
app.include_router(scrap_router)
|
| 335 |
app.include_router(ops_router)
|
| 336 |
|
| 337 |
+
# =============================== MCP server ===================================
|
| 338 |
+
# Re-exposes every endpoint above as an MCP tool over streamable HTTP at /mcp.
|
| 339 |
+
# Must stay below the include_router() calls, or the routes are not picked up.
|
| 340 |
+
mcp = mount_mcp_server(app)
|
| 341 |
+
|
| 342 |
if __name__ == "__main__":
|
| 343 |
uvicorn.run(app, host="0.0.0.0", port=7860)
|
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MCP (Model Context Protocol) server for SERPent.
|
| 2 |
+
|
| 3 |
+
Every route registered on the FastAPI app is automatically re-exposed as an MCP
|
| 4 |
+
tool, so a single deployment serves both audiences:
|
| 5 |
+
|
| 6 |
+
REST -> POST https://<host>/serp/search_arxiv
|
| 7 |
+
MCP -> POST https://<host>/mcp (streamable HTTP transport)
|
| 8 |
+
|
| 9 |
+
Nothing here duplicates business logic. FastMCP reads the app's OpenAPI schema
|
| 10 |
+
and, when a tool is called, dispatches the request back into the same FastAPI
|
| 11 |
+
app in-process (via an ASGI transport, no network round-trip), so tools always
|
| 12 |
+
stay in sync with the endpoints.
|
| 13 |
+
|
| 14 |
+
Usage (see the bottom of `app.py`):
|
| 15 |
+
|
| 16 |
+
from mcp_server import mount_mcp_server
|
| 17 |
+
mcp = mount_mcp_server(app)
|
| 18 |
+
|
| 19 |
+
Configuration (all optional, read from the environment):
|
| 20 |
+
|
| 21 |
+
MCP_ENABLED "0"/"false" to disable the MCP server entirely.
|
| 22 |
+
MCP_PATH Mount path. Default "/mcp".
|
| 23 |
+
MCP_STATELESS "0" to keep per-session state. Default stateless, which
|
| 24 |
+
is what you want behind the HF Spaces proxy.
|
| 25 |
+
MCP_ALLOWED_HOSTS Comma-separated Host allow-list. Default "*".
|
| 26 |
+
MCP_ALLOWED_ORIGINS Comma-separated Origin allow-list. Default "*".
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
import logging
|
| 32 |
+
import os
|
| 33 |
+
from typing import Optional
|
| 34 |
+
|
| 35 |
+
from fastapi import FastAPI
|
| 36 |
+
from fastapi.routing import APIRoute
|
| 37 |
+
from fastapi.utils import generate_unique_id
|
| 38 |
+
|
| 39 |
+
from fastmcp import FastMCP
|
| 40 |
+
from fastmcp.utilities.lifespan import combine_lifespans
|
| 41 |
+
|
| 42 |
+
logger = logging.getLogger(__name__)
|
| 43 |
+
|
| 44 |
+
MCP_SERVER_NAME = "SERPent"
|
| 45 |
+
|
| 46 |
+
MCP_INSTRUCTIONS = """\
|
| 47 |
+
SERPent gives you web, academic and patent search plus full-text patent retrieval.
|
| 48 |
+
|
| 49 |
+
Typical workflow:
|
| 50 |
+
1. Search with the backend that matches the question:
|
| 51 |
+
- `search` for general web results (tries DuckDuckGo, then Brave, then Bing)
|
| 52 |
+
- `search_arxiv` for preprints, `search_google_scholar` for academic papers
|
| 53 |
+
- `search_patents` for prior art (falls back to the EPO OPS API)
|
| 54 |
+
2. Feed the patent ids you found to `scrap_patent` (one) or `scrap_patents`
|
| 55 |
+
(many, and much faster than looping) to get title, abstract, claims and
|
| 56 |
+
description.
|
| 57 |
+
|
| 58 |
+
Every search tool accepts a LIST of queries and runs them concurrently, so send
|
| 59 |
+
all your query variations in one call rather than making several calls. Set
|
| 60 |
+
`n_results` to control how many hits come back per query.
|
| 61 |
+
|
| 62 |
+
The `ops_*` tools hit the official EPO OPS API directly and only work when the
|
| 63 |
+
deployment has OPS credentials configured; the regular tools already fall back
|
| 64 |
+
to OPS on their own, so prefer them unless you specifically need EPO data.
|
| 65 |
+
"""
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _env_flag(name: str, default: bool) -> bool:
|
| 69 |
+
raw = os.getenv(name)
|
| 70 |
+
if raw is None:
|
| 71 |
+
return default
|
| 72 |
+
return raw.strip().lower() not in {"0", "false", "no", "off", ""}
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _env_list(name: str, default: list[str]) -> list[str]:
|
| 76 |
+
raw = os.getenv(name)
|
| 77 |
+
if not raw:
|
| 78 |
+
return default
|
| 79 |
+
return [item.strip() for item in raw.split(",") if item.strip()]
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _tool_names(app: FastAPI) -> dict[str, str]:
|
| 83 |
+
"""Map each route's OpenAPI operationId to a clean MCP tool name.
|
| 84 |
+
|
| 85 |
+
FastAPI's generated operationIds look like
|
| 86 |
+
`search_arxiv_serp_search_arxiv_post`, which is what an LLM would otherwise
|
| 87 |
+
see. The Python handler name (`search_arxiv`) is a far better tool name, so
|
| 88 |
+
use that instead. Adding an explicit `operation_id=` to a route decorator
|
| 89 |
+
overrides this.
|
| 90 |
+
"""
|
| 91 |
+
names: dict[str, str] = {}
|
| 92 |
+
|
| 93 |
+
def walk(routes) -> None:
|
| 94 |
+
for route in routes:
|
| 95 |
+
# Depending on the FastAPI version, included routers either flatten
|
| 96 |
+
# into the parent or stay nested, so recurse.
|
| 97 |
+
if isinstance(route, APIRoute):
|
| 98 |
+
generate = getattr(route, "generate_unique_id_function", None)
|
| 99 |
+
# FastAPI stores an unset callable as a DefaultPlaceholder.
|
| 100 |
+
generate = getattr(generate, "value", generate)
|
| 101 |
+
if not callable(generate):
|
| 102 |
+
generate = generate_unique_id
|
| 103 |
+
operation_id = route.operation_id or generate(route)
|
| 104 |
+
names[operation_id] = route.name
|
| 105 |
+
# Starlette Mounts nest under `.routes`; FastAPI >= 0.141 wraps
|
| 106 |
+
# included routers in `_IncludedRouter`, which nests under
|
| 107 |
+
# `.original_router`.
|
| 108 |
+
sub = getattr(route, "routes", None)
|
| 109 |
+
if sub:
|
| 110 |
+
walk(sub)
|
| 111 |
+
included = getattr(route, "original_router", None)
|
| 112 |
+
if included is not None and getattr(included, "routes", None):
|
| 113 |
+
walk(included.routes)
|
| 114 |
+
|
| 115 |
+
walk(app.routes)
|
| 116 |
+
return names
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def build_mcp_server(app: FastAPI) -> FastMCP:
|
| 120 |
+
"""Build an MCP server exposing every route of `app` as a tool."""
|
| 121 |
+
mcp = FastMCP.from_fastapi(
|
| 122 |
+
app=app,
|
| 123 |
+
name=MCP_SERVER_NAME,
|
| 124 |
+
mcp_names=_tool_names(app),
|
| 125 |
+
# Requests are dispatched straight back into this same app in-process.
|
| 126 |
+
httpx_client_kwargs={"timeout": 300.0},
|
| 127 |
+
)
|
| 128 |
+
mcp.instructions = MCP_INSTRUCTIONS
|
| 129 |
+
return mcp
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def mount_mcp_server(app: FastAPI, path: Optional[str] = None) -> Optional[FastMCP]:
|
| 133 |
+
"""Mount the MCP server onto `app` (default: `/mcp`).
|
| 134 |
+
|
| 135 |
+
Must be called after every router has been included, otherwise the missing
|
| 136 |
+
routes will not show up as tools. Returns None when MCP is disabled.
|
| 137 |
+
"""
|
| 138 |
+
if not _env_flag("MCP_ENABLED", True):
|
| 139 |
+
logger.info("MCP server disabled (MCP_ENABLED=0).")
|
| 140 |
+
return None
|
| 141 |
+
|
| 142 |
+
path = path or os.getenv("MCP_PATH", "/mcp")
|
| 143 |
+
path = "/" + path.strip("/")
|
| 144 |
+
|
| 145 |
+
mcp = build_mcp_server(app)
|
| 146 |
+
|
| 147 |
+
mcp_app = mcp.http_app(
|
| 148 |
+
path=path,
|
| 149 |
+
stateless_http=_env_flag("MCP_STATELESS", True),
|
| 150 |
+
allowed_hosts=_env_list("MCP_ALLOWED_HOSTS", ["*"]),
|
| 151 |
+
allowed_origins=_env_list("MCP_ALLOWED_ORIGINS", ["*"]),
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
# The MCP session manager needs its own lifespan to run. The app already has
|
| 155 |
+
# one (Playwright), so run both.
|
| 156 |
+
app.router.lifespan_context = combine_lifespans(
|
| 157 |
+
app.router.lifespan_context,
|
| 158 |
+
mcp_app.lifespan,
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
# The MCP endpoint is `path` *inside* mcp_app, and mcp_app is mounted at the
|
| 162 |
+
# root. Mounting it at `path` instead would make Starlette answer a bare
|
| 163 |
+
# `POST /mcp` with a 307 to `/mcp/`, which several MCP clients choke on.
|
| 164 |
+
# This mount is added last, so all the API routes above still match first.
|
| 165 |
+
app.mount("/", mcp_app)
|
| 166 |
+
|
| 167 |
+
logger.info("MCP server mounted at %s (streamable HTTP).", path)
|
| 168 |
+
return mcp
|
|
@@ -6,4 +6,5 @@ duckduckgo_search
|
|
| 6 |
beautifulsoup4
|
| 7 |
httpx
|
| 8 |
lxml
|
| 9 |
-
python-dotenv
|
|
|
|
|
|
| 6 |
beautifulsoup4
|
| 7 |
httpx
|
| 8 |
lxml
|
| 9 |
+
python-dotenv
|
| 10 |
+
fastmcp>=3.0,<4
|