Almaatla commited on
Commit
2fb998d
·
verified ·
1 Parent(s): c320b8e

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.

Files changed (3) hide show
  1. app.py +6 -0
  2. mcp_server.py +168 -0
  3. requirements.txt +2 -1
app.py CHANGED
@@ -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)
mcp_server.py ADDED
@@ -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
requirements.txt CHANGED
@@ -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