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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude AI
2026-03-19 04:14:52 +00:00
parent 7bf55dc4a9
commit 24467a9090
3 changed files with 92 additions and 42 deletions

View File

@@ -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

View File

@@ -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; // 状态未知,不计入
// 强制刷新组件状态
const refresh = () => setUpdateTrigger(prev => prev + 1);
if (config.authType === 'none') {
// 无需登录,只要健康就算已连接
return status.healthy ? count + 1 : count;
} else {
// 需要登录,健康 + 已登录才算已连接
return (status.healthy && status.loggedIn) ? count + 1 : count;
}
}, 0);
// 处理子组件报告的连接状态变化
// 使用 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>

View File

@@ -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 (