diff --git a/src/main/java/com/wok/supportbot/config/McpClientManager.java b/src/main/java/com/wok/supportbot/config/McpClientManager.java index d002933..f9c3cff 100644 --- a/src/main/java/com/wok/supportbot/config/McpClientManager.java +++ b/src/main/java/com/wok/supportbot/config/McpClientManager.java @@ -177,9 +177,7 @@ public class McpClientManager { return null; } - // 缓存未命中,使用 computeIfAbsent 保证同一 key 只创建一次客户端 - // ConcurrentHashMap.computeIfAbsent 对同一 key 加锁,避免并发线程重复创建 - // 注意:mapping function 不能返回 null(会抛 NPE),因此不可用的情况记录到 unavailableConfigs + // 缓存未命中,从 DB 读取配置后创建客户端(创建逻辑见下方 double-check + putIfAbsent) McpServerConfig config = mcpServerConfigService.getConfigById(configId); if (config == null) { log.warn("MCP 配置不存在: id={}", configId); @@ -192,8 +190,29 @@ public class McpClientManager { return null; } - // 配置存在且启用,通过 computeIfAbsent 原子创建(防止并发重复创建) - return clientCache.computeIfAbsent(key, k -> createClientDirectly(config)); + // 配置存在且启用,创建并缓存客户端。 + // 手动 double-check + putIfAbsent 代替 computeIfAbsent:mapping 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("description", config.getDescription()); - // 从 MCP Client 获取真实的工具列表 + // 从 MCP Client 获取真实的工具列表(失败时自动重连并重试一次,应对 MCP 服务端重启) List> toolList = new ArrayList<>(); - try { - McpSyncClient client = entry.getValue(); - McpSchema.ListToolsResult listToolsResult = client.listTools(); + McpSyncClient client = entry.getValue(); + McpSchema.ListToolsResult listToolsResult = listToolsWithRecovery(configId, client); + if (listToolsResult != null) { // 调试日志:输出 MCP 原始返回结果 log.info("🔧 [DEBUG] MCP listTools 原始结果 - configId={}, server={}, rawJson={}", 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()); for (McpSchema.Tool tool : listToolsResult.tools()) { Map toolMeta = new LinkedHashMap<>(); @@ -260,9 +279,10 @@ public class McpClientManager { if (toolList.isEmpty()) { 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); @@ -272,6 +292,36 @@ public class McpClientManager { 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 分钟执行一次 * 遍历所有缓存的客户端,尝试 listTools() 探测连通性,记录状态到 healthCache @@ -329,6 +379,12 @@ public class McpClientManager { HealthStatus status = HealthStatus.offline(latencyMs, e.getMessage()); healthCache.put(key, status); 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; } }