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
+18
View File
@@ -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 };
+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> {
return fetchAPI<SystemStats>('/status/stats');
@@ -22,4 +22,9 @@ export async function pingModelConnections(): Promise<ModelUsageResponse> {
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;
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',
+13
View File
@@ -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: '文档管理',
+100 -5
View File
@@ -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<string, string> {
@@ -90,11 +90,15 @@ export function StatusPage() {
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
const [modelUsage, setModelUsage] = useState<ModelUsageEntry[] | null>(null);
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(() => {
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<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 (
<div className="status-page">
<Topbar
@@ -344,6 +380,65 @@ export function StatusPage() {
)}
</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) */}
<div className="card">
<button