Critical: the MCP SDK auto-enables DNS-rebinding protection when its host parameter is left at the 127.0.0.1 default, hard-coding a loopback-only Host allow-list. Every remote client (the only deployment this feature targets) was refused with HTTP 421 before auth or the tool ran. Now driven by a new MCP_ALLOWED_HOSTS setting, with '*' as an explicit, logged opt-out. Also bounds query/top_k to match AskRequest (top_k is amplified 4x downstream, so an unbounded value was a resource-exhaustion vector), decodes the Authorization header as latin-1 per the ASGI spec instead of raising a 500 on malformed bytes, and returns WWW-Authenticate on 401 per RFC 7235. Moves the psycopg2 import guard into backend/tests/conftest.py: duplicated across four test modules, it only worked because of alphabetical collection order, and any earlier-sorting package would have reintroduced a live connection attempt against the production database. Registers the mcp module in the authoritative backend architecture doc. 84 backend tests pass. Verified against a live server: allowed remote Host returns a valid initialize result, unknown Host returns 421, missing token returns 401 with WWW-Authenticate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
206 lines
16 KiB
Markdown
206 lines
16 KiB
Markdown
# MCP Regulation Search Server — Design
|
||
|
||
**Date:** 2026-07-29
|
||
**Scope:** Expose the existing compliance knowledge base as a standalone Model Context Protocol (MCP) server module, mounted into the existing FastAPI backend, so external MCP clients (Claude Desktop, GitHub Copilot, Cursor, etc.) can call a single `search_regulations` tool over the network.
|
||
**Relationship to existing roadmap:** This is the first half ("Direction A" — expose our data) of the MCP integration opportunity identified during the 2026-07-29 brainstorming session. "Direction B" (consuming external MCP servers, e.g. for US/UK regulatory data) was researched and explicitly rejected for this iteration — no existing open-source regulation MCP server covers this platform's actual sources (国标委/GB standards, CATARC, EUR-Lex); the closest match (`lamcearber-spec/eu-legal-mcp`) is a 0-star, month-old project that only duplicates EUR-Lex data this platform already crawls itself. Direction B is deferred until a concrete need for a jurisdiction this platform doesn't already cover arises.
|
||
|
||
---
|
||
|
||
## Goals
|
||
|
||
1. Expose exactly one MCP tool, `search_regulations`, backed by the **existing** `AgentConversationService.ask()` application service (`backend/app/application/agent/services.py`) — the same code path already used by the `/api/v1/agent/ask` REST endpoint. Zero new retrieval/answering logic.
|
||
2. Package the MCP server as its own self-contained module (`backend/app/mcp/`), then mount it into the existing FastAPI app (`backend/app/api/main.py`) so it ships with the current deployment — no new process, no new deployment pipeline.
|
||
3. Use the Streamable HTTP transport (not stdio) — the backend is deployed remotely (6.86.80.9), so external MCP clients must connect over the network, not via a locally-spawned subprocess.
|
||
4. Reuse the existing JWT auth mechanism — no new auth system. Any authenticated user (any of the four roles) may call `search_regulations`, matching the existing `/agent/ask` endpoint's access level and the `UserRole` docstring ("knowledge query" is available to all four roles including `READONLY`).
|
||
|
||
## Non-Goals
|
||
|
||
- Direction B (this platform's agent consuming external MCP servers) — deferred, see rejection rationale above.
|
||
- Additional tools beyond `search_regulations` (e.g. perception event queries, compliance checks) — explicit user decision to ship the minimal viable version first.
|
||
- Role-based restriction of the tool (e.g. ADMIN-only) — all four roles already have knowledge-query access per the existing RBAC model; no new restriction needed.
|
||
- stdio transport / local-only usage — not useful for a remotely-deployed backend.
|
||
- Rate limiting or per-client quotas on the MCP endpoint — no existing precedent in this codebase for any endpoint; out of scope until a concrete abuse case appears.
|
||
|
||
---
|
||
|
||
## Architecture
|
||
|
||
> **Implementation note (post-design correction):** the `mcp` PyPI package released version `2.0.0` shortly before implementation and renamed the `FastMCP` class referenced below to `MCPServer` (import path `mcp.server.MCPServer` instead of `mcp.server.fastmcp.FastMCP`). The `.tool()` / `.streamable_http_app()` API surface used throughout this doc is otherwise unchanged. `backend/app/mcp/server.py` uses the actual shipped `MCPServer` name — treat every `FastMCP` mention below as that rename. Two other corrections discovered during implementation: (1) `MCPServer.streamable_http_app()` registers its own internal route at a fixed `/mcp` path, so mounting it at `/mcp` in `api/main.py` would double the path to `/mcp/mcp` — fixed by calling `mcp.streamable_http_app(streamable_http_path="/")`; (2) the effective external URL for clients is `/mcp/` (**with** a trailing slash) — Starlette's `Mount` 307-redirects the bare `/mcp` to `/mcp/`, which most HTTP clients follow automatically, but it is more robust to configure clients with the trailing slash directly.
|
||
|
||
### Module layout
|
||
|
||
```
|
||
backend/app/mcp/
|
||
__init__.py
|
||
server.py # FastMCP instance, search_regulations tool, auth wrapper, ASGI app builder
|
||
```
|
||
|
||
This sits as a new top-level package alongside `app/api/`, `app/application/`, `app/services/`, `app/shared/` — not nested inside `app/api/routes/`, because MCP tool registration (decorator-based schema binding) and its own sub-ASGI-app are a fundamentally different transport mechanism from the FastAPI `APIRouter` REST routes there. Keeping it as its own top-level module satisfies "list MCP as its own module" and keeps the REST route directory free of non-REST concerns.
|
||
|
||
`server.py` contains **zero new business logic** — it is a protocol adapter that calls the existing composition root (`app.shared.bootstrap.get_agent_conversation_service()`), the same function `backend/app/api/routes/agent.py` already calls. This is consistent with the architecture rule that new business orchestration belongs in `application/`, not scattered across transport adapters — there is no new orchestration here at all.
|
||
|
||
### Tool definition
|
||
|
||
```python
|
||
# backend/app/mcp/server.py
|
||
from mcp.server.fastmcp import FastMCP
|
||
from app.shared.bootstrap import get_agent_conversation_service
|
||
|
||
mcp = FastMCP("ai-regulations")
|
||
|
||
@mcp.tool()
|
||
def search_regulations(query: str, top_k: int = 5) -> dict:
|
||
"""检索法规知识库,返回基于检索结果生成的答案及引用来源。
|
||
|
||
query: 自然语言检索问题,例如"国六排放标准最新要求"。
|
||
top_k: 返回的引用来源条数上限,默认5条。
|
||
"""
|
||
_, result = get_agent_conversation_service().ask(query=query, top_k=top_k)
|
||
return {
|
||
"answer": result.answer,
|
||
"sources": [source.__dict__ for source in result.sources],
|
||
}
|
||
```
|
||
|
||
Calling `ask()` **without** `session_id` is intentional: it skips all `ConversationStore` reads/writes (see `AgentConversationService.ask()` — history/session logic is only engaged when `session_id` is passed), so each MCP tool call is stateless and side-effect-free, matching the "search" semantics (not a multi-turn chat).
|
||
|
||
### Transport & mounting
|
||
|
||
```python
|
||
# backend/app/mcp/server.py (continued)
|
||
def build_mcp_asgi_app():
|
||
"""Return the mounted MCP ASGI app (Streamable HTTP transport)."""
|
||
return mcp.streamable_http_app()
|
||
```
|
||
|
||
```python
|
||
# backend/app/api/main.py (modified)
|
||
from contextlib import AsyncExitStack
|
||
from app.mcp.server import build_mcp_asgi_app, MCPAuthMiddleware
|
||
|
||
mcp_app = build_mcp_asgi_app()
|
||
mcp_app.add_middleware(MCPAuthMiddleware) # see Auth section
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
"""Application lifecycle hooks."""
|
||
async with AsyncExitStack() as stack:
|
||
# FastMCP's streamable_http_app() owns a session manager that must be
|
||
# started via its own lifespan context. app.mount() does NOT propagate
|
||
# nested ASGI lifespans automatically (confirmed Starlette/ASGI limitation:
|
||
# https://github.com/modelcontextprotocol/python-sdk/issues/1367) — without
|
||
# this, every search_regulations call would fail because the MCP session
|
||
# manager was never started.
|
||
await stack.enter_async_context(mcp_app.router.lifespan_context(mcp_app))
|
||
|
||
logger.info(f"启动 {settings.app_name} v{settings.app_version}")
|
||
preload_runtime_dependencies()
|
||
yield
|
||
cleanup_runtime_dependencies()
|
||
|
||
app.mount("/mcp", mcp_app)
|
||
```
|
||
|
||
This is the one non-obvious infrastructure detail in this design: naively mounting `mcp.streamable_http_app()` via `app.mount()` without wiring its lifespan results in tool calls failing at runtime because the MCP session manager was never started — this is not a hypothetical, it is a confirmed, documented limitation of nested ASGI apps. Using stdlib `AsyncExitStack` inside the **existing** `lifespan()` function avoids adding any new dependency to solve it.
|
||
|
||
### Auth
|
||
|
||
The `/mcp` mount point is protected by a small ASGI middleware (not a FastAPI `Depends`, since the mounted app is not a `FastAPI`/`APIRouter` instance) that:
|
||
|
||
1. Reads the `Authorization: Bearer <token>` header from the incoming ASGI scope.
|
||
2. When `settings.auth_enabled` is `False` (dev mode) — passes through unchanged, matching the existing `get_current_user` dev bypass behavior.
|
||
3. When `settings.auth_enabled` is `True` — validates the token via the **existing** `get_jwt_handler().decode_token(token)` (`backend/app/infrastructure/auth/jwt_handler.py`). On `ValueError` (expired/invalid/missing), returns an HTTP 401 before the request ever reaches the MCP protocol handler. On success, the request proceeds — no role check, since all roles already have knowledge-query access.
|
||
|
||
```python
|
||
# backend/app/mcp/server.py (continued)
|
||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||
from starlette.responses import PlainTextResponse
|
||
from app.config.settings import settings
|
||
from app.shared.bootstrap import get_jwt_handler
|
||
|
||
class MCPAuthMiddleware:
|
||
"""Reject unauthenticated requests to the mounted MCP app before they reach FastMCP."""
|
||
|
||
def __init__(self, app: ASGIApp) -> None:
|
||
self.app = app
|
||
|
||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||
if scope["type"] != "http" or not settings.auth_enabled:
|
||
await self.app(scope, receive, send)
|
||
return
|
||
|
||
headers = dict(scope["headers"])
|
||
auth_header = headers.get(b"authorization", b"").decode()
|
||
token = auth_header.removeprefix("Bearer ").strip()
|
||
try:
|
||
get_jwt_handler().decode_token(token)
|
||
except ValueError as exc:
|
||
response = PlainTextResponse(str(exc), status_code=401)
|
||
await response(scope, receive, send)
|
||
return
|
||
|
||
await self.app(scope, receive, send)
|
||
```
|
||
|
||
**Operational consequence:** to connect an external MCP client (Claude Desktop, Copilot, etc.) to this server, the user must configure a static bearer token (a JWT obtained via the existing `/api/v1/auth/login` flow) in that client's MCP server config, e.g.:
|
||
|
||
```json
|
||
{
|
||
"mcpServers": {
|
||
"ai-regulations": {
|
||
"url": "http://6.86.80.9:8000/mcp/",
|
||
"headers": { "Authorization": "Bearer <jwt>" }
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
JWTs expire after `expire_minutes` (480 by default, see `JWTHandler`) — long-lived external tool connections will need a token refresh story, but that is an existing limitation of the JWT scheme generally (not new to MCP), so it is not addressed differently here.
|
||
|
||
### Transport security (Host allow-list)
|
||
|
||
> **Added post-design after code review.** This was missed in the original design and would have made the feature 100% non-functional in the target deployment.
|
||
|
||
The MCP SDK enables DNS-rebinding protection automatically whenever the transport's bind host is a loopback address (its `host` parameter defaults to `127.0.0.1`), and then hard-codes the allow-list to `127.0.0.1:*`, `localhost:*`, `[::1]:*`. `TransportSecurityMiddleware` rejects any request whose `Host` header is not on that list with **HTTP 421**, *before* `MCPAuthMiddleware` or the tool runs. A client pointed at `http://6.86.80.9:8000/mcp/` sends `Host: 6.86.80.9:8000` and is therefore refused every time.
|
||
|
||
The module resolves this by passing an explicit `TransportSecuritySettings` built from a new setting, `MCP_ALLOWED_HOSTS` (comma-separated, `:*` suffix matches any port, documented in `.env.example`):
|
||
|
||
- Default `127.0.0.1:*,localhost:*,[::1]:*` — safe for local development.
|
||
- Deployments must add their real address, e.g. `MCP_ALLOWED_HOSTS=6.86.80.9:*,127.0.0.1:*,localhost:*`.
|
||
- The literal value `*` disables the protection entirely. This is deliberately an explicit, log-warned opt-out rather than the default, since binding to `0.0.0.0` to sidestep the check would silently switch DNS-rebinding protection off.
|
||
- `allowed_origins` reuses the existing `CORS_ALLOW_ORIGINS` list, so trusted browser origins are declared in exactly one place. Non-browser MCP clients send no `Origin` header, which the SDK treats as allowed.
|
||
|
||
### Tool input bounds
|
||
|
||
`search_regulations` declares `query` as 1–2000 characters and `top_k` as 1–20 via `Annotated[..., Field(...)]`, matching `AskRequest` in `app/api/models/agent.py`. This is load-bearing rather than cosmetic: `KnowledgeRetrievalService.retrieve()` amplifies the value (`candidate_k = max(top_k * 4, 20)`) when reranking is active, so an unbounded `top_k` is a cheap resource-exhaustion vector — and an LLM client hallucinating a large value is the likelier trigger than an attacker. Declaring the bounds via `Annotated` also publishes them in the advertised JSON schema, so well-behaved clients never send an out-of-range value at all.
|
||
|
||
---
|
||
|
||
## Error Handling
|
||
|
||
- **Auth failure** (missing/expired/invalid token, when `auth_enabled=True`): HTTP 401 from `MCPAuthMiddleware`, before the MCP protocol layer is invoked at all. The response carries `WWW-Authenticate: Bearer`, matching the `get_current_user` dependency and RFC 7235.
|
||
- **Malformed `Authorization` header bytes**: ASGI header values are latin-1, so the middleware decodes as latin-1; a non-UTF-8 byte yields a normal 401 rather than an unhandled `UnicodeDecodeError`/500.
|
||
- **Rejected `Host` header**: HTTP 421 from the SDK's transport-security middleware (see above), before auth.
|
||
- **Tool execution failure** (e.g. the underlying retrieval/LLM call raises): FastMCP's own tool-call error handling catches exceptions raised inside `@mcp.tool()`-decorated functions and returns them as a normal MCP tool-error result to the calling client — no special handling needed in `search_regulations` itself, consistent with how `/agent/ask`'s REST handler already lets the global FastAPI exception handler in `main.py` catch unexpected errors.
|
||
- **Lifespan startup failure** (e.g. `mcp_app`'s session manager fails to start): surfaces the same way any other `lifespan()` failure does today — the app fails to start, visible immediately in logs, not a silent partial-degradation.
|
||
|
||
## Testing
|
||
|
||
- `backend/tests/mcp/test_search_regulations_tool.py` — unit test for the tool function with a mocked `AgentConversationService` (same mocking style as existing `application/agent` tests): asserts `search_regulations()` calls `.ask(query=..., top_k=...)` with no `session_id`, shapes the returned dict correctly (`answer`, `sources`), and that the advertised JSON schema carries the `query`/`top_k` bounds.
|
||
- `backend/tests/mcp/test_mcp_auth_middleware.py` — unit test for `MCPAuthMiddleware`: no token → 401; invalid/expired token → 401; valid token → request passed through to the wrapped app; `auth_enabled=False` → always passed through; 401 carries `WWW-Authenticate`; non-UTF-8 header bytes → 401 not 500. Uses Starlette's `TestClient` against a minimal dummy inner ASGI app, no real MCP protocol handshake needed.
|
||
- `backend/tests/mcp/test_mcp_transport_security.py` — exercises the **real** MCP app over `TestClient`: a configured remote `Host` completes a real JSON-RPC `initialize` handshake; an unconfigured `Host` is refused with 421; `*` disables protection; the comma-separated setting parses correctly. These run the app's lifespan via `with TestClient(...)`, without which the SDK's session-manager task group is uninitialized.
|
||
- `backend/tests/conftest.py` — mocks `psycopg2` at import time for the whole suite. Individual test modules cannot do this reliably, because whether a module runs before the one that needs the mock depends on alphabetical collection order; `conftest.py` is imported before any test module in the tree.
|
||
- **Manual end-to-end verification** (not automated): use the official `mcp` Python client (`mcp.client.streamable_http.streamable_http_client` + `mcp.ClientSession`) to connect to a locally running instance, call `list_tools()`, then call `search_regulations` with a real query, confirming a real answer + sources come back. This is a one-time manual check, not a CI test.
|
||
|
||
## Dependencies
|
||
|
||
- Add `mcp` (official Model Context Protocol Python SDK, provides `mcp.server.fastmcp.FastMCP`) to `backend/requirements.txt`. No existing dependency implements the MCP protocol (JSON-RPC 2.0 framing + Streamable HTTP transport + capability negotiation); hand-rolling this would be substantially more code and more fragile than the official SDK.
|
||
|
||
## Out of Scope (deferred to future iterations)
|
||
|
||
- Direction B: consuming external MCP servers from this platform's own Agentic RAG pipeline.
|
||
- Additional MCP tools (perception event queries, compliance checks).
|
||
- Per-role tool restrictions.
|
||
- Token refresh / long-lived credential story for external MCP clients beyond the existing JWT expiry behavior.
|
||
- Rate limiting on the `/mcp` endpoint.
|