|
|
@ -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 代替 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("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; |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|