Przeglądaj źródła

feat(mcp): 完善健康检查和连接统计功能

- 使用 HEAD 请求检测 MCP 服务器可达性,解决 405 错误
- 修复连接统计逻辑:Novel Translator 无需登录即可算已连接
- 显示服务器响应延迟(毫秒)
- 修复无限循环更新问题:使用 useCallback 稳定回调引用
- 使用 ref 跟踪上次报告状态,避免重复报告相同值
- 开发环境测试账号快速复制功能

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude AI 5 godzin temu
rodzic
commit
24467a9090

+ 23 - 22
backend/app_fastapi.py

@@ -523,10 +523,11 @@ async def check_mcp_health(mcp_type: str):
     """
     检查 MCP 服务器健康状态
 
-    通过调用 MCP 服务器的 SSE 端点来检查服务器是否在线
+    使用 HEAD 请求检查 MCP 服务器是否在线
     返回健康状态和响应延迟
     """
     import time
+    import urllib.parse
 
     try:
         # 查找 MCP 服务器配置
@@ -545,37 +546,29 @@ async def check_mcp_health(mcp_type: str):
                 content={"status": "error", "message": f"No URL configured for {mcp_type}"}
             )
 
-        # 调用 MCP 服务器的 SSE 端点检查健康状态
+        # 使用 HEAD 请求检查服务器是否在线
         start_time = time.time()
 
         try:
             async with httpx.AsyncClient(timeout=10.0) as http_client:
