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 uuid
import asyncio
from typing import List, Optional
from pathlib import Path
from contextlib import AsyncExitStack
from fastapi import APIRouter, HTTPException, Depends
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.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:
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])
def list_mcp_servers(project_id: Optional[int] = None):
async def list_mcp_servers(project_id: Optional[int] = None):
servers = read_mcp_servers()
if project_id is not None:
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
@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 { useProjectStore } from "@/store/projectStore";
import { useDashboardStore } from "@/store/dashboardStore";
import { useMcpHealthStore } from "@/store/mcpHealthStore";
import { api } from "@/lib/api";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
@@ -406,12 +407,21 @@ function SidebarBody() {
const queryParams = new URLSearchParams(location.search);
const activeSessionKey = queryParams.get("session") || "api:default";
const { hasMcpError, startPolling, stopPolling } = useMcpHealthStore();
useEffect(() => {
if (currentProject) {
loadDashboards(currentProject.id);
}
}, [currentProject, loadDashboards]);
useEffect(() => {
startPolling(currentProject?.id ?? null);
return () => {
stopPolling();
};
}, [currentProject?.id, startPolling, stopPolling]);
const fetchSessions = async () => {
try {
const url = currentProject
@@ -898,11 +908,14 @@ function SidebarBody() {
</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")}
>
<Wand2 className="h-4 w-4" />
{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>
</div>
+53 -13
View File
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from "@/components/ui/button";
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 { Label } from "@/components/ui/label";
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 { api } from "@/lib/api";
import { useProjectStore } from "@/store/projectStore";
import { useMcpHealthStore } from "@/store/mcpHealthStore";
import { useRef } from 'react';
interface Skill {
@@ -69,8 +70,10 @@ export function Skills() {
const [mcpArgsStr, setMcpArgsStr] = useState('');
const [mcpEnvStr, setMcpEnvStr] = useState('');
const [mcpHeadersStr, setMcpHeadersStr] = useState('');
const [isRefreshingMcpHealth, setIsRefreshingMcpHealth] = useState(false);
const { currentProject } = useProjectStore();
const { hasMcpError, refresh: refreshMcpHealth } = useMcpHealthStore();
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@@ -101,13 +104,14 @@ export function Skills() {
};
if (currentProject) {
void refreshMcpHealth(currentProject.id);
if (activeTab === 'skills') {
fetchSkills();
void fetchSkills();
} else {
fetchMcpServers();
void fetchMcpServers();
}
}
}, [currentProject, activeTab]);
}, [currentProject?.id, activeTab, refreshMcpHealth]);
const fetchSkills = async () => {
if (!currentProject) return;
@@ -128,6 +132,7 @@ export function Skills() {
try {
const data = await api.get<MCPServer[]>(`/api/v1/mcp?project_id=${currentProject.id}`);
setMcpServers(data);
void refreshMcpHealth(currentProject.id);
} catch (error) {
console.error("Failed to fetch MCP servers", error);
} 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 file = event.target.files?.[0];
if (!file || !currentProject) return;
@@ -300,10 +318,13 @@ export function Skills() {
{t('skills')}
</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')}
>
{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>
</div>
{activeTab === 'skills' ? (
@@ -323,12 +344,27 @@ export function Skills() {
</Button>
</>
) : (
<Button
className="h-9 bg-[#ff4d29] hover:bg-[#ff4d29]/90 text-white gap-2 rounded-md px-3"
onClick={() => setIsMcpDialogOpen(true)}
>
<Plus className="h-4 w-4" />{t('addMcpServer')}
</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
className="h-9 bg-[#ff4d29] hover:bg-[#ff4d29]/90 text-white gap-2 rounded-md px-3"
onClick={() => setIsMcpDialogOpen(true)}
>
<Plus className="h-4 w-4" />{t('addMcpServer')}
</Button>
</>
)}
</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 ${
mcp.status === 'connected'
? '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'
}`}>
}`}
title={mcp.status}
>
{mcp.status === 'connected' ? (
<ShieldCheck className="h-3 w-3" />
) : (
<AlertCircle className="h-3 w-3" />
)}
{mcp.status}
<span className="truncate max-w-[150px]">{mcp.status}</span>
</div>
</TableCell>
<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;
}
},
}));