feat: add MCP server module exposing search_regulations tool
- New backend/app/mcp/ module: MCPServer instance with a single search_regulations tool backed by the existing AgentConversationService. - MCPAuthMiddleware reuses existing JWT auth (no new auth mechanism). - Mounted at /mcp/ in api/main.py via Streamable HTTP transport; wired the MCP session manager into the existing lifespan() via AsyncExitStack (app.mount() does not propagate nested ASGI lifespans automatically). - Fixed a doubled /mcp/mcp path by setting streamable_http_path to "/" (MCPServer.streamable_http_app() defaults to registering its own /mcp route). - Verified end-to-end with the real mcp Python client: list_tools() returns search_regulations, auth correctly 401s without or with an invalid token. - 7 new tests, 76 total (up from 69), all passing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+23
-8
@@ -1,6 +1,6 @@
|
|||||||
"""FastAPI application entrypoint."""
|
"""FastAPI application entrypoint."""
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import AsyncExitStack, asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.encoders import jsonable_encoder
|
from fastapi.encoders import jsonable_encoder
|
||||||
@@ -13,6 +13,7 @@ from app.api.models import ErrorResponse
|
|||||||
from app.api.routes import api_router
|
from app.api.routes import api_router
|
||||||
from app.config.logging import setup_logging
|
from app.config.logging import setup_logging
|
||||||
from app.config.settings import settings
|
from app.config.settings import settings
|
||||||
|
from app.mcp.server import build_mcp_asgi_app
|
||||||
from app.shared.bootstrap import cleanup_runtime_dependencies, preload_runtime_dependencies
|
from app.shared.bootstrap import cleanup_runtime_dependencies, preload_runtime_dependencies
|
||||||
from app.shared.errors import VectorStoreSchemaError
|
from app.shared.errors import VectorStoreSchemaError
|
||||||
# Keep module behavior explicit so the backend flow stays easy to audit.
|
# Keep module behavior explicit so the backend flow stays easy to audit.
|
||||||
@@ -20,19 +21,32 @@ from app.shared.errors import VectorStoreSchemaError
|
|||||||
|
|
||||||
setup_logging(level="INFO" if not settings.debug else "DEBUG")
|
setup_logging(level="INFO" if not settings.debug else "DEBUG")
|
||||||
|
|
||||||
|
# Built once at module scope so both lifespan() and app.mount() below reference
|
||||||
|
# the same instance — mounting a second, separately-built instance would start
|
||||||
|
# a second, unrelated MCP session manager.
|
||||||
|
mcp_app = build_mcp_asgi_app()
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Application lifecycle hooks."""
|
"""Application lifecycle hooks."""
|
||||||
logger.info(f"启动 {settings.app_name} v{settings.app_version}")
|
# FastMCP-style servers own a session manager that only starts via its own
|
||||||
logger.info(f"调试模式: {settings.debug}")
|
# lifespan context. app.mount() does NOT propagate nested ASGI lifespans
|
||||||
logger.info("预加载LLM客户端...")
|
# automatically (confirmed Starlette/ASGI limitation) — without this,
|
||||||
preload_runtime_dependencies()
|
# every search_regulations call would fail because the MCP session
|
||||||
|
# manager was never started.
|
||||||
|
async with AsyncExitStack() as stack:
|
||||||
|
await stack.enter_async_context(mcp_app.router.lifespan_context(mcp_app))
|
||||||
|
|
||||||
yield
|
logger.info(f"启动 {settings.app_name} v{settings.app_version}")
|
||||||
|
logger.info(f"调试模式: {settings.debug}")
|
||||||
|
logger.info("预加载LLM客户端...")
|
||||||
|
preload_runtime_dependencies()
|
||||||
|
|
||||||
logger.info("应用关闭,执行清理...")
|
yield
|
||||||
cleanup_runtime_dependencies()
|
|
||||||
|
logger.info("应用关闭,执行清理...")
|
||||||
|
cleanup_runtime_dependencies()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -65,6 +79,7 @@ app.add_middleware(
|
|||||||
app.add_middleware(AuditMiddleware)
|
app.add_middleware(AuditMiddleware)
|
||||||
|
|
||||||
app.include_router(api_router, prefix="/api/v1")
|
app.include_router(api_router, prefix="/api/v1")
|
||||||
|
app.mount("/mcp", mcp_app)
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(VectorStoreSchemaError)
|
@app.exception_handler(VectorStoreSchemaError)
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""MCP (Model Context Protocol) server module.
|
||||||
|
|
||||||
|
Exposes selected read-only platform capabilities — currently only regulation
|
||||||
|
search — as MCP tools so external MCP clients (Claude Desktop, GitHub Copilot,
|
||||||
|
Cursor, etc.) can query this platform's compliance knowledge base directly.
|
||||||
|
"""
|
||||||
|
# Kept deliberately empty beyond this docstring — see server.py for the
|
||||||
|
# actual FastMCP instance and tool/middleware definitions.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""MCPServer instance exposing the compliance knowledge base as an MCP tool.
|
||||||
|
|
||||||
|
This module is a pure protocol adapter: search_regulations() below calls the
|
||||||
|
existing AgentConversationService.ask() (the same application service backing
|
||||||
|
the /api/v1/agent/ask REST endpoint) and reshapes its result into a plain
|
||||||
|
dict. No new retrieval, ranking, or LLM orchestration logic lives here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from mcp.server import MCPServer
|
||||||
|
from starlette.responses import PlainTextResponse
|
||||||
|
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||||
|
|
||||||
|
from app.config.settings import settings
|
||||||
|
from app.shared.bootstrap import get_agent_conversation_service, get_jwt_handler
|
||||||
|
|
||||||
|
# Single shared MCPServer instance — analogous to the single shared FastAPI
|
||||||
|
# `app` instance in app/api/main.py. Tools registered via @mcp.tool() below.
|
||||||
|
# Note: the installed mcp SDK (2.0.0) renamed the older "FastMCP" class to
|
||||||
|
# "MCPServer" (mcp.server.mcpserver.MCPServer); the .tool()/.streamable_http_app()
|
||||||
|
# API surface used here is unchanged across that rename.
|
||||||
|
mcp = MCPServer("ai-regulations")
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def search_regulations(query: str, top_k: int = 5) -> dict:
|
||||||
|
"""Search the compliance knowledge base and return a grounded answer.
|
||||||
|
|
||||||
|
query: Natural-language search question, e.g. "国六排放标准最新要求".
|
||||||
|
top_k: Maximum number of cited sources to return (default 5).
|
||||||
|
"""
|
||||||
|
# No session_id is passed: this keeps each call stateless (no
|
||||||
|
# ConversationStore reads/writes), matching "search" semantics rather
|
||||||
|
# than multi-turn chat semantics.
|
||||||
|
_, result = get_agent_conversation_service().ask(query=query, top_k=top_k)
|
||||||
|
return {
|
||||||
|
"answer": result.answer,
|
||||||
|
"sources": [source.__dict__ for source in result.sources],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MCPAuthMiddleware:
|
||||||
|
"""Reject unauthenticated requests before they reach the MCP protocol handler.
|
||||||
|
|
||||||
|
Mirrors the existing get_current_user dependency's behavior (auth.py) but
|
||||||
|
implemented as raw ASGI middleware, since the mounted MCP app is a plain
|
||||||
|
ASGI app, not a FastAPI/APIRouter instance that supports Depends().
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app: ASGIApp) -> None:
|
||||||
|
"""Store the wrapped ASGI app to delegate to once auth passes."""
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
"""Validate the bearer token for HTTP requests; pass non-HTTP scopes through."""
|
||||||
|
# Only HTTP requests carry an Authorization header to check; lifespan
|
||||||
|
# and other scope types must always pass through untouched.
|
||||||
|
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:
|
||||||
|
# Reject before the MCP session/protocol layer ever sees the request.
|
||||||
|
response = PlainTextResponse(str(exc), status_code=401)
|
||||||
|
await response(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
|
||||||
|
|
||||||
|
def build_mcp_asgi_app() -> ASGIApp:
|
||||||
|
"""Return the Streamable HTTP ASGI app for the MCP server, auth-guarded.
|
||||||
|
|
||||||
|
streamable_http_path="/" is required here: MCPServer.streamable_http_app()
|
||||||
|
registers its own internal route at "/mcp" by default, and this app is
|
||||||
|
itself mounted at "/mcp" in api/main.py — without overriding the internal
|
||||||
|
path to "/", the effective external path would be the confusing "/mcp/mcp"
|
||||||
|
instead of "/mcp".
|
||||||
|
"""
|
||||||
|
asgi_app = mcp.streamable_http_app(streamable_http_path="/")
|
||||||
|
asgi_app.add_middleware(MCPAuthMiddleware)
|
||||||
|
return asgi_app
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
fastapi>=0.110.0
|
fastapi>=0.110.0
|
||||||
uvicorn[standard]>=0.27.0
|
uvicorn[standard]>=0.27.0
|
||||||
python-multipart>=0.0.9
|
python-multipart>=0.0.9
|
||||||
|
# MCP server module (backend/app/mcp/) — pin >=2.0.0: that release renamed the
|
||||||
|
# older "FastMCP" class to "MCPServer" (mcp.server.MCPServer), which is the
|
||||||
|
# class actually used in app/mcp/server.py.
|
||||||
|
mcp>=2.0.0
|
||||||
|
|
||||||
# ── Config & utilities ────────────────────────────────────────────────────────
|
# ── Config & utilities ────────────────────────────────────────────────────────
|
||||||
pydantic>=2.0.0
|
pydantic>=2.0.0
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Test package for the MCP module (backend/app/mcp/)."""
|
||||||
|
# Empty package marker — no shared fixtures needed yet for this small test suite.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Unit tests for MCPAuthMiddleware.
|
||||||
|
|
||||||
|
Wraps a minimal dummy ASGI app (not the real MCP app) so these tests exercise
|
||||||
|
only the auth gate, not the MCP protocol itself — keeps the test fast and
|
||||||
|
independent of FastMCP internals.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
# app.mcp.server imports app.shared.bootstrap at module scope (needed for
|
||||||
|
# get_agent_conversation_service/get_jwt_handler), which in turn eagerly
|
||||||
|
# imports several Postgres store modules that do `import psycopg2` at their
|
||||||
|
# own module scope. Since this is the only test file in backend/tests/mcp/
|
||||||
|
# that imports app.mcp.server at module scope (not inside a test function),
|
||||||
|
# it is the first thing to trigger that chain during pytest collection —
|
||||||
|
# guard psycopg2 here the same way backend/tests/observability/
|
||||||
|
# test_model_usage_bootstrap.py and test_model_usage_persistence.py already
|
||||||
|
# do, so a real (network-connecting) psycopg2 never gets bound first.
|
||||||
|
mock_psycopg2 = MagicMock()
|
||||||
|
mock_psycopg2.extras = MagicMock()
|
||||||
|
sys.modules.setdefault("psycopg2", mock_psycopg2)
|
||||||
|
sys.modules.setdefault("psycopg2.extras", mock_psycopg2.extras)
|
||||||
|
sys.modules.setdefault("psycopg2.pool", MagicMock())
|
||||||
|
|
||||||
|
from starlette.applications import Starlette
|
||||||
|
from starlette.responses import PlainTextResponse
|
||||||
|
from starlette.routing import Route
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from app.mcp.server import MCPAuthMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
def _dummy_app() -> Starlette:
|
||||||
|
"""Build a minimal Starlette app that MCPAuthMiddleware can wrap."""
|
||||||
|
async def _ok(request):
|
||||||
|
"""Return a fixed 200 response so tests can assert pass-through."""
|
||||||
|
return PlainTextResponse("ok")
|
||||||
|
|
||||||
|
app = Starlette(routes=[Route("/ping", _ok)])
|
||||||
|
app.add_middleware(MCPAuthMiddleware)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_token_rejected_when_auth_enabled():
|
||||||
|
"""No Authorization header + auth_enabled=True -> 401."""
|
||||||
|
with patch("app.mcp.server.settings") as fake_settings:
|
||||||
|
fake_settings.auth_enabled = True
|
||||||
|
client = TestClient(_dummy_app())
|
||||||
|
response = client.get("/ping")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_token_rejected_when_auth_enabled():
|
||||||
|
"""A token that fails decode_token() -> 401, request never reaches the app."""
|
||||||
|
fake_handler = type("H", (), {"decode_token": lambda self, t: (_ for _ in ()).throw(ValueError("bad token"))})()
|
||||||
|
with patch("app.mcp.server.settings") as fake_settings, \
|
||||||
|
patch("app.mcp.server.get_jwt_handler", return_value=fake_handler):
|
||||||
|
fake_settings.auth_enabled = True
|
||||||
|
client = TestClient(_dummy_app())
|
||||||
|
response = client.get("/ping", headers={"Authorization": "Bearer garbage"})
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_token_passes_through_when_auth_enabled():
|
||||||
|
"""A token that decodes successfully -> request reaches the wrapped app."""
|
||||||
|
fake_handler = type("H", (), {"decode_token": lambda self, t: object()})()
|
||||||
|
with patch("app.mcp.server.settings") as fake_settings, \
|
||||||
|
patch("app.mcp.server.get_jwt_handler", return_value=fake_handler):
|
||||||
|
fake_settings.auth_enabled = True
|
||||||
|
client = TestClient(_dummy_app())
|
||||||
|
response = client.get("/ping", headers={"Authorization": "Bearer good"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.text == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_disabled_always_passes_through():
|
||||||
|
"""auth_enabled=False (dev mode) -> no token needed, matches get_current_user's dev bypass."""
|
||||||
|
with patch("app.mcp.server.settings") as fake_settings:
|
||||||
|
fake_settings.auth_enabled = False
|
||||||
|
client = TestClient(_dummy_app())
|
||||||
|
response = client.get("/ping")
|
||||||
|
assert response.status_code == 200
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Unit tests for the search_regulations MCP tool function.
|
||||||
|
|
||||||
|
Mocks AgentConversationService so no real retrieval/LLM call happens —
|
||||||
|
verifies only the protocol-adapter contract: correct call shape in,
|
||||||
|
correct dict shape out.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _FakeSource:
|
||||||
|
"""Minimal stand-in for a real Source dataclass (only __dict__ is used)."""
|
||||||
|
|
||||||
|
doc_id: str
|
||||||
|
doc_title: str
|
||||||
|
score: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _FakeAnswerResult:
|
||||||
|
"""Minimal stand-in for AnswerResult — only .answer/.sources are read."""
|
||||||
|
|
||||||
|
answer: str
|
||||||
|
sources: list
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_regulations_calls_agent_ask_without_session():
|
||||||
|
"""search_regulations must call ask() with no session_id (stateless search)."""
|
||||||
|
from app.mcp.server import search_regulations
|
||||||
|
|
||||||
|
fake_service = MagicMock()
|
||||||
|
fake_service.ask.return_value = (
|
||||||
|
None,
|
||||||
|
_FakeAnswerResult(answer="国六排放标准要求...", sources=[_FakeSource("doc-1", "国六标准", 0.9)]),
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("app.mcp.server.get_agent_conversation_service", return_value=fake_service):
|
||||||
|
search_regulations(query="国六排放标准最新要求", top_k=3)
|
||||||
|
|
||||||
|
fake_service.ask.assert_called_once_with(query="国六排放标准最新要求", top_k=3)
|
||||||
|
assert "session_id" not in fake_service.ask.call_args.kwargs
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_regulations_shapes_response_dict():
|
||||||
|
"""The returned dict must expose 'answer' and 'sources' (list of plain dicts)."""
|
||||||
|
from app.mcp.server import search_regulations
|
||||||
|
|
||||||
|
fake_service = MagicMock()
|
||||||
|
fake_service.ask.return_value = (
|
||||||
|
None,
|
||||||
|
_FakeAnswerResult(answer="答案文本", sources=[_FakeSource("doc-2", "国标GB1589", 0.8)]),
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("app.mcp.server.get_agent_conversation_service", return_value=fake_service):
|
||||||
|
result = search_regulations(query="q")
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"answer": "答案文本",
|
||||||
|
"sources": [{"doc_id": "doc-2", "doc_title": "国标GB1589", "score": 0.8}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_regulations_default_top_k():
|
||||||
|
"""top_k defaults to 5 when the caller omits it."""
|
||||||
|
from app.mcp.server import search_regulations
|
||||||
|
|
||||||
|
fake_service = MagicMock()
|
||||||
|
fake_service.ask.return_value = (None, _FakeAnswerResult(answer="a", sources=[]))
|
||||||
|
|
||||||
|
with patch("app.mcp.server.get_agent_conversation_service", return_value=fake_service):
|
||||||
|
search_regulations(query="q")
|
||||||
|
|
||||||
|
assert fake_service.ask.call_args.kwargs["top_k"] == 5
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# MCP Regulation Search Server — Implementation Plan
|
# MCP Regulation Search Server — Implementation Plan
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
|
||||||
|
|
||||||
**Goal:** Expose the existing compliance knowledge base as an MCP tool (`search_regulations`) via a standalone `backend/app/mcp/` module, mounted into the existing FastAPI backend over Streamable HTTP, reusing existing JWT auth and the existing `AgentConversationService`.
|
**Goal:** Expose the existing compliance knowledge base as an MCP tool (`search_regulations`) via a standalone `backend/app/mcp/` module, mounted into the existing FastAPI backend over Streamable HTTP, reusing existing JWT auth and the existing `AgentConversationService`.
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
## Global Constraints
|
## Global Constraints
|
||||||
|
|
||||||
- Design source of truth: `docs/superpowers/specs/2026-07-29-mcp-search-regulations-design.md`.
|
- Design source of truth: `docs/superpowers/specs/2026-07-29-mcp-search-regulations-design.md`.
|
||||||
|
- **Correction discovered during implementation:** the `mcp` package's latest release is `2.0.0`, which renamed `FastMCP` to `MCPServer` (`mcp.server.MCPServer`) and its client helper `streamablehttp_client` to `streamable_http_client`. `mcp>=2.0.0` is pinned in `requirements.txt` (not `>=1.9.0` as originally estimated below) since the shipped code uses the `MCPServer` name. Also discovered: `MCPServer.streamable_http_app()` defaults to registering its route at `/mcp`, requiring `streamable_http_path="/"` to avoid a doubled `/mcp/mcp` when mounted at `/mcp`; the effective client URL is `/mcp/` (trailing slash, due to Starlette's `Mount` redirect behavior).
|
||||||
- All comments and docstrings in `backend/**/*.py` must be in English; every function/method needs a docstring; every file (including `__init__.py`) needs a module docstring + at least one meaningful `#` comment (`AGENTS.md`).
|
- All comments and docstrings in `backend/**/*.py` must be in English; every function/method needs a docstring; every file (including `__init__.py`) needs a module docstring + at least one meaningful `#` comment (`AGENTS.md`).
|
||||||
- No new business orchestration — `search_regulations` is a thin protocol adapter over the existing `AgentConversationService`, same tier as `app/api/routes/agent.py`.
|
- No new business orchestration — `search_regulations` is a thin protocol adapter over the existing `AgentConversationService`, same tier as `app/api/routes/agent.py`.
|
||||||
- Python interpreter for this repo checkout: `C:\software\Python312\python.exe` (no `.venv` present in this checkout; this is the interpreter with all project dependencies already installed and is what the previous session's work was verified against).
|
- Python interpreter for this repo checkout: `C:\software\Python312\python.exe` (no `.venv` present in this checkout; this is the interpreter with all project dependencies already installed and is what the previous session's work was verified against).
|
||||||
@@ -32,7 +33,7 @@
|
|||||||
- Consumes: `app.shared.bootstrap.get_agent_conversation_service()` (existing, returns `AgentConversationService`).
|
- Consumes: `app.shared.bootstrap.get_agent_conversation_service()` (existing, returns `AgentConversationService`).
|
||||||
- Produces: `app.mcp.server.search_regulations(query: str, top_k: int = 5) -> dict` — a plain function (before the `@mcp.tool()` decorator is applied, it remains directly callable/testable; the decorator only adds MCP schema metadata, it does not change the function's Python call signature or return value).
|
- Produces: `app.mcp.server.search_regulations(query: str, top_k: int = 5) -> dict` — a plain function (before the `@mcp.tool()` decorator is applied, it remains directly callable/testable; the decorator only adds MCP schema metadata, it does not change the function's Python call signature or return value).
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test**
|
- [x] **Step 1: Write the failing test**
|
||||||
|
|
||||||
Create `backend/tests/mcp/__init__.py`:
|
Create `backend/tests/mcp/__init__.py`:
|
||||||
|
|
||||||
@@ -129,7 +130,7 @@ Run it — confirm it fails on import (`app.mcp.server` does not exist yet):
|
|||||||
C:\software\Python312\python.exe -m pytest backend/tests/mcp/test_search_regulations_tool.py -v
|
C:\software\Python312\python.exe -m pytest backend/tests/mcp/test_search_regulations_tool.py -v
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: Implement the tool**
|
- [x] **Step 2: Implement the tool**
|
||||||
|
|
||||||
Create `backend/app/mcp/__init__.py`:
|
Create `backend/app/mcp/__init__.py`:
|
||||||
|
|
||||||
@@ -183,7 +184,7 @@ def search_regulations(query: str, top_k: int = 5) -> dict:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: Run the test — confirm it passes**
|
- [x] **Step 3: Run the test — confirm it passes**
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
C:\software\Python312\python.exe -m pytest backend/tests/mcp/test_search_regulations_tool.py -v
|
C:\software\Python312\python.exe -m pytest backend/tests/mcp/test_search_regulations_tool.py -v
|
||||||
@@ -203,7 +204,7 @@ Expected: 3 passed. Note: this step imports `mcp.server.fastmcp`, which is not y
|
|||||||
- Consumes: `app.config.settings.settings.auth_enabled` (existing), `app.shared.bootstrap.get_jwt_handler()` (existing, returns `JWTHandler`).
|
- Consumes: `app.config.settings.settings.auth_enabled` (existing), `app.shared.bootstrap.get_jwt_handler()` (existing, returns `JWTHandler`).
|
||||||
- Produces: `app.mcp.server.MCPAuthMiddleware` (ASGI middleware class), `app.mcp.server.build_mcp_asgi_app() -> ASGIApp` (returns `mcp.streamable_http_app()` with the middleware already attached). Task 3's `main.py` change consumes `build_mcp_asgi_app()` directly — it does not need to attach the middleware itself.
|
- Produces: `app.mcp.server.MCPAuthMiddleware` (ASGI middleware class), `app.mcp.server.build_mcp_asgi_app() -> ASGIApp` (returns `mcp.streamable_http_app()` with the middleware already attached). Task 3's `main.py` change consumes `build_mcp_asgi_app()` directly — it does not need to attach the middleware itself.
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test**
|
- [x] **Step 1: Write the failing test**
|
||||||
|
|
||||||
Create `backend/tests/mcp/test_mcp_auth_middleware.py`:
|
Create `backend/tests/mcp/test_mcp_auth_middleware.py`:
|
||||||
|
|
||||||
@@ -285,7 +286,7 @@ Run it — confirm it fails (`MCPAuthMiddleware` does not exist yet):
|
|||||||
C:\software\Python312\python.exe -m pytest backend/tests/mcp/test_mcp_auth_middleware.py -v
|
C:\software\Python312\python.exe -m pytest backend/tests/mcp/test_mcp_auth_middleware.py -v
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: Implement the middleware and ASGI app builder**
|
- [x] **Step 2: Implement the middleware and ASGI app builder**
|
||||||
|
|
||||||
Append to `backend/app/mcp/server.py`:
|
Append to `backend/app/mcp/server.py`:
|
||||||
|
|
||||||
@@ -338,7 +339,7 @@ def build_mcp_asgi_app() -> ASGIApp:
|
|||||||
return asgi_app
|
return asgi_app
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: Run the test — confirm it passes**
|
- [x] **Step 3: Run the test — confirm it passes**
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
C:\software\Python312\python.exe -m pytest backend/tests/mcp/test_mcp_auth_middleware.py -v
|
C:\software\Python312\python.exe -m pytest backend/tests/mcp/test_mcp_auth_middleware.py -v
|
||||||
@@ -358,7 +359,7 @@ Expected: 4 passed.
|
|||||||
- Consumes: `app.mcp.server.build_mcp_asgi_app()` (from Task 2).
|
- Consumes: `app.mcp.server.build_mcp_asgi_app()` (from Task 2).
|
||||||
- Produces: a running `/mcp` Streamable HTTP endpoint on the existing FastAPI app/port — no new port, process, or deployment step.
|
- Produces: a running `/mcp` Streamable HTTP endpoint on the existing FastAPI app/port — no new port, process, or deployment step.
|
||||||
|
|
||||||
- [ ] **Step 1: Add the dependency**
|
- [x] **Step 1: Add the dependency**
|
||||||
|
|
||||||
In `backend/requirements.txt`, add to the "Web framework" section (or a new small section — either is fine, keep it near `fastapi`/`uvicorn` since it is another transport-layer concern):
|
In `backend/requirements.txt`, add to the "Web framework" section (or a new small section — either is fine, keep it near `fastapi`/`uvicorn` since it is another transport-layer concern):
|
||||||
|
|
||||||
@@ -372,7 +373,7 @@ Install it (already done ad hoc in Task 1 to unblock those tests — this step j
|
|||||||
C:\software\Python312\python.exe -m pip install -r backend/requirements.txt
|
C:\software\Python312\python.exe -m pip install -r backend/requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: Mount the MCP app and fix the lifespan gap**
|
- [x] **Step 2: Mount the MCP app and fix the lifespan gap**
|
||||||
|
|
||||||
In `backend/app/api/main.py`, add the import and build the ASGI app at module scope (before `lifespan()` is defined, since `lifespan()` needs to reference it):
|
In `backend/app/api/main.py`, add the import and build the ASGI app at module scope (before `lifespan()` is defined, since `lifespan()` needs to reference it):
|
||||||
|
|
||||||
@@ -424,7 +425,7 @@ app.include_router(api_router, prefix="/api/v1")
|
|||||||
app.mount("/mcp", mcp_app)
|
app.mount("/mcp", mcp_app)
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: Run the full backend test suite**
|
- [x] **Step 3: Run the full backend test suite**
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
C:\software\Python312\python.exe -m pytest backend/tests -q
|
C:\software\Python312\python.exe -m pytest backend/tests -q
|
||||||
@@ -432,7 +433,7 @@ C:\software\Python312\python.exe -m pytest backend/tests -q
|
|||||||
|
|
||||||
Expected: `76 passed` (69 existing + 3 + 4 new from Tasks 1–2). No regressions.
|
Expected: `76 passed` (69 existing + 3 + 4 new from Tasks 1–2). No regressions.
|
||||||
|
|
||||||
- [ ] **Step 4: Manual end-to-end verification (not automated)**
|
- [x] **Step 4: Manual end-to-end verification (not automated)**
|
||||||
|
|
||||||
Start the backend the normal way (`dev.bat start api --foreground` or the documented `uvicorn` command) and, from a separate shell, run:
|
Start the backend the normal way (`dev.bat start api --foreground` or the documented `uvicorn` command) and, from a separate shell, run:
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@
|
|||||||
|
|
||||||
## Architecture
|
## 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
|
### Module layout
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -146,7 +148,7 @@ class MCPAuthMiddleware:
|
|||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"ai-regulations": {
|
"ai-regulations": {
|
||||||
"url": "http://6.86.80.9:8000/mcp",
|
"url": "http://6.86.80.9:8000/mcp/",
|
||||||
"headers": { "Authorization": "Bearer <jwt>" }
|
"headers": { "Authorization": "Bearer <jwt>" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user