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
+100
View File
@@ -0,0 +1,100 @@
"""Tests for the in-memory MCP per-tool statistics tracker.
These pin the two properties the status panel depends on: counters stay exact
under the concurrent thread dispatch the mcp SDK uses, and recording never
raises into a live tool call.
"""
from __future__ import annotations
import threading
from app.mcp.stats import MCPStatsTracker, MCPToolStats, get_mcp_stats_tracker
def test_avg_duration_is_none_before_any_call():
"""A never-called tool reports None, not 0.0, so the UI can distinguish them."""
assert MCPToolStats().avg_duration_ms is None
def test_avg_duration_is_the_mean_of_recorded_durations():
"""Average is computed over all calls, successful or not."""
tracker = MCPStatsTracker()
for duration in (100.0, 200.0, 300.0):
tracker.record(tool="search_regulations", duration_ms=duration, success=True)
stats = tracker.snapshot()["search_regulations"]
assert stats.calls == 3
assert stats.avg_duration_ms == 200.0
def test_failures_increment_both_calls_and_errors():
"""errors is a subset of calls, so the UI can show "2 of 3 failed" honestly."""
tracker = MCPStatsTracker()
tracker.record(tool="t", duration_ms=1.0, success=True)
tracker.record(tool="t", duration_ms=1.0, success=False)
tracker.record(tool="t", duration_ms=1.0, success=False)
stats = tracker.snapshot()["t"]
assert stats.calls == 3
assert stats.errors == 2
def test_last_called_at_is_set_and_timezone_aware():
"""The panel renders this as a local time, which requires an aware datetime."""
tracker = MCPStatsTracker()
tracker.record(tool="t", duration_ms=1.0, success=True)
last_called = tracker.snapshot()["t"].last_called_at
assert last_called is not None
assert last_called.tzinfo is not None
def test_concurrent_record_calls_are_not_lost():
"""8 threads x 100 calls must total exactly 800.
Without the lock this loses increments non-deterministically. The mcp SDK
runs synchronous tool bodies via anyio.to_thread.run_sync, so this is the
real dispatch model, not a hypothetical.
"""
tracker = MCPStatsTracker()
def hammer() -> None:
for _ in range(100):
tracker.record(tool="search_regulations", duration_ms=1.0, success=True)
threads = [threading.Thread(target=hammer) for _ in range(8)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
stats = tracker.snapshot()["search_regulations"]
assert stats.calls == 800
assert stats.total_duration_ms == 800.0
def test_record_swallows_bad_input_instead_of_raising():
"""A malformed duration must not propagate into the caller's tool call."""
tracker = MCPStatsTracker()
tracker.record(tool="t", duration_ms="not-a-number", success=True) # type: ignore[arg-type]
# Coercion happens before the lock is taken, so the entry is never created
# in a half-updated state.
assert tracker.snapshot() == {}
def test_snapshot_is_a_copy_not_the_live_dict():
"""Callers mutating the snapshot must not corrupt the tracker."""
tracker = MCPStatsTracker()
tracker.record(tool="t", duration_ms=1.0, success=True)
snapshot = tracker.snapshot()
snapshot.clear()
assert "t" in tracker.snapshot()
def test_get_mcp_stats_tracker_returns_a_singleton():
"""Instrumentation and the status route must observe the same counters."""
assert get_mcp_stats_tracker() is get_mcp_stats_tracker()
+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")