From 72d25826a1f6ff03acef36271efb0819b01fe4e8 Mon Sep 17 00:00:00 2001 From: qixinbo Date: Sun, 29 Mar 2026 23:30:32 +0800 Subject: [PATCH] feat: add MCP health check --- backend/app/api/mcp.py | 55 ++++++++++++++++++++++- frontend/src/components/Sidebar.tsx | 15 ++++++- frontend/src/pages/Skills.tsx | 66 ++++++++++++++++++++++------ frontend/src/store/mcpHealthStore.ts | 60 +++++++++++++++++++++++++ 4 files changed, 181 insertions(+), 15 deletions(-) create mode 100644 frontend/src/store/mcpHealthStore.ts diff --git a/backend/app/api/mcp.py b/backend/app/api/mcp.py index cec6fd6..bbf0cec 100644 --- a/backend/app/api/mcp.py +++ b/backend/app/api/mcp.py @@ -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) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index ebc8ab6..8ec43b5 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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() { diff --git a/frontend/src/pages/Skills.tsx b/frontend/src/pages/Skills.tsx index b2fa58b..6a221a1 100644 --- a/frontend/src/pages/Skills.tsx +++ b/frontend/src/pages/Skills.tsx @@ -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(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(`/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) => { const file = event.target.files?.[0]; if (!file || !currentProject) return; @@ -300,10 +318,13 @@ export function Skills() { {t('skills')} {activeTab === 'skills' ? ( @@ -323,12 +344,27 @@ export function Skills() { ) : ( - + <> + + + )} @@ -483,14 +519,18 @@ export function Skills() {
+ }`} + title={mcp.status} + > {mcp.status === 'connected' ? ( ) : ( )} - {mcp.status} + {mcp.status}
diff --git a/frontend/src/store/mcpHealthStore.ts b/frontend/src/store/mcpHealthStore.ts new file mode 100644 index 0000000..9cfa7fc --- /dev/null +++ b/frontend/src/store/mcpHealthStore.ts @@ -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; +} + +let pollingTimer: ReturnType | null = null; + +export const useMcpHealthStore = create((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(`/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; + } + }, +}));