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>
80 lines
3.1 KiB
Python
80 lines
3.1 KiB
Python
"""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")
|