-                # 尝试访问 MCP 端点
-                response = await http_client.get(
+                # 使用 HEAD 请求检查服务器可达性
+                response = await http_client.head(
                     mcp_url,
-                    headers={"Accept": "text/event-stream"},
                     timeout=10.0
                 )
 
             latency = int((time.time() - start_time) * 1000)
 
-            # SSE 端点返回 200 表示服务器在线
-            if response.status_code == 200:
-                return {
-                    "status": "healthy",
-                    "healthy": True,
-                    "mcp_type": mcp_type,
-                    "latency": latency,
-                    "url": mcp_url
-                }
-            else:
-                return {
-                    "status": "unhealthy",
-                    "healthy": False,
-                    "mcp_type": mcp_type,
-                    "error": f"HTTP {response.status_code}",
-                    "latency": latency
-                }
+            # 只要能收到响应(无论什么状态码),说明服务器在线
+            # MCP SSE 端点可能返回 405 (不允许 HEAD),但服务器仍健康
+            return {
+                "status": "healthy",
+                "healthy": True,
+                "mcp_type": mcp_type,
+                "latency": latency,
+                "url": mcp_url,
+                "http_status": response.status_code
+            }
 
         except httpx.TimeoutException:
             return {
@@ -593,6 +586,14 @@ async def check_mcp_health(mcp_type: str):
                 "error": f"Connection error: {str(e)}",
                 "latency": 0
             }
+        except httpx.ConnectTimeout as e:
+            return {
+                "status": "timeout",
+                "healthy": False,
+                "mcp_type": mcp_type,
+                "error": f"Connection timeout: {str(e)}",
+                "latency": 10000
+            }
 
     except Exception as e:
         import traceback

+ 45 - 13
frontend-v2/app/mcp/page.tsx

@@ -5,27 +5,60 @@
  * - Web UI 是 MCP 测试工具,不需要全局登录
  * - 每个 MCP 有独立的认证状态
  * - 可以同时管理多个 MCP 的登录
+ *
+ * 连接统计逻辑:
+ * - Novel Translator (authType='none'): 健康就算已连接
+ * - User/Admin MCP (authType='jwt'): 健康 + 已登录才算已连接
  */
 'use client';
 
-import { useState, useEffect } from 'react';
+import { useState, useCallback } from 'react';
 import Link from 'next/link';
 import { McpServerCard } from '@/components/McpServerCard';
-import { MCP_SERVERS, mcpTokenManager } from '@/lib/mcp-token-manager';
+import { MCP_SERVERS } from '@/lib/mcp-token-manager';
+
+// 每个服务器的连接状态
+interface ServerConnectionStatus {
+  healthy: boolean;
+  loggedIn: boolean;
+}
 
 export default function McpPage() {
-  const [updateTrigger, setUpdateTrigger] = useState(0);
-  const [loggedInCount, setLoggedInCount] = useState(0); // SSR 安全的初始值
+  const [connectionStatuses, setConnectionStatuses] = useState<Record<string, ServerConnectionStatus>>({});
 
   const totalCount = Object.keys(MCP_SERVERS).length;
 
-  // 页面加载时从 localStorage 读取登录状态
-  useEffect(() => {
-    setLoggedInCount(mcpTokenManager.getLoggedInMcpList().length);
-  }, [updateTrigger]);
+  // 计算已连接数量
+  // - authType='none': 只要健康就算已连接
+  // - authType='jwt': 健康 + 已登录才算已连接
+  const connectedCount = Object.entries(MCP_SERVERS).reduce((count, [mcpType, config]) => {
+    const status = connectionStatuses[mcpType];
+    if (!status) return count; // 状态未知,不计入
+
+    if (config.authType === 'none') {
+      // 无需登录,只要健康就算已连接
+      return status.healthy ? count + 1 : count;
+    } else {
+      // 需要登录,健康 + 已登录才算已连接
+      return (status.healthy && status.loggedIn) ? count + 1 : count;
+    }
+  }, 0);
 
-  // 强制刷新组件状态
-  const refresh = () => setUpdateTrigger(prev => prev + 1);
+  // 处理子组件报告的连接状态变化
+  // 使用 useCallback 避免每次渲染创建新函数引用,防止子组件 useEffect 无限循环
+  const handleConnectionStatusChange = useCallback((mcpType: string, status: ServerConnectionStatus) => {
+    setConnectionStatuses(prev => {
+      // 只在状态真正改变时才更新,避免不必要的重新渲染
+      const current = prev[mcpType];
+      if (current && current.healthy === status.healthy && current.loggedIn === status.loggedIn) {
+        return prev; // 状态未改变,返回原对象
+      }
+      return {
+        ...prev,
+        [mcpType]: status
+      };
+    });
+  }, []); // 空依赖数组,函数引用永远不变
 
   return (
     <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
@@ -62,7 +95,7 @@ export default function McpPage() {
             MCP 服务器管理
           </h2>
           <p className="text-gray-600 dark:text-gray-400">
-            管理和配置您的 MCP 服务器连接。已连接 {loggedInCount}/{totalCount} 个服务器。
+            管理和配置您的 MCP 服务器连接。已连接 {connectedCount}/{totalCount} 个服务器。
           </p>
         </div>
 
@@ -73,8 +106,7 @@ export default function McpPage() {
               key={mcpType}
               mcpType={mcpType}
               config={config}
-              onLoginSuccess={refresh}
-              onLogoutSuccess={refresh}
+              onConnectionStatusChange={handleConnectionStatusChange}
             />
           ))}
         </div>

+ 24 - 7
frontend-v2/components/McpServerCard.tsx

@@ -3,7 +3,7 @@
  */
 'use client';
 
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useRef } from 'react';
 import { MCP_SERVERS, mcpTokenManager, type McpServerConfig } from '@/lib/mcp-token-manager';
 
 /**
@@ -75,11 +75,10 @@ function DevTestAccounts({ mcpType }: { mcpType: string }) {
 interface McpServerCardProps {
   mcpType: string;
   config: McpServerConfig;
-  onLoginSuccess?: () => void;
-  onLogoutSuccess?: () => void;
+  onConnectionStatusChange?: (mcpType: string, status: { healthy: boolean; loggedIn: boolean }) => void;
 }
 
-export function McpServerCard({ mcpType, config, onLoginSuccess, onLogoutSuccess }: McpServerCardProps) {
+export function McpServerCard({ mcpType, config, onConnectionStatusChange }: McpServerCardProps) {
   const [showLoginForm, setShowLoginForm] = useState(false);
   const [showTokenForm, setShowTokenForm] = useState(false);
   const [email, setEmail] = useState('');
@@ -152,6 +151,27 @@ export function McpServerCard({ mcpType, config, onLoginSuccess, onLogoutSuccess
   const remainingTime = mcpTokenManager.getTokenRemainingTime(mcpType);
   const hoursRemaining = Math.floor(remainingTime / (1000 * 60 * 60));
 
+  // 使用 ref 跟踪上次报告的状态,避免重复报告相同状态
+  const lastReportedStatusRef = useRef<{ healthy: boolean | null; loggedIn: boolean }>({
+    healthy: null,
+    loggedIn: false
+  });
+
+  // 向父组件报告连接状态变化
+  useEffect(() => {
+    if (isHealthy !== null) {
+      const lastReported = lastReportedStatusRef.current;
+      // 只在状态真正改变时才报告
+      if (lastReported.healthy !== isHealthy || lastReported.loggedIn !== isLoggedIn) {
+        lastReportedStatusRef.current = { healthy: isHealthy, loggedIn: isLoggedIn };
+        onConnectionStatusChange?.(mcpType, {
+          healthy: isHealthy,
+          loggedIn: isLoggedIn
+        });
+      }
+    }
+  }, [isHealthy, isLoggedIn, mcpType, onConnectionStatusChange]);
+
   // 计算连接状态显示
   const getConnectionStatus = () => {
     if (isHealthy === null) {
@@ -183,7 +203,6 @@ export function McpServerCard({ mcpType, config, onLoginSuccess, onLogoutSuccess
       setPassword('');
       // 强制刷新组件状态
       setUpdateTrigger(prev => prev + 1);
-      onLoginSuccess?.();
     } else {
       setError(result.error || '登录失败');
     }
@@ -195,7 +214,6 @@ export function McpServerCard({ mcpType, config, onLoginSuccess, onLogoutSuccess
     mcpTokenManager.logoutMcp(mcpType);
     // 强制刷新组件状态
     setUpdateTrigger(prev => prev + 1);
-    onLogoutSuccess?.();
   };
 
   const handleSetToken = () => {
@@ -208,7 +226,6 @@ export function McpServerCard({ mcpType, config, onLoginSuccess, onLogoutSuccess
     setManualToken('');
     setManualUsername('');
     setUpdateTrigger(prev => prev + 1);
-    onLoginSuccess?.();
   };
 
   return (