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:
@@ -176,3 +176,9 @@ CORS_ALLOW_ORIGINS=http://localhost:5173
|
|||||||
# MCP_ALLOWED_HOSTS=6.86.80.9:*,127.0.0.1:*,localhost:*
|
# MCP_ALLOWED_HOSTS=6.86.80.9:*,127.0.0.1:*,localhost:*
|
||||||
MCP_ALLOWED_HOSTS=127.0.0.1:*,localhost:*,[::1]:*
|
MCP_ALLOWED_HOSTS=127.0.0.1:*,localhost:*,[::1]:*
|
||||||
|
|
||||||
|
# 系统状态页展示、以及"复制接入配置"按钮所使用的 MCP 外部访问地址。
|
||||||
|
# 留空则由后端从请求的 Host 头推导;当前端经 Vite 代理(changeOrigin: true)
|
||||||
|
# 或反向代理改写了 Host 时,推导结果会是 127.0.0.1,此时必须显式指定。
|
||||||
|
# MCP_PUBLIC_URL=http://6.86.80.9:8000/mcp/
|
||||||
|
MCP_PUBLIC_URL=
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import asyncio
|
|||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, Request
|
||||||
|
|
||||||
from app.config.settings import settings
|
from app.config.settings import settings
|
||||||
from app.domain.retrieval import RetrievedChunk
|
from app.domain.retrieval import RetrievedChunk
|
||||||
|
from app.mcp.server import get_mcp_status
|
||||||
from app.services.llm.llm_factory import get_llm_client, get_llm_factory
|
from app.services.llm.llm_factory import get_llm_client, get_llm_factory
|
||||||
from app.shared.bootstrap import (
|
from app.shared.bootstrap import (
|
||||||
get_bm25_retriever,
|
get_bm25_retriever,
|
||||||
@@ -280,3 +281,17 @@ async def ping_model_connections():
|
|||||||
]
|
]
|
||||||
await asyncio.gather(*tasks, return_exceptions=True)
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
return {"models": [_build_model_status(role) for role in _MODEL_ROLES]}
|
return {"models": [_build_model_status(role) for role in _MODEL_ROLES]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mcp")
|
||||||
|
async def get_mcp_server_status(request: Request):
|
||||||
|
"""Return MCP endpoint config, advertised tools, and per-tool call counters.
|
||||||
|
|
||||||
|
This route is a thin HTTP adapter: everything MCP-specific is assembled by
|
||||||
|
app.mcp.server.get_mcp_status(). The only thing decided here is the public
|
||||||
|
URL, because only the HTTP layer knows how the client reached us.
|
||||||
|
"""
|
||||||
|
# request.base_url already carries scheme/host/port and a trailing slash;
|
||||||
|
# strip it before appending so the result is ".../mcp/", not ".../mcp//".
|
||||||
|
public_url = settings.mcp_public_url or f"{str(request.base_url).rstrip('/')}/mcp/"
|
||||||
|
return await get_mcp_status(public_url)
|
||||||
|
|||||||
@@ -212,6 +212,18 @@ class Settings(BaseSettings):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Optional override for the URL shown on the System Status page and copied
|
||||||
|
# into client configs. Needed because request.base_url reflects the Host
|
||||||
|
# header, which the Vite dev proxy (changeOrigin: true) and reverse proxies
|
||||||
|
# that do not forward the original Host both rewrite.
|
||||||
|
mcp_public_url: str = Field(
|
||||||
|
default="",
|
||||||
|
description=(
|
||||||
|
"Externally reachable MCP endpoint URL, e.g. http://6.86.80.9:8000/mcp/. "
|
||||||
|
"Leave empty to derive it from the incoming request."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
"""Return settings."""
|
"""Return settings."""
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ dict. No new retrieval, ranking, or LLM orchestration logic lives here.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from mcp.server import MCPServer
|
from mcp.server import MCPServer
|
||||||
@@ -18,6 +19,7 @@ from starlette.responses import PlainTextResponse
|
|||||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||||
|
|
||||||
from app.config.settings import settings
|
from app.config.settings import settings
|
||||||
|
from app.mcp.stats import get_mcp_stats_tracker
|
||||||
from app.shared.bootstrap import get_agent_conversation_service, get_jwt_handler
|
from app.shared.bootstrap import get_agent_conversation_service, get_jwt_handler
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -50,7 +52,24 @@ def search_regulations(
|
|||||||
# No session_id is passed: this keeps each call stateless (no
|
# No session_id is passed: this keeps each call stateless (no
|
||||||
# ConversationStore reads/writes), matching "search" semantics rather
|
# ConversationStore reads/writes), matching "search" semantics rather
|
||||||
# than multi-turn chat semantics.
|
# than multi-turn chat semantics.
|
||||||
_, result = get_agent_conversation_service().ask(query=query, top_k=top_k)
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
_, result = get_agent_conversation_service().ask(query=query, top_k=top_k)
|
||||||
|
except Exception:
|
||||||
|
# Record the failure, then re-raise unchanged so the MCP SDK still
|
||||||
|
# converts it into a protocol-level error for the client. Swallowing
|
||||||
|
# it here would report success to the caller.
|
||||||
|
get_mcp_stats_tracker().record(
|
||||||
|
tool="search_regulations",
|
||||||
|
duration_ms=(time.perf_counter() - started) * 1000,
|
||||||
|
success=False,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
get_mcp_stats_tracker().record(
|
||||||
|
tool="search_regulations",
|
||||||
|
duration_ms=(time.perf_counter() - started) * 1000,
|
||||||
|
success=True,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"answer": result.answer,
|
"answer": result.answer,
|
||||||
"sources": [source.__dict__ for source in result.sources],
|
"sources": [source.__dict__ for source in result.sources],
|
||||||
@@ -99,6 +118,13 @@ class MCPAuthMiddleware:
|
|||||||
await self.app(scope, receive, send)
|
await self.app(scope, receive, send)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_allowed_hosts() -> list[str]:
|
||||||
|
"""Split the configured MCP host allow-list into individual entries."""
|
||||||
|
# Shared by the transport-security builder and the status endpoint so the
|
||||||
|
# panel can never display an allow-list different from the enforced one.
|
||||||
|
return [h.strip() for h in settings.mcp_allowed_hosts.split(",") if h.strip()]
|
||||||
|
|
||||||
|
|
||||||
def _build_transport_security() -> TransportSecuritySettings:
|
def _build_transport_security() -> TransportSecuritySettings:
|
||||||
"""Translate the configured MCP host allow-list into SDK transport settings.
|
"""Translate the configured MCP host allow-list into SDK transport settings.
|
||||||
|
|
||||||
@@ -106,7 +132,7 @@ def _build_transport_security() -> TransportSecuritySettings:
|
|||||||
defaults to 127.0.0.1 and therefore rejects every remote client with HTTP
|
defaults to 127.0.0.1 and therefore rejects every remote client with HTTP
|
||||||
421 — fatal for a remotely deployed backend.
|
421 — fatal for a remotely deployed backend.
|
||||||
"""
|
"""
|
||||||
allowed = [h.strip() for h in settings.mcp_allowed_hosts.split(",") if h.strip()]
|
allowed = _parse_allowed_hosts()
|
||||||
if "*" in allowed:
|
if "*" in allowed:
|
||||||
# Explicit, logged opt-out. Kept as an escape hatch for environments
|
# Explicit, logged opt-out. Kept as an escape hatch for environments
|
||||||
# behind a proxy that rewrites Host unpredictably, but never the default.
|
# behind a proxy that rewrites Host unpredictably, but never the default.
|
||||||
@@ -140,3 +166,36 @@ def build_mcp_asgi_app() -> ASGIApp:
|
|||||||
)
|
)
|
||||||
asgi_app.add_middleware(MCPAuthMiddleware)
|
asgi_app.add_middleware(MCPAuthMiddleware)
|
||||||
return asgi_app
|
return asgi_app
|
||||||
|
|
||||||
|
|
||||||
|
async def get_mcp_status(public_url: str) -> dict:
|
||||||
|
"""Assemble the MCP status payload shown on the System Status page.
|
||||||
|
|
||||||
|
Owned by this module rather than the status route so that MCP internals
|
||||||
|
(the tool registry, the allow-list format, the stats tracker) stay behind
|
||||||
|
one boundary; the route only supplies public_url, which is the one value
|
||||||
|
only the HTTP layer can know.
|
||||||
|
"""
|
||||||
|
stats = get_mcp_stats_tracker().snapshot()
|
||||||
|
# list_tools() reads the in-memory registry populated by @mcp.tool() at
|
||||||
|
# import time, so the panel always reflects what is actually advertised
|
||||||
|
# rather than a hand-maintained duplicate list.
|
||||||
|
tools = await mcp.list_tools()
|
||||||
|
return {
|
||||||
|
"endpoint_url": public_url,
|
||||||
|
"auth_required": settings.auth_enabled,
|
||||||
|
"allowed_hosts": _parse_allowed_hosts(),
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": tool.name,
|
||||||
|
"description": (tool.description or "").strip().split("\n")[0],
|
||||||
|
"calls": entry.calls if entry else 0,
|
||||||
|
"errors": entry.errors if entry else 0,
|
||||||
|
"avg_duration_ms": entry.avg_duration_ms if entry else None,
|
||||||
|
"last_called_at": (
|
||||||
|
entry.last_called_at.isoformat() if entry and entry.last_called_at else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for tool, entry in ((tool, stats.get(tool.name)) for tool in tools)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""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()
|
||||||
@@ -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()
|
||||||
@@ -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")
|
||||||
@@ -322,17 +322,20 @@ backend/app/
|
|||||||
- 以 Model Context Protocol 对外暴露平台已有能力
|
- 以 Model Context Protocol 对外暴露平台已有能力
|
||||||
- MCP tool 注册与入参 schema 绑定
|
- MCP tool 注册与入参 schema 绑定
|
||||||
- MCP 专用鉴权(复用现有 JWT)与 Streamable HTTP 子 ASGI 应用装配
|
- MCP 专用鉴权(复用现有 JWT)与 Streamable HTTP 子 ASGI 应用装配
|
||||||
|
- MCP 传输自身的可观测性:进程内 per-tool 调用计数(`stats.py`),以及供 System Status 页面读取的状态汇总 `get_mcp_status()`
|
||||||
|
|
||||||
非职责:
|
非职责:
|
||||||
|
|
||||||
- 不实现任何新的检索、问答或业务编排逻辑
|
- 不实现任何新的检索、问答或业务编排逻辑
|
||||||
- 不直接访问 Milvus、MinIO、LLM SDK
|
- 不直接访问 Milvus、MinIO、LLM SDK
|
||||||
|
- 不统计 token 消耗 —— MCP 调用经由 `AgentConversationService.ask()`,已由 `shared/model_usage_tracker.py` 记账
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
|
|
||||||
- `mcp` 与 `api` 是并列的两个 transport 适配层:`api` 面向 HTTP REST 客户端,`mcp` 面向 MCP 客户端(Claude Desktop、IDE 等)。二者共用同一套 application service。
|
- `mcp` 与 `api` 是并列的两个 transport 适配层:`api` 面向 HTTP REST 客户端,`mcp` 面向 MCP 客户端(Claude Desktop、IDE 等)。二者共用同一套 application service。
|
||||||
- 它独立成顶层模块而不是放进 `api/routes/`,因为 MCP 使用装饰器式 tool 注册和自带的子 ASGI 应用,与 `APIRouter` 是不同的传输机制。
|
- 它独立成顶层模块而不是放进 `api/routes/`,因为 MCP 使用装饰器式 tool 注册和自带的子 ASGI 应用,与 `APIRouter` 是不同的传输机制。
|
||||||
- 当前实现见 `backend/app/mcp/server.py`,只暴露 `search_regulations` 一个 tool,内部直接调用 `get_agent_conversation_service()`。
|
- 当前实现见 `backend/app/mcp/server.py`,只暴露 `search_regulations` 一个 tool,内部直接调用 `get_agent_conversation_service()`。
|
||||||
|
- `api/routes/status.py` 的 `GET /status/mcp` 是薄适配层:它只负责解析对外可达的 URL(`settings.mcp_public_url` 或从请求推导),其余全部交给 `mcp.server.get_mcp_status()`,路由不得直接读取 MCP 内部结构。
|
||||||
|
|
||||||
## 5. Module Responsibilities
|
## 5. Module Responsibilities
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# MCP Status Panel — Implementation Plan
|
||||||
|
|
||||||
|
Spec: `docs/superpowers/specs/2026-08-03-mcp-status-panel-design.md`
|
||||||
|
|
||||||
|
Backend first (tests alongside), then frontend, then verify. Each task is
|
||||||
|
independently reviewable.
|
||||||
|
|
||||||
|
## Task 1 — `app/mcp/stats.py`
|
||||||
|
|
||||||
|
- [x] `MCPToolStats` dataclass: `calls: int = 0`, `errors: int = 0`,
|
||||||
|
`total_duration_ms: float = 0.0`, `last_called_at: datetime | None = None`;
|
||||||
|
`avg_duration_ms` property returning `None` when `calls == 0`.
|
||||||
|
- [x] `MCPStatsTracker` with `threading.Lock`, `record(tool, duration_ms, success)`,
|
||||||
|
`snapshot()` returning a shallow copy.
|
||||||
|
- [x] `record()` wraps its body in `try/except Exception` → `logger.warning`, never raises.
|
||||||
|
- [x] `get_mcp_stats_tracker()` with `@lru_cache`.
|
||||||
|
- [x] Module docstring + at least one `#` comment (AGENTS.md).
|
||||||
|
|
||||||
|
## Task 2 — `backend/tests/mcp/test_mcp_stats.py`
|
||||||
|
|
||||||
|
- [x] 8 threads × 100 `record()` calls → `calls == 800` exactly.
|
||||||
|
- [x] `avg_duration_ms` is `None` at zero calls, correct mean afterwards.
|
||||||
|
- [x] `success=False` increments `errors` and `calls`.
|
||||||
|
- [x] `record()` with a non-numeric duration logs and does not raise.
|
||||||
|
|
||||||
|
## Task 3 — instrument `app/mcp/server.py`
|
||||||
|
|
||||||
|
- [x] Wrap `search_regulations` body: `time.perf_counter()` start,
|
||||||
|
`try/except` records `success=False` and re-raises, `finally` not needed
|
||||||
|
once both branches record.
|
||||||
|
- [x] `async def get_mcp_status(public_url: str) -> dict` returning
|
||||||
|
`{endpoint_url, auth_required, allowed_hosts, tools: [...]}` where each
|
||||||
|
tool is `{name, description, calls, errors, avg_duration_ms, last_called_at}`.
|
||||||
|
- [x] `allowed_hosts` parsed from `settings.mcp_allowed_hosts` with the same
|
||||||
|
split/strip logic `_build_transport_security()` already uses.
|
||||||
|
- [x] `last_called_at` serialized as ISO-8601 string or `None`.
|
||||||
|
|
||||||
|
## Task 4 — `mcp_public_url` setting
|
||||||
|
|
||||||
|
- [x] `app/config/settings.py`: `mcp_public_url: str = ""` in the existing `# ── MCP ──` block.
|
||||||
|
- [x] `.env.example`: documented under the existing MCP section, in Chinese,
|
||||||
|
with the `http://6.86.80.9:8000/mcp/` example and a note that it is only
|
||||||
|
needed when a proxy rewrites `Host`.
|
||||||
|
|
||||||
|
## Task 5 — `GET /status/mcp`
|
||||||
|
|
||||||
|
- [x] Add route to `backend/app/api/routes/status.py`, taking `request: Request`.
|
||||||
|
- [x] `public_url = settings.mcp_public_url or f"{str(request.base_url).rstrip('/')}/mcp/"`.
|
||||||
|
- [x] Delegate to `get_mcp_status()`; no MCP internals in the route.
|
||||||
|
|
||||||
|
## Task 6 — `backend/tests/mcp/test_mcp_status.py`
|
||||||
|
|
||||||
|
- [x] Tool advertised with zeroed stats before any call.
|
||||||
|
- [x] Stats reflected after `record()`.
|
||||||
|
- [x] `auth_required` follows a patched `settings.auth_enabled`.
|
||||||
|
- [x] `public_url` passes through unmodified.
|
||||||
|
|
||||||
|
## Task 7 — frontend types + client
|
||||||
|
|
||||||
|
- [x] `frontend/src/api/index.ts`: `MCPToolEntry`, `MCPStatusResponse`.
|
||||||
|
- [x] `frontend/src/api/status.ts`: `getMCPStatus()` + re-export.
|
||||||
|
|
||||||
|
## Task 8 — MCP Server card
|
||||||
|
|
||||||
|
- [x] Add `getMCPStatus()` to the existing `Promise.allSettled` batch in
|
||||||
|
`StatusPage.tsx`, with its own `mcpLoading` state.
|
||||||
|
- [x] Card below "AI Models": endpoint row + one row per tool.
|
||||||
|
- [x] Copy-config button: builds the `mcpServers` JSON, embeds the
|
||||||
|
`localStorage` token when `auth_required`, writes via
|
||||||
|
`navigator.clipboard.writeText`, and reflects success/failure in its label
|
||||||
|
for ~2s.
|
||||||
|
- [x] `handleExport()` includes `mcp`.
|
||||||
|
- [x] Reuse `card` / `card-header` / `service-row` / `StatusIcon`. No new CSS.
|
||||||
|
|
||||||
|
## Task 9 — i18n
|
||||||
|
|
||||||
|
- [x] `locales/zh.ts` and `locales/en.ts`: `cardMcp`, `mcpEndpoint`,
|
||||||
|
`mcpAuthRequired`, `mcpAuthDisabled`, `mcpAllowedHosts`, `mcpCopyConfig`,
|
||||||
|
`mcpCopied`, `mcpCopyFailed`, `mcpCalls`, `mcpErrors`, `mcpAvgDuration`,
|
||||||
|
`mcpNoTools`, `mcpUnavailable`.
|
||||||
|
- [x] Both files must stay structurally identical (`en.ts` is typed against `zh.ts`).
|
||||||
|
|
||||||
|
## Task 10 — verify
|
||||||
|
|
||||||
|
- [x] `python -m pytest backend/tests -q` — all pass.
|
||||||
|
- [x] `npm --prefix frontend run lint`.
|
||||||
|
- [x] `npm --prefix frontend run build`.
|
||||||
|
- [x] Live check: start uvicorn, `GET /api/v1/status/mcp`, confirm the tool is
|
||||||
|
listed and counters move after a real MCP `tools/call`.
|
||||||
@@ -333,4 +333,22 @@ export interface ModelUsageResponse {
|
|||||||
models: ModelUsageEntry[];
|
models: ModelUsageEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One tool advertised by the MCP server, joined with its in-memory call counters. */
|
||||||
|
export interface MCPToolEntry {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
calls: number;
|
||||||
|
errors: number;
|
||||||
|
/** null when the tool has never been called — distinct from an average of 0. */
|
||||||
|
avg_duration_ms: number | null;
|
||||||
|
last_called_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MCPStatusResponse {
|
||||||
|
endpoint_url: string;
|
||||||
|
auth_required: boolean;
|
||||||
|
allowed_hosts: string[];
|
||||||
|
tools: MCPToolEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
export { API_BASE_URL };
|
export { API_BASE_URL };
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { fetchAPI, type ModelUsageResponse, type SystemConfig, type SystemHealth, type SystemStats } from './index';
|
import { fetchAPI, type MCPStatusResponse, type ModelUsageResponse, type SystemConfig, type SystemHealth, type SystemStats } from './index';
|
||||||
|
|
||||||
export async function getSystemStats(): Promise<SystemStats> {
|
export async function getSystemStats(): Promise<SystemStats> {
|
||||||
return fetchAPI<SystemStats>('/status/stats');
|
return fetchAPI<SystemStats>('/status/stats');
|
||||||
@@ -22,4 +22,9 @@ export async function pingModelConnections(): Promise<ModelUsageResponse> {
|
|||||||
return fetchAPI<ModelUsageResponse>('/status/models/ping', { method: 'POST' });
|
return fetchAPI<ModelUsageResponse>('/status/models/ping', { method: 'POST' });
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { ModelUsageResponse, SystemConfig, SystemHealth, SystemStats };
|
/** MCP endpoint config, advertised tools, and per-tool call counters. */
|
||||||
|
export async function getMCPStatus(): Promise<MCPStatusResponse> {
|
||||||
|
return fetchAPI<MCPStatusResponse>('/status/mcp');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { MCPStatusResponse, ModelUsageResponse, SystemConfig, SystemHealth, SystemStats };
|
||||||
|
|||||||
@@ -143,6 +143,19 @@ export interface Translations {
|
|||||||
modelStatusDisabled: string;
|
modelStatusDisabled: string;
|
||||||
sharesUsageWithMain: string;
|
sharesUsageWithMain: string;
|
||||||
lastCalledNever: string;
|
lastCalledNever: string;
|
||||||
|
cardMcp: string;
|
||||||
|
mcpEndpoint: string;
|
||||||
|
mcpAuthRequired: string;
|
||||||
|
mcpAuthDisabled: string;
|
||||||
|
mcpAllowedHosts: string;
|
||||||
|
mcpCopyConfig: string;
|
||||||
|
mcpCopied: string;
|
||||||
|
mcpCopyFailed: string;
|
||||||
|
mcpCalls: string;
|
||||||
|
mcpErrors: string;
|
||||||
|
mcpAvgDuration: string;
|
||||||
|
mcpNoTools: string;
|
||||||
|
mcpUnavailable: string;
|
||||||
};
|
};
|
||||||
docs: {
|
docs: {
|
||||||
topbarTitle: string;
|
topbarTitle: string;
|
||||||
@@ -415,6 +428,19 @@ export const en: Translations = {
|
|||||||
modelStatusDisabled: 'Disabled',
|
modelStatusDisabled: 'Disabled',
|
||||||
sharesUsageWithMain: 'Shares usage with main LLM',
|
sharesUsageWithMain: 'Shares usage with main LLM',
|
||||||
lastCalledNever: 'Never',
|
lastCalledNever: 'Never',
|
||||||
|
cardMcp: 'MCP Server',
|
||||||
|
mcpEndpoint: 'Endpoint',
|
||||||
|
mcpAuthRequired: 'Auth required',
|
||||||
|
mcpAuthDisabled: 'No auth',
|
||||||
|
mcpAllowedHosts: 'Allowed hosts',
|
||||||
|
mcpCopyConfig: 'Copy client config',
|
||||||
|
mcpCopied: 'Copied',
|
||||||
|
mcpCopyFailed: 'Copy failed',
|
||||||
|
mcpCalls: 'calls',
|
||||||
|
mcpErrors: 'errors',
|
||||||
|
mcpAvgDuration: 'avg',
|
||||||
|
mcpNoTools: 'No MCP tools registered',
|
||||||
|
mcpUnavailable: 'MCP status endpoint unavailable',
|
||||||
},
|
},
|
||||||
docs: {
|
docs: {
|
||||||
topbarTitle: 'Document Management',
|
topbarTitle: 'Document Management',
|
||||||
|
|||||||
@@ -144,6 +144,19 @@ export const zh: Translations = {
|
|||||||
modelStatusDisabled: '已禁用',
|
modelStatusDisabled: '已禁用',
|
||||||
sharesUsageWithMain: '与主 LLM 共用统计',
|
sharesUsageWithMain: '与主 LLM 共用统计',
|
||||||
lastCalledNever: '从未',
|
lastCalledNever: '从未',
|
||||||
|
cardMcp: 'MCP 服务',
|
||||||
|
mcpEndpoint: '接入端点',
|
||||||
|
mcpAuthRequired: '需鉴权',
|
||||||
|
mcpAuthDisabled: '未鉴权',
|
||||||
|
mcpAllowedHosts: 'Host 白名单',
|
||||||
|
mcpCopyConfig: '复制接入配置',
|
||||||
|
mcpCopied: '已复制',
|
||||||
|
mcpCopyFailed: '复制失败',
|
||||||
|
mcpCalls: '调用',
|
||||||
|
mcpErrors: '失败',
|
||||||
|
mcpAvgDuration: '平均',
|
||||||
|
mcpNoTools: '未注册任何 MCP 工具',
|
||||||
|
mcpUnavailable: 'MCP 状态接口不可用',
|
||||||
},
|
},
|
||||||
docs: {
|
docs: {
|
||||||
topbarTitle: '文档管理',
|
topbarTitle: '文档管理',
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Topbar } from '../../components/layout/Topbar';
|
import { Topbar } from '../../components/layout/Topbar';
|
||||||
import { Search, Upload, Download, RefreshCw, CheckCircle, XCircle, AlertTriangle, Info } from 'lucide-react';
|
import { Search, Upload, Download, RefreshCw, CheckCircle, XCircle, AlertTriangle, Info, Copy } from 'lucide-react';
|
||||||
import { UploadModal } from '../Docs/UploadModal';
|
import { UploadModal } from '../Docs/UploadModal';
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
import { useLanguage } from '../../contexts/LanguageContext';
|
||||||
import { getModelUsage, pingModelConnections } from '../../api/status';
|
import { getMCPStatus, getModelUsage, pingModelConnections } from '../../api/status';
|
||||||
import type { ModelUsageEntry } from '../../api/index';
|
import type { MCPStatusResponse, ModelUsageEntry } from '../../api/index';
|
||||||
|
|
||||||
const TOKEN_KEY = 'auth_token';
|
const TOKEN_KEY = 'auth_token';
|
||||||
function authHeader(): Record<string, string> {
|
function authHeader(): Record<string, string> {
|
||||||
@@ -90,11 +90,15 @@ export function StatusPage() {
|
|||||||
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
|
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
|
||||||
const [modelUsage, setModelUsage] = useState<ModelUsageEntry[] | null>(null);
|
const [modelUsage, setModelUsage] = useState<ModelUsageEntry[] | null>(null);
|
||||||
const [pinging, setPinging] = useState(false);
|
const [pinging, setPinging] = useState(false);
|
||||||
|
const [mcp, setMcp] = useState<MCPStatusResponse | null>(null);
|
||||||
|
const [mcpLoading, setMcpLoading] = useState(true);
|
||||||
|
const [copyState, setCopyState] = useState<'idle' | 'ok' | 'fail'>('idle');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setHealthLoading(true);
|
setHealthLoading(true);
|
||||||
setModelsLoading(true);
|
setModelsLoading(true);
|
||||||
|
setMcpLoading(true);
|
||||||
|
|
||||||
// Fetch all endpoints in parallel. The first three use raw fetch() (legacy
|
// Fetch all endpoints in parallel. The first three use raw fetch() (legacy
|
||||||
// pattern already established in this file); model usage uses the typed
|
// pattern already established in this file); model usage uses the typed
|
||||||
@@ -104,7 +108,8 @@ export function StatusPage() {
|
|||||||
fetch('/api/v1/status/health', { headers: authHeader() }).then(r => r.json()),
|
fetch('/api/v1/status/health', { headers: authHeader() }).then(r => r.json()),
|
||||||
fetch('/api/v1/status/config', { headers: authHeader() }).then(r => r.json()),
|
fetch('/api/v1/status/config', { headers: authHeader() }).then(r => r.json()),
|
||||||
getModelUsage(),
|
getModelUsage(),
|
||||||
]).then(([statsRes, healthRes, configRes, modelsRes]) => {
|
getMCPStatus(),
|
||||||
|
]).then(([statsRes, healthRes, configRes, modelsRes, mcpRes]) => {
|
||||||
if (statsRes.status === 'fulfilled') setStats(statsRes.value);
|
if (statsRes.status === 'fulfilled') setStats(statsRes.value);
|
||||||
else setStats({ documents_total: 0, documents_indexed: 0, documents_failed: 0, chunks_total: 0 });
|
else setStats({ documents_total: 0, documents_indexed: 0, documents_failed: 0, chunks_total: 0 });
|
||||||
|
|
||||||
@@ -112,10 +117,13 @@ export function StatusPage() {
|
|||||||
if (configRes.status === 'fulfilled') setConfig(configRes.value);
|
if (configRes.status === 'fulfilled') setConfig(configRes.value);
|
||||||
if (modelsRes.status === 'fulfilled') setModelUsage(modelsRes.value.models);
|
if (modelsRes.status === 'fulfilled') setModelUsage(modelsRes.value.models);
|
||||||
else setModelUsage(null);
|
else setModelUsage(null);
|
||||||
|
// A failing MCP endpoint must degrade to a muted card, never blank the page.
|
||||||
|
setMcp(mcpRes.status === 'fulfilled' ? mcpRes.value : null);
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setHealthLoading(false);
|
setHealthLoading(false);
|
||||||
setModelsLoading(false);
|
setModelsLoading(false);
|
||||||
|
setMcpLoading(false);
|
||||||
setLastRefresh(new Date());
|
setLastRefresh(new Date());
|
||||||
});
|
});
|
||||||
}, [refreshKey]);
|
}, [refreshKey]);
|
||||||
@@ -140,7 +148,7 @@ export function StatusPage() {
|
|||||||
|
|
||||||
// ── Export ───────────────────────────────────────────────────────────────
|
// ── Export ───────────────────────────────────────────────────────────────
|
||||||
function handleExport() {
|
function handleExport() {
|
||||||
const data = { stats, health, config, exportedAt: new Date().toISOString() };
|
const data = { stats, health, config, mcp, exportedAt: new Date().toISOString() };
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
@@ -180,6 +188,34 @@ export function StatusPage() {
|
|||||||
return new Date(entry.last_called_at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
return new Date(entry.last_called_at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Build the mcpServers block Claude Desktop / Cursor accept for a Streamable HTTP server. */
|
||||||
|
function buildMCPClientConfig(status: MCPStatusResponse): string {
|
||||||
|
const token = localStorage.getItem(TOKEN_KEY);
|
||||||
|
const server: Record<string, unknown> = { url: status.endpoint_url };
|
||||||
|
// Omit the header entirely when the backend runs unauthenticated, so the
|
||||||
|
// pasted config never carries a stale "Bearer null".
|
||||||
|
if (status.auth_required && token) server.headers = { Authorization: `Bearer ${token}` };
|
||||||
|
return JSON.stringify({ mcpServers: { 'ai-regulations': server } }, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopyMCPConfig() {
|
||||||
|
if (!mcp) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(buildMCPClientConfig(mcp));
|
||||||
|
setCopyState('ok');
|
||||||
|
} catch {
|
||||||
|
// clipboard.writeText rejects on insecure origins and denied permissions.
|
||||||
|
// Surface it: a silent no-op would leave the operator pasting stale data.
|
||||||
|
setCopyState('fail');
|
||||||
|
}
|
||||||
|
setTimeout(() => setCopyState('idle'), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mcpDurationLabel(ms: number | null): string {
|
||||||
|
if (ms === null) return '—';
|
||||||
|
return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms`;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="status-page">
|
<div className="status-page">
|
||||||
<Topbar
|
<Topbar
|
||||||
@@ -344,6 +380,65 @@ export function StatusPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* MCP server — endpoint config + advertised tools + call counters */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<span>{t.status.cardMcp}</span>
|
||||||
|
<button className="btn sm" onClick={handleCopyMCPConfig} disabled={!mcp}>
|
||||||
|
<Copy size={13} />
|
||||||
|
{copyState === 'ok' ? t.status.mcpCopied : copyState === 'fail' ? t.status.mcpCopyFailed : t.status.mcpCopyConfig}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mcpLoading ? (
|
||||||
|
<div style={{ padding: '12px 0', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{[1, 2].map(i => <div key={i} className="loading-shimmer" style={{ height: 28, borderRadius: 6 }} />)}
|
||||||
|
</div>
|
||||||
|
) : mcp ? (
|
||||||
|
<>
|
||||||
|
<div className="service-row">
|
||||||
|
<StatusIcon status="ok" />
|
||||||
|
<span className="service-name" style={{ marginLeft: 8 }}>{t.status.mcpEndpoint}</span>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--muted)', marginLeft: 6, fontFamily: 'var(--font-mono)', wordBreak: 'break-all' }}>
|
||||||
|
{mcp.endpoint_url}
|
||||||
|
</span>
|
||||||
|
<span className={`status ${mcp.auth_required ? 'ok' : 'warn'}`} style={{ marginLeft: 'auto' }}>
|
||||||
|
{mcp.auth_required ? t.status.mcpAuthRequired : t.status.mcpAuthDisabled}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="service-row">
|
||||||
|
<StatusIcon status="info" />
|
||||||
|
<span className="service-name" style={{ marginLeft: 8 }}>{t.status.mcpAllowedHosts}</span>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--muted)', marginLeft: 6, fontFamily: 'var(--font-mono)', wordBreak: 'break-all' }}>
|
||||||
|
{mcp.allowed_hosts.join(', ') || '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mcp.tools.length === 0 ? (
|
||||||
|
<div style={{ padding: '12px 0', color: 'var(--muted)', fontSize: 13 }}>{t.status.mcpNoTools}</div>
|
||||||
|
) : mcp.tools.map(tool => (
|
||||||
|
<div className="service-row" key={tool.name}>
|
||||||
|
<StatusIcon status={tool.errors > 0 ? 'warn' : tool.calls > 0 ? 'ok' : 'info'} />
|
||||||
|
<span className="service-name" style={{ marginLeft: 8, fontFamily: 'var(--font-mono)' }}>{tool.name}</span>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--muted)', marginLeft: 6 }}>
|
||||||
|
{`${t.status.mcpCalls} ${tool.calls}`}
|
||||||
|
{tool.errors > 0 && ` · ${t.status.mcpErrors} ${tool.errors}`}
|
||||||
|
{` · ${t.status.mcpAvgDuration} ${mcpDurationLabel(tool.avg_duration_ms)}`}
|
||||||
|
</span>
|
||||||
|
<span style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--muted)' }}>
|
||||||
|
{tool.last_called_at
|
||||||
|
? new Date(tool.last_called_at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||||
|
: t.status.lastCalledNever}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div style={{ padding: '12px 0', color: 'var(--muted)', fontSize: 13 }}>{t.status.mcpUnavailable}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* System config (collapsible) */}
|
{/* System config (collapsible) */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user