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:
@@ -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
|
||||
Reference in New Issue
Block a user