Browse Source

fix(mcp): MCP 客户端服务端重启后自动重连

TDesign-Vue-Next-1.20.6
wanghanlin 1 week ago
parent
commit
a8cb53b171
  1. 80
      src/main/java/com/wok/supportbot/config/McpClientManager.java

80
src/main/java/com/wok/supportbot/config/McpClientManager.java

@ -177,9 +177,7 @@ public class McpClientManager {
return null; return null;
} }
// 缓存未命中使用 computeIfAbsent 保证同一 key 只创建一次客户端
// ConcurrentHashMap.computeIfAbsent 对同一 key 加锁避免并发线程重复创建
// 注意mapping function 不能返回 null会抛 NPE因此不可用的情况记录到 unavailableConfigs
// 缓存未命中 DB 读取配置后创建客户端创建逻辑见下方 double-check + putIfAbsent
McpServerConfig config = mcpServerConfigService.getConfigById(configId); McpServerConfig config = mcpServerConfigService.getConfigById(configId);
if (config == null) { if (config == null) {
log.warn("MCP 配置不存在: id={}", configId); log.warn("MCP 配置不存在: id={}", configId);
@ -192,8 +190,29 @@ public class McpClientManager {
return null; return null;
} }
// 配置存在且启用通过 computeIfAbsent 原子创建防止并发重复创建
return clientCache.computeIfAbsent(key, k -> createClientDirectly(config));
// 配置存在且启用创建并缓存客户端
// 手动 double-check + putIfAbsent 代替 computeIfAbsentmapping function 返回 null
// computeIfAbsent 会抛 NPE导致服务端离线时重连无法优雅降级为返回 null
McpSyncClient existing = clientCache.get(key);
if (existing != null) {
return existing;
}
McpSyncClient created = createClientDirectly(config);
if (created == null) {
unavailableConfigs.add(key);
return null;
}
McpSyncClient prev = clientCache.putIfAbsent(key, created);
if (prev != null) {
// 并发下已有其他线程放入关闭本次多余创建的客户端避免连接泄漏
try {
created.close();
} catch (Exception closeEx) {
log.debug("关闭并发重复创建的 MCP 客户端失败: configId={}, error={}", key, closeEx.getMessage());
}
return prev;
}
return created;
} }
/** /**
@ -233,15 +252,15 @@ public class McpClientManager {
toolInfo.put("transport_type", config.getTransportType()); toolInfo.put("transport_type", config.getTransportType());
toolInfo.put("description", config.getDescription()); toolInfo.put("description", config.getDescription());
// MCP Client 获取真实的工具列表
// MCP Client 获取真实的工具列表失败时自动重连并重试一次应对 MCP 服务端重启
List<Map<String, Object>> toolList = new ArrayList<>(); List<Map<String, Object>> toolList = new ArrayList<>();
try {
McpSyncClient client = entry.getValue(); McpSyncClient client = entry.getValue();
McpSchema.ListToolsResult listToolsResult = client.listTools();
McpSchema.ListToolsResult listToolsResult = listToolsWithRecovery(configId, client);
if (listToolsResult != null) {
// 调试日志输出 MCP 原始返回结果 // 调试日志输出 MCP 原始返回结果
log.info("🔧 [DEBUG] MCP listTools 原始结果 - configId={}, server={}, rawJson={}", log.info("🔧 [DEBUG] MCP listTools 原始结果 - configId={}, server={}, rawJson={}",
configId, config.getName(), ModelOptionsUtils.toJsonString(listToolsResult)); configId, config.getName(), ModelOptionsUtils.toJsonString(listToolsResult));
if (listToolsResult != null && listToolsResult.tools() != null) {
if (listToolsResult.tools() != null) {
log.info(" 📦 MCP Server [{}] 暴露了 {} 个工具", config.getName(), listToolsResult.tools().size()); log.info(" 📦 MCP Server [{}] 暴露了 {} 个工具", config.getName(), listToolsResult.tools().size());
for (McpSchema.Tool tool : listToolsResult.tools()) { for (McpSchema.Tool tool : listToolsResult.tools()) {
Map<String, Object> toolMeta = new LinkedHashMap<>(); Map<String, Object> toolMeta = new LinkedHashMap<>();
@ -260,9 +279,10 @@ public class McpClientManager {
if (toolList.isEmpty()) { if (toolList.isEmpty()) {
log.warn(" ⚠️ MCP Server [{}] 未暴露任何工具,AI 模型将无法调用", config.getName()); log.warn(" ⚠️ MCP Server [{}] 未暴露任何工具,AI 模型将无法调用", config.getName());
} }
} catch (Exception e) {
log.error("获取 MCP 工具列表失败: serverId={}, name={}, error={}",
configId, config.getName(), e.getMessage());
} else {
// 重连后仍失败toolList 保持空前端该 Server 分组将展示为无可选工具
log.error("获取 MCP 工具列表失败(重连后仍失败): serverId={}, name={}",
configId, config.getName());
} }
toolInfo.put("tools", toolList); toolInfo.put("tools", toolList);
@ -272,6 +292,36 @@ public class McpClientManager {
return tools; return tools;
} }
/**
* 调用 listTools 获取工具列表失败时自动重连客户端并重试一次
* MCP 服务端重启后clientCache 中缓存的长连接会失效直接 listTools 会抛异常
* 通过 reconnect 重建连接后重试使工具授权页面与对话能自动恢复无需手动刷新连接
*
* @param configId 配置ID
* @param client 当前缓存的客户端可能已失效
* @return 工具列表结果重连后仍失败返回 null
*/
private McpSchema.ListToolsResult listToolsWithRecovery(Long configId, McpSyncClient client) {
try {
return client.listTools();
} catch (Exception firstEx) {
log.warn("MCP listTools 失败,尝试重连客户端后重试: configId={}, error={}",
configId, firstEx.getMessage());
}
try {
McpSyncClient reconnected = reconnect(configId);
if (reconnected == null) {
log.warn("MCP 客户端重连失败,无法获取工具列表: configId={}", configId);
return null;
}
return reconnected.listTools();
} catch (Exception retryEx) {
log.error("MCP 客户端重连并重试 listTools 失败: configId={}, error={}",
configId, retryEx.getMessage());
return null;
}
}
/** /**
* 定时健康检查 5 分钟执行一次 * 定时健康检查 5 分钟执行一次
* 遍历所有缓存的客户端尝试 listTools() 探测连通性记录状态到 healthCache * 遍历所有缓存的客户端尝试 listTools() 探测连通性记录状态到 healthCache
@ -329,6 +379,12 @@ public class McpClientManager {
HealthStatus status = HealthStatus.offline(latencyMs, e.getMessage()); HealthStatus status = HealthStatus.offline(latencyMs, e.getMessage());
healthCache.put(key, status); healthCache.put(key, status);
log.warn("MCP Server 健康检查失败: configId={}, latencyMs={}, error={}", key, latencyMs, e.getMessage()); log.warn("MCP Server 健康检查失败: configId={}, latencyMs={}, error={}", key, latencyMs, e.getMessage());
// 探测失败说明连接已失效 MCP 服务端重启自动重连以恢复避免缓存长期保留失效客户端
try {
reconnect(Long.parseLong(key));
} catch (Exception reconnectEx) {
log.warn("健康检查自动重连失败: configId={}, error={}", key, reconnectEx.getMessage());
}
return status; return status;
} }
} }

Loading…
Cancel
Save