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
+6
View File
@@ -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=
+16 -1
View File
@@ -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)
+12
View File
@@ -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."""
+61 -2
View File
@@ -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)
],
}
+91
View File
@@ -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()
+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")
@@ -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`.
+18
View File
@@ -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 };
+7 -2
View File
@@ -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 };
+26
View File
@@ -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',
+13
View File
@@ -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: '文档管理',
+100 -5
View File
@@ -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