diff --git a/.env.example b/.env.example index 6765915..69721fd 100644 --- a/.env.example +++ b/.env.example @@ -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=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= + diff --git a/backend/app/api/routes/status.py b/backend/app/api/routes/status.py index 7c379e8..00e760b 100644 --- a/backend/app/api/routes/status.py +++ b/backend/app/api/routes/status.py @@ -4,10 +4,11 @@ import asyncio import time from typing import Any -from fastapi import APIRouter +from fastapi import APIRouter, Request from app.config.settings import settings 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.shared.bootstrap import ( get_bm25_retriever, @@ -280,3 +281,17 @@ async def ping_model_connections(): ] await asyncio.gather(*tasks, return_exceptions=True) 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) diff --git a/backend/app/config/settings.py b/backend/app/config/settings.py index 51c7112..dec2344 100644 --- a/backend/app/config/settings.py +++ b/backend/app/config/settings.py @@ -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 def get_settings() -> Settings: """Return settings.""" diff --git a/backend/app/mcp/server.py b/backend/app/mcp/server.py index 6f40b7a..8c2a135 100644 --- a/backend/app/mcp/server.py +++ b/backend/app/mcp/server.py @@ -9,6 +9,7 @@ dict. No new retrieval, ranking, or LLM orchestration logic lives here. from __future__ import annotations import logging +import time from typing import Annotated from mcp.server import MCPServer @@ -18,6 +19,7 @@ from starlette.responses import PlainTextResponse from starlette.types import ASGIApp, Receive, Scope, Send 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 logger = logging.getLogger(__name__) @@ -50,7 +52,24 @@ def search_regulations( # No session_id is passed: this keeps each call stateless (no # ConversationStore reads/writes), matching "search" semantics rather # 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 { "answer": result.answer, "sources": [source.__dict__ for source in result.sources], @@ -99,6 +118,13 @@ class MCPAuthMiddleware: 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: """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 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: # Explicit, logged opt-out. Kept as an escape hatch for environments # 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) 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) + ], + } diff --git a/backend/app/mcp/stats.py b/backend/app/mcp/stats.py new file mode 100644 index 0000000..73cfdcb --- /dev/null +++ b/backend/app/mcp/stats.py @@ -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() diff --git a/backend/tests/mcp/test_mcp_stats.py b/backend/tests/mcp/test_mcp_stats.py new file mode 100644 index 0000000..42a8e07 --- /dev/null +++ b/backend/tests/mcp/test_mcp_stats.py @@ -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() diff --git a/backend/tests/mcp/test_mcp_status.py b/backend/tests/mcp/test_mcp_status.py new file mode 100644 index 0000000..9d95fb1 --- /dev/null +++ b/backend/tests/mcp/test_mcp_status.py @@ -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") diff --git a/docs/architecture/backend-project-architecture.md b/docs/architecture/backend-project-architecture.md index f527287..1bef908 100644 --- a/docs/architecture/backend-project-architecture.md +++ b/docs/architecture/backend-project-architecture.md @@ -322,17 +322,20 @@ backend/app/ - 以 Model Context Protocol 对外暴露平台已有能力 - MCP tool 注册与入参 schema 绑定 - MCP 专用鉴权(复用现有 JWT)与 Streamable HTTP 子 ASGI 应用装配 +- MCP 传输自身的可观测性:进程内 per-tool 调用计数(`stats.py`),以及供 System Status 页面读取的状态汇总 `get_mcp_status()` 非职责: - 不实现任何新的检索、问答或业务编排逻辑 - 不直接访问 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。 - 它独立成顶层模块而不是放进 `api/routes/`,因为 MCP 使用装饰器式 tool 注册和自带的子 ASGI 应用,与 `APIRouter` 是不同的传输机制。 - 当前实现见 `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 diff --git a/docs/superpowers/plans/2026-08-03-mcp-status-panel.md b/docs/superpowers/plans/2026-08-03-mcp-status-panel.md new file mode 100644 index 0000000..47c1623 --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-mcp-status-panel.md @@ -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`. diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index de97334..ce14912 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -333,4 +333,22 @@ export interface ModelUsageResponse { 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 }; diff --git a/frontend/src/api/status.ts b/frontend/src/api/status.ts index dda28e7..04fa8a2 100644 --- a/frontend/src/api/status.ts +++ b/frontend/src/api/status.ts @@ -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 { return fetchAPI('/status/stats'); @@ -22,4 +22,9 @@ export async function pingModelConnections(): Promise { return fetchAPI('/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 { + return fetchAPI('/status/mcp'); +} + +export type { MCPStatusResponse, ModelUsageResponse, SystemConfig, SystemHealth, SystemStats }; diff --git a/frontend/src/locales/en.ts b/frontend/src/locales/en.ts index 50cba4a..974e471 100644 --- a/frontend/src/locales/en.ts +++ b/frontend/src/locales/en.ts @@ -143,6 +143,19 @@ export interface Translations { modelStatusDisabled: string; sharesUsageWithMain: 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: { topbarTitle: string; @@ -415,6 +428,19 @@ export const en: Translations = { modelStatusDisabled: 'Disabled', sharesUsageWithMain: 'Shares usage with main LLM', 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: { topbarTitle: 'Document Management', diff --git a/frontend/src/locales/zh.ts b/frontend/src/locales/zh.ts index 68e9532..d94d7ce 100644 --- a/frontend/src/locales/zh.ts +++ b/frontend/src/locales/zh.ts @@ -144,6 +144,19 @@ export const zh: Translations = { modelStatusDisabled: '已禁用', sharesUsageWithMain: '与主 LLM 共用统计', lastCalledNever: '从未', + cardMcp: 'MCP 服务', + mcpEndpoint: '接入端点', + mcpAuthRequired: '需鉴权', + mcpAuthDisabled: '未鉴权', + mcpAllowedHosts: 'Host 白名单', + mcpCopyConfig: '复制接入配置', + mcpCopied: '已复制', + mcpCopyFailed: '复制失败', + mcpCalls: '调用', + mcpErrors: '失败', + mcpAvgDuration: '平均', + mcpNoTools: '未注册任何 MCP 工具', + mcpUnavailable: 'MCP 状态接口不可用', }, docs: { topbarTitle: '文档管理', diff --git a/frontend/src/pages/Status/StatusPage.tsx b/frontend/src/pages/Status/StatusPage.tsx index 2c1c92d..74073b5 100644 --- a/frontend/src/pages/Status/StatusPage.tsx +++ b/frontend/src/pages/Status/StatusPage.tsx @@ -1,10 +1,10 @@ import { useState, useEffect } from 'react'; 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 { useLanguage } from '../../contexts/LanguageContext'; -import { getModelUsage, pingModelConnections } from '../../api/status'; -import type { ModelUsageEntry } from '../../api/index'; +import { getMCPStatus, getModelUsage, pingModelConnections } from '../../api/status'; +import type { MCPStatusResponse, ModelUsageEntry } from '../../api/index'; const TOKEN_KEY = 'auth_token'; function authHeader(): Record { @@ -90,11 +90,15 @@ export function StatusPage() { const [lastRefresh, setLastRefresh] = useState(null); const [modelUsage, setModelUsage] = useState(null); const [pinging, setPinging] = useState(false); + const [mcp, setMcp] = useState(null); + const [mcpLoading, setMcpLoading] = useState(true); + const [copyState, setCopyState] = useState<'idle' | 'ok' | 'fail'>('idle'); useEffect(() => { setLoading(true); setHealthLoading(true); setModelsLoading(true); + setMcpLoading(true); // Fetch all endpoints in parallel. The first three use raw fetch() (legacy // 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/config', { headers: authHeader() }).then(r => r.json()), getModelUsage(), - ]).then(([statsRes, healthRes, configRes, modelsRes]) => { + getMCPStatus(), + ]).then(([statsRes, healthRes, configRes, modelsRes, mcpRes]) => { if (statsRes.status === 'fulfilled') setStats(statsRes.value); 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 (modelsRes.status === 'fulfilled') setModelUsage(modelsRes.value.models); 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); setHealthLoading(false); setModelsLoading(false); + setMcpLoading(false); setLastRefresh(new Date()); }); }, [refreshKey]); @@ -140,7 +148,7 @@ export function StatusPage() { // ── Export ─────────────────────────────────────────────────────────────── 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 url = URL.createObjectURL(blob); 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' }); } + /** 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 = { 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 (
+ {/* MCP server — endpoint config + advertised tools + call counters */} +
+
+ {t.status.cardMcp} + +
+ + {mcpLoading ? ( +
+ {[1, 2].map(i =>
)} +
+ ) : mcp ? ( + <> +
+ + {t.status.mcpEndpoint} + + {mcp.endpoint_url} + + + {mcp.auth_required ? t.status.mcpAuthRequired : t.status.mcpAuthDisabled} + +
+ +
+ + {t.status.mcpAllowedHosts} + + {mcp.allowed_hosts.join(', ') || '—'} + +
+ + {mcp.tools.length === 0 ? ( +
{t.status.mcpNoTools}
+ ) : mcp.tools.map(tool => ( +
+ 0 ? 'warn' : tool.calls > 0 ? 'ok' : 'info'} /> + {tool.name} + + {`${t.status.mcpCalls} ${tool.calls}`} + {tool.errors > 0 && ` · ${t.status.mcpErrors} ${tool.errors}`} + {` · ${t.status.mcpAvgDuration} ${mcpDurationLabel(tool.avg_duration_ms)}`} + + + {tool.last_called_at + ? new Date(tool.last_called_at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) + : t.status.lastCalledNever} + +
+ ))} + + ) : ( +
{t.status.mcpUnavailable}
+ )} +
+ {/* System config (collapsible) */}