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>
92 lines
3.4 KiB
Python
92 lines
3.4 KiB
Python
"""In-memory per-tool call counters for the MCP server.
|
|
|
|
Lives in `app/mcp/` rather than `app/shared/` because these counters are
|
|
meaningful only for the MCP transport: they answer "is anything actually
|
|
calling our MCP endpoint, and does it work?" for the System Status page.
|
|
Token consumption is deliberately not tracked here — MCP tool calls route
|
|
through AgentConversationService.ask() like every other caller, so the
|
|
existing ModelUsageTracker already accounts for it.
|
|
|
|
Counters are process-local and reset on restart. That is an accepted
|
|
tradeoff, recorded in the design spec: nothing billable depends on them.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from functools import lru_cache
|
|
|
|
from loguru import logger
|
|
|
|
|
|
@dataclass
|
|
class MCPToolStats:
|
|
"""Accumulated call outcomes for a single MCP tool."""
|
|
|
|
calls: int = 0
|
|
errors: int = 0
|
|
total_duration_ms: float = 0.0
|
|
last_called_at: datetime | None = None
|
|
|
|
@property
|
|
def avg_duration_ms(self) -> float | None:
|
|
"""Mean call duration, or None when the tool has never been called.
|
|
|
|
Returning None rather than 0.0 keeps "never called" distinguishable
|
|
from "called, but instantaneous" in the status UI.
|
|
"""
|
|
if self.calls == 0:
|
|
return None
|
|
return self.total_duration_ms / self.calls
|
|
|
|
|
|
class MCPStatsTracker:
|
|
"""Thread-safe registry of per-tool MCP call statistics.
|
|
|
|
The lock is load-bearing, not defensive habit: the mcp SDK dispatches
|
|
synchronous tool functions through anyio.to_thread.run_sync, so tool
|
|
bodies genuinely run on multiple worker threads at once — unlike the
|
|
async REST routes, which are serialized by the event loop.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
"""Initialize an empty registry guarded by a single lock."""
|
|
self._tools: dict[str, MCPToolStats] = {}
|
|
# One coarse lock is enough: record() runs once per MCP tool call and
|
|
# snapshot() is only read by the low-traffic status endpoint.
|
|
self._lock = threading.Lock()
|
|
|
|
def record(self, *, tool: str, duration_ms: float, success: bool) -> None:
|
|
"""Record the outcome of one MCP tool invocation.
|
|
|
|
Never raises: a defect in observability code must not turn a working
|
|
tool call into a protocol error for the client.
|
|
"""
|
|
try:
|
|
# Coerce outside the lock so a bad argument cannot abort mid-update
|
|
# and leave calls incremented but duration unaccounted for.
|
|
duration = float(duration_ms)
|
|
now = datetime.now(timezone.utc)
|
|
with self._lock:
|
|
stats = self._tools.setdefault(tool, MCPToolStats())
|
|
stats.calls += 1
|
|
if not success:
|
|
stats.errors += 1
|
|
stats.total_duration_ms += duration
|
|
stats.last_called_at = now
|
|
except Exception as exc: # noqa: BLE001 - tracking must never break a real call
|
|
logger.warning("MCPStatsTracker.record failed for tool {} - {}", tool, exc)
|
|
|
|
def snapshot(self) -> dict[str, MCPToolStats]:
|
|
"""Return a shallow copy of all tracked tools, safe to read outside the lock."""
|
|
with self._lock:
|
|
return dict(self._tools)
|
|
|
|
|
|
@lru_cache
|
|
def get_mcp_stats_tracker() -> MCPStatsTracker:
|
|
"""Return the process-wide singleton tracker (mirrors get_model_usage_tracker())."""
|
|
return MCPStatsTracker()
|