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:
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '文档管理',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user