Files
AIRegulation-DocAnalysis/docs/superpowers/specs/2026-07-29-mcp-search-regulations-design.md
T
wangweiandCopilot 49ee50c104 fix: harden MCP endpoint after code review
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>
2026-07-29 17:11:54 +08:00

16 KiB
Raw Blame History

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

# 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

# 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()
# 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.
# 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.:

{
  "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 12000 characters and top_k as 120 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.