feat: add MCP health check

This commit is contained in:
qixinbo
2026-03-29 23:30:32 +08:00
parent aea97b6342
commit 72d25826a1
4 changed files with 181 additions and 15 deletions
+54 -1
View File
@@ -1,10 +1,15 @@
import json import json
import uuid import uuid
import asyncio
from typing import List, Optional from typing import List, Optional
from pathlib import Path from pathlib import Path
from contextlib import AsyncExitStack
from fastapi import APIRouter, HTTPException, Depends from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel from pydantic import BaseModel
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from mcp.client.sse import sse_client
from app.schemas.mcp import MCPServer, MCPServerCreate, MCPServerUpdate from app.schemas.mcp import MCPServer, MCPServerCreate, MCPServerUpdate
from app.core.data_root import get_data_root from app.core.data_root import get_data_root
@@ -29,11 +34,59 @@ def write_mcp_servers(servers: List[dict]) -> None:
with open(file_path, "w", encoding="utf-8") as f: with open(file_path, "w", encoding="utf-8") as f:
json.dump(servers, f, indent=2, ensure_ascii=False) json.dump(servers, f, indent=2, ensure_ascii=False)
async def _check_single_mcp_health(server: dict) -> str:
try:
async with AsyncExitStack() as stack:
server_type = server.get("type")
if server_type == "stdio":
params = StdioServerParameters(
command=server.get("command", ""),
args=server.get("args", []),
env=server.get("env")
)
read, write = await stack.enter_async_context(stdio_client(params))
elif server_type in ["sse", "streamableHttp"]:
read, write = await stack.enter_async_context(sse_client(server.get("url", "")))
else:
return "error: unsupported type"
session = await stack.enter_async_context(ClientSession(read, write))
await asyncio.wait_for(session.initialize(), timeout=5.0)
return "connected"
except Exception as e:
err_msg = str(e)
if "unhandled errors in a TaskGroup" in err_msg:
return "error: connection refused"
return f"error: {err_msg or 'unknown'}"
@router.get("/mcp", response_model=List[MCPServer]) @router.get("/mcp", response_model=List[MCPServer])
def list_mcp_servers(project_id: Optional[int] = None): async def list_mcp_servers(project_id: Optional[int] = None):
servers = read_mcp_servers() servers = read_mcp_servers()
if project_id is not None: if project_id is not None:
servers = [s for s in servers if s.get("project_id") == project_id] servers = [s for s in servers if s.get("project_id") == project_id]
if not servers:
return []
tasks = [_check_single_mcp_health(s) for s in servers]
statuses = await asyncio.gather(*tasks, return_exceptions=True)
needs_update = False
for server, status in zip(servers, statuses):
new_status = status if isinstance(status, str) else f"error: {str(status)}"
if server.get("status") != new_status:
server["status"] = new_status
needs_update = True
if needs_update:
# Write back to persist the new statuses
all_servers = read_mcp_servers()
for s in all_servers:
for checked_s in servers:
if s.get("id") == checked_s.get("id"):
s["status"] = checked_s["status"]
write_mcp_servers(all_servers)
return servers return servers
@router.post("/mcp", response_model=MCPServer) @router.post("/mcp", response_model=MCPServer)
+14 -1
View File
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
import { useAuthStore } from "@/store/authStore"; import { useAuthStore } from "@/store/authStore";
import { useProjectStore } from "@/store/projectStore"; import { useProjectStore } from "@/store/projectStore";
import { useDashboardStore } from "@/store/dashboardStore"; import { useDashboardStore } from "@/store/dashboardStore";
import { useMcpHealthStore } from "@/store/mcpHealthStore";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
@@ -406,12 +407,21 @@ function SidebarBody() {
const queryParams = new URLSearchParams(location.search); const queryParams = new URLSearchParams(location.search);
const activeSessionKey = queryParams.get("session") || "api:default"; const activeSessionKey = queryParams.get("session") || "api:default";
const { hasMcpError, startPolling, stopPolling } = useMcpHealthStore();
useEffect(() => { useEffect(() => {
if (currentProject) { if (currentProject) {
loadDashboards(currentProject.id); loadDashboards(currentProject.id);
} }
}, [currentProject, loadDashboards]); }, [currentProject, loadDashboards]);
useEffect(() => {
startPolling(currentProject?.id ?? null);
return () => {
stopPolling();
};
}, [currentProject?.id, startPolling, stopPolling]);
const fetchSessions = async () => { const fetchSessions = async () => {
try { try {
const url = currentProject const url = currentProject
@@ -898,11 +908,14 @@ function SidebarBody() {
</button> </button>
<button <button
className="flex items-center gap-1.5 text-sm hover:text-foreground transition-colors px-2 py-1.5 rounded-md hover:bg-muted" className="flex items-center gap-1.5 text-sm hover:text-foreground transition-colors px-2 py-1.5 rounded-md hover:bg-muted relative"
onClick={() => navigate("/skills")} onClick={() => navigate("/skills")}
> >
<Wand2 className="h-4 w-4" /> <Wand2 className="h-4 w-4" />
{t('skillCenter')} {t('skillCenter')}
{hasMcpError && (
<span className="absolute top-1 right-1 w-2 h-2 rounded-full bg-red-500 ring-2 ring-background animate-pulse" title="MCP Server Error" />
)}
</button> </button>
</div> </div>
+47 -7
View File
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Trash2, Terminal, Loader2, FolderOpen, Eye, ShieldCheck, AlertCircle, Wand2, Upload, Plus } from "lucide-react"; import { Trash2, Terminal, Loader2, FolderOpen, Eye, ShieldCheck, AlertCircle, Wand2, Upload, Plus, RefreshCw } from "lucide-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
@@ -10,6 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useProjectStore } from "@/store/projectStore"; import { useProjectStore } from "@/store/projectStore";
import { useMcpHealthStore } from "@/store/mcpHealthStore";
import { useRef } from 'react'; import { useRef } from 'react';
interface Skill { interface Skill {
@@ -69,8 +70,10 @@ export function Skills() {
const [mcpArgsStr, setMcpArgsStr] = useState(''); const [mcpArgsStr, setMcpArgsStr] = useState('');
const [mcpEnvStr, setMcpEnvStr] = useState(''); const [mcpEnvStr, setMcpEnvStr] = useState('');
const [mcpHeadersStr, setMcpHeadersStr] = useState(''); const [mcpHeadersStr, setMcpHeadersStr] = useState('');
const [isRefreshingMcpHealth, setIsRefreshingMcpHealth] = useState(false);
const { currentProject } = useProjectStore(); const { currentProject } = useProjectStore();
const { hasMcpError, refresh: refreshMcpHealth } = useMcpHealthStore();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
@@ -101,13 +104,14 @@ export function Skills() {
}; };
if (currentProject) { if (currentProject) {
void refreshMcpHealth(currentProject.id);
if (activeTab === 'skills') { if (activeTab === 'skills') {
fetchSkills(); void fetchSkills();
} else { } else {
fetchMcpServers(); void fetchMcpServers();
} }
} }
}, [currentProject, activeTab]); }, [currentProject?.id, activeTab, refreshMcpHealth]);
const fetchSkills = async () => { const fetchSkills = async () => {
if (!currentProject) return; if (!currentProject) return;
@@ -128,6 +132,7 @@ export function Skills() {
try { try {
const data = await api.get<MCPServer[]>(`/api/v1/mcp?project_id=${currentProject.id}`); const data = await api.get<MCPServer[]>(`/api/v1/mcp?project_id=${currentProject.id}`);
setMcpServers(data); setMcpServers(data);
void refreshMcpHealth(currentProject.id);
} catch (error) { } catch (error) {
console.error("Failed to fetch MCP servers", error); console.error("Failed to fetch MCP servers", error);
} finally { } finally {
@@ -135,6 +140,19 @@ export function Skills() {
} }
}; };
const handleRefreshMcpHealth = async () => {
if (!currentProject) return;
setIsRefreshingMcpHealth(true);
try {
await refreshMcpHealth(currentProject.id);
if (activeTab === 'mcp') {
await fetchMcpServers();
}
} finally {
setIsRefreshingMcpHealth(false);
}
};
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (!file || !currentProject) return; if (!file || !currentProject) return;
@@ -300,10 +318,13 @@ export function Skills() {
{t('skills')} {t('skills')}
</button> </button>
<button <button
className={`px-3 py-1 text-sm font-medium rounded-md transition-colors ${activeTab === 'mcp' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground/80'}`} className={`relative px-3 py-1 text-sm font-medium rounded-md transition-colors ${activeTab === 'mcp' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground/80'}`}
onClick={() => setActiveTab('mcp')} onClick={() => setActiveTab('mcp')}
> >
{t('mcpConfig')} {t('mcpConfig')}
{hasMcpError && (
<span className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse" title="MCP Server Error" />
)}
</button> </button>
</div> </div>
{activeTab === 'skills' ? ( {activeTab === 'skills' ? (
@@ -323,12 +344,27 @@ export function Skills() {
</Button> </Button>
</> </>
) : ( ) : (
<>
<Button
variant="outline"
className="h-9 gap-2 rounded-md px-3"
onClick={handleRefreshMcpHealth}
disabled={isRefreshingMcpHealth}
>
{isRefreshingMcpHealth ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
{t('refresh')}
</Button>
<Button <Button
className="h-9 bg-[#ff4d29] hover:bg-[#ff4d29]/90 text-white gap-2 rounded-md px-3" className="h-9 bg-[#ff4d29] hover:bg-[#ff4d29]/90 text-white gap-2 rounded-md px-3"
onClick={() => setIsMcpDialogOpen(true)} onClick={() => setIsMcpDialogOpen(true)}
> >
<Plus className="h-4 w-4" />{t('addMcpServer')} <Plus className="h-4 w-4" />{t('addMcpServer')}
</Button> </Button>
</>
)} )}
</div> </div>
</div> </div>
@@ -483,14 +519,18 @@ export function Skills() {
<div className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] md:text-xs font-medium whitespace-nowrap ${ <div className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] md:text-xs font-medium whitespace-nowrap ${
mcp.status === 'connected' mcp.status === 'connected'
? 'bg-green-50 text-green-700 border border-green-100' ? 'bg-green-50 text-green-700 border border-green-100'
: mcp.status.startsWith('error')
? 'bg-red-50 text-red-700 border border-red-100'
: 'bg-muted/50 text-foreground/80 border border-border' : 'bg-muted/50 text-foreground/80 border border-border'
}`}> }`}
title={mcp.status}
>
{mcp.status === 'connected' ? ( {mcp.status === 'connected' ? (
<ShieldCheck className="h-3 w-3" /> <ShieldCheck className="h-3 w-3" />
) : ( ) : (
<AlertCircle className="h-3 w-3" /> <AlertCircle className="h-3 w-3" />
)} )}
{mcp.status} <span className="truncate max-w-[150px]">{mcp.status}</span>
</div> </div>
</TableCell> </TableCell>
<TableCell className="py-4 px-4 text-right"> <TableCell className="py-4 px-4 text-right">
+60
View File
@@ -0,0 +1,60 @@
import { create } from 'zustand';
import { api } from '@/lib/api';
interface MCPServerStatus {
status?: string;
}
interface MCPHealthState {
hasMcpError: boolean;
currentProjectId: number | null;
startPolling: (projectId: number | null) => void;
stopPolling: () => void;
refresh: (projectId?: number | null) => Promise<void>;
}
let pollingTimer: ReturnType<typeof setInterval> | null = null;
export const useMcpHealthStore = create<MCPHealthState>((set, get) => ({
hasMcpError: false,
currentProjectId: null,
refresh: async (projectIdArg?: number | null) => {
const projectId = projectIdArg ?? get().currentProjectId;
if (!projectId) {
set({ hasMcpError: false, currentProjectId: null });
return;
}
try {
const data = await api.get<MCPServerStatus[]>(`/api/v1/mcp?project_id=${projectId}`);
const hasError = data.some((mcp) => Boolean(mcp.status && mcp.status.startsWith('error')));
set({ hasMcpError: hasError, currentProjectId: projectId });
} catch (error) {
console.error('Failed to check MCP health', error);
set({ hasMcpError: true, currentProjectId: projectId });
}
},
startPolling: (projectId: number | null) => {
if (pollingTimer) {
clearInterval(pollingTimer);
pollingTimer = null;
}
if (!projectId) {
set({ hasMcpError: false, currentProjectId: null });
return;
}
set({ currentProjectId: projectId });
void get().refresh(projectId);
pollingTimer = setInterval(() => {
void get().refresh(projectId);
}, 60000);
},
stopPolling: () => {
if (pollingTimer) {
clearInterval(pollingTimer);
pollingTimer = null;
}
},
}));