feat: surface MCP server status in System Status page

Add per-tool in-memory call counters to the MCP module and a
GET /api/v1/status/mcp endpoint that joins them with the live tool
registry and endpoint config, then render it as a new card on the
System Status page with a one-click client-config copy button.

- app/mcp/stats.py: lock-guarded MCPStatsTracker (the mcp SDK runs sync
  tool bodies via anyio.to_thread.run_sync, so this is genuinely
  multi-threaded, unlike the async REST routes)
- app/mcp/server.py: instrument search_regulations, add get_mcp_status()
- app/config/settings.py: optional MCP_PUBLIC_URL override, required
  because the Vite proxy and reverse proxies rewrite the Host header
- StatusPage.tsx: MCP Server card, joins the existing parallel fetch

Counters are process-local by design; token usage is already persisted
by ModelUsageTracker since MCP calls route through ask().

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
wangwei
2026-08-03 11:37:22 +08:00
co-authored by Copilot
parent 73e79a610d
commit 31bbf80aeb
14 changed files with 621 additions and 10 deletions
+79
View File
@@ -0,0 +1,79 @@
"""Tests for get_mcp_status(), the payload behind the System Status MCP card.
Covers the join between the live tool registry and the stats tracker, plus
the two values the route supplies or the settings decide.
"""
from __future__ import annotations
import asyncio
from unittest.mock import patch
from app.mcp.stats import MCPStatsTracker
def _status(tracker: MCPStatsTracker | None = None, **setting_overrides) -> dict:
"""Call get_mcp_status() with an isolated tracker and patched settings.
The real tracker is a process-wide singleton, so tests must inject their
own instance or they leak counters into each other.
"""
from app.mcp.server import get_mcp_status, settings
patched = settings.model_copy(update=setting_overrides)
with (
patch("app.mcp.server.settings", patched),
patch("app.mcp.server.get_mcp_stats_tracker", return_value=tracker or MCPStatsTracker()),
):
return asyncio.run(get_mcp_status("http://6.86.80.9:8000/mcp/"))
def test_public_url_is_passed_through_unmodified():
"""The route owns URL resolution; get_mcp_status() must not rewrite it."""
assert _status()["endpoint_url"] == "http://6.86.80.9:8000/mcp/"
def test_auth_required_follows_settings():
"""The panel's auth badge must reflect live config, not a hard-coded value."""
assert _status(auth_enabled=True)["auth_required"] is True
assert _status(auth_enabled=False)["auth_required"] is False
def test_allowed_hosts_are_split_and_stripped():
"""Displayed allow-list must match the one the transport actually enforces."""
status = _status(mcp_allowed_hosts="6.86.80.9:* , 127.0.0.1:*,")
assert status["allowed_hosts"] == ["6.86.80.9:*", "127.0.0.1:*"]
def test_tools_come_from_the_live_registry_with_zeroed_stats():
"""An advertised but never-called tool reports zeros, not absence."""
tools = {tool["name"]: tool for tool in _status()["tools"]}
assert "search_regulations" in tools
assert tools["search_regulations"]["calls"] == 0
assert tools["search_regulations"]["errors"] == 0
assert tools["search_regulations"]["avg_duration_ms"] is None
assert tools["search_regulations"]["last_called_at"] is None
def test_recorded_stats_are_joined_onto_the_matching_tool():
"""Counters recorded by the instrumented tool must surface on that tool's row."""
tracker = MCPStatsTracker()
tracker.record(tool="search_regulations", duration_ms=120.0, success=True)
tracker.record(tool="search_regulations", duration_ms=80.0, success=False)
tool = next(t for t in _status(tracker)["tools"] if t["name"] == "search_regulations")
assert tool["calls"] == 2
assert tool["errors"] == 1
assert tool["avg_duration_ms"] == 100.0
# Serialized for JSON transport; the frontend parses it with new Date().
assert isinstance(tool["last_called_at"], str)
def test_description_is_the_first_docstring_line():
"""Multi-line tool docstrings must not blow up the card's row height."""
tool = next(t for t in _status()["tools"] if t["name"] == "search_regulations")
assert "\n" not in tool["description"]
assert tool["description"].startswith("Search the compliance knowledge base")