|
@@ -19,7 +19,8 @@ import org.springframework.web.reactive.function.client.WebClientResponseExcepti
|
|
|
import java.time.Duration;
|
|
import java.time.Duration;
|
|
|
import java.util.ArrayList;
|
|
import java.util.ArrayList;
|
|
|
import java.util.List;
|
|
import java.util.List;
|
|
|
-import java.util.concurrent.CopyOnWriteArrayList;
|
|
|
|
|
|
|
+import java.util.Map;
|
|
|
|
|
+import java.util.concurrent.ConcurrentHashMap;
|
|
|
|
|
|
|
|
@Service
|
|
@Service
|
|
|
@Slf4j
|
|
@Slf4j
|
|
@@ -28,9 +29,8 @@ public class McpClientService {
|
|
|
private final CustomMcpProperties properties;
|
|
private final CustomMcpProperties properties;
|
|
|
private final Builder webClientBuilder;
|
|
private final Builder webClientBuilder;
|
|
|
|
|
|
|
|
- //private final ObjectMapper objectMapper;
|
|
|
|
|
- // 1. 使用 CopyOnWriteArrayList 保证读写并发安全
|
|
|
|
|
- private final List<McpAsyncClient> mcpAsyncClients = new CopyOnWriteArrayList<>();
|
|
|
|
|
|
|
+ // 使用 Map 存储 MCP 客户端,key 为节点名称
|
|
|
|
|
+ private final Map<String, McpAsyncClient> mcpAsyncClientMap = new ConcurrentHashMap<>();
|
|
|
|
|
|
|
|
|
|
|
|
|
@Resource
|
|
@Resource
|
|
@@ -42,54 +42,65 @@ public class McpClientService {
|
|
|
this.webClientBuilder = webClientBuilder;
|
|
this.webClientBuilder = webClientBuilder;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 获取所有 MCP 客户端
|
|
|
|
|
+ */
|
|
|
|
|
+ public Map<String, McpAsyncClient> getMcpClientMap() {
|
|
|
|
|
+ return mcpAsyncClientMap;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* 获取 MCP Session ID
|
|
* 获取 MCP Session ID
|
|
|
* 通过 SSE 连接获取服务端分配的 sessionId
|
|
* 通过 SSE 连接获取服务端分配的 sessionId
|
|
|
|
|
+ * 如果获取失败,返回空字符串,不影响后续连接
|
|
|
*/
|
|
*/
|
|
|
- private String getMCPSessionId(String url, String authorization) throws Exception {
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
- WebClient webClient = webClientBuilder.clone()
|
|
|
|
|
- .baseUrl(url)
|
|
|
|
|
- .defaultHeader(HttpHeaders.AUTHORIZATION, authorization)
|
|
|
|
|
- .defaultHeader(HttpHeaders.ACCEPT, MediaType.TEXT_EVENT_STREAM_VALUE)
|
|
|
|
|
- .build();
|
|
|
|
|
-
|
|
|
|
|
- // 收集所有 SSE 事件数据以便分析
|
|
|
|
|
- StringBuilder sseData = new StringBuilder();
|
|
|
|
|
-
|
|
|
|
|
- // 使用更灵活的方式处理 SSE 流
|
|
|
|
|
- String sessionId = webClient.get()
|
|
|
|
|
- .uri("/mcp/airouting")
|
|
|
|
|
- .retrieve()
|
|
|
|
|
- .bodyToFlux(String.class)
|
|
|
|
|
- .doOnNext(line -> {
|
|
|
|
|
- log.debug("📥 SSE 流数据:{}", line);
|
|
|
|
|
- sseData.append(line).append("\n");
|
|
|
|
|
- })
|
|
|
|
|
- .filter(line -> line != null && !line.isEmpty())
|
|
|
|
|
- .map(line -> {
|
|
|
|
|
- // 尝试从行中提取 sessionId
|
|
|
|
|
- if (line.contains("sessionId=")) {
|
|
|
|
|
- int start = line.indexOf("sessionId=") + 10;
|
|
|
|
|
- int end = line.indexOf(" ", start);
|
|
|
|
|
- if (end == -1) end = line.length();
|
|
|
|
|
- return line.substring(start, end).trim();
|
|
|
|
|
- }
|
|
|
|
|
- return null;
|
|
|
|
|
- })
|
|
|
|
|
- .filter(sid -> sid != null && !sid.isEmpty())
|
|
|
|
|
- .timeout(Duration.ofSeconds(10))
|
|
|
|
|
- .take(1)
|
|
|
|
|
- .blockFirst();
|
|
|
|
|
-
|
|
|
|
|
- if (sessionId == null || sessionId.isEmpty()) {
|
|
|
|
|
- log.error("❌ SSE 响应数据:{}", sseData.toString());
|
|
|
|
|
- throw new RuntimeException("无法从 SSE 响应中提取 Session ID");
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ private String getMCPSessionId(String url, String authorization) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ WebClient webClient = webClientBuilder.clone()
|
|
|
|
|
+ .baseUrl(url)
|
|
|
|
|
+ .defaultHeader(HttpHeaders.AUTHORIZATION, authorization)
|
|
|
|
|
+ .defaultHeader(HttpHeaders.ACCEPT, MediaType.TEXT_EVENT_STREAM_VALUE)
|
|
|
|
|
+ .build();
|
|
|
|
|
+
|
|
|
|
|
+ // 收集所有 SSE 事件数据以便分析
|
|
|
|
|
+ StringBuilder sseData = new StringBuilder();
|
|
|
|
|
+
|
|
|
|
|
+ // 使用更灵活的方式处理 SSE 流
|
|
|
|
|
+ String sessionId = webClient.get()
|
|
|
|
|
+ .uri("/mcp/airouting")
|
|
|
|
|
+ .retrieve()
|
|
|
|
|
+ .bodyToFlux(String.class)
|
|
|
|
|
+ .doOnNext(line -> {
|
|
|
|
|
+ log.debug("📥 SSE 流数据:{}", line);
|
|
|
|
|
+ sseData.append(line).append("\n");
|
|
|
|
|
+ })
|
|
|
|
|
+ .filter(line -> line != null && !line.isEmpty())
|
|
|
|
|
+ .map(line -> {
|
|
|
|
|
+ // 尝试从行中提取 sessionId
|
|
|
|
|
+ if (line.contains("sessionId=")) {
|
|
|
|
|
+ int start = line.indexOf("sessionId=") + 10;
|
|
|
|
|
+ int end = line.indexOf(" ", start);
|
|
|
|
|
+ if (end == -1) end = line.length();
|
|
|
|
|
+ return line.substring(start, end).trim();
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ })
|
|
|
|
|
+ .filter(sid -> sid != null && !sid.isEmpty())
|
|
|
|
|
+ .timeout(Duration.ofSeconds(10))
|
|
|
|
|
+ .take(1)
|
|
|
|
|
+ .blockFirst();
|
|
|
|
|
+
|
|
|
|
|
+ if (sessionId == null || sessionId.isEmpty()) {
|
|
|
|
|
+ log.warn("⚠️ 无法从 SSE 响应中提取 Session ID,将继续使用空 sessionId");
|
|
|
|
|
+ return "";
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- log.info("✅ 获取到 MCP Session ID: {}", sessionId);
|
|
|
|
|
- return sessionId;
|
|
|
|
|
|
|
+ log.info("✅ 获取到 MCP Session ID: {}", sessionId);
|
|
|
|
|
+ return sessionId;
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("⚠️ 获取 MCP Session ID 失败:{},将继续使用空 sessionId", e.getMessage());
|
|
|
|
|
+ return "";
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@@ -101,39 +112,37 @@ public class McpClientService {
|
|
|
|
|
|
|
|
// 2. 增加同步锁,防止多线程同时重构
|
|
// 2. 增加同步锁,防止多线程同时重构
|
|
|
public synchronized void rebuild() {
|
|
public synchronized void rebuild() {
|
|
|
-// log.info("🛠️ 正在执行 MCP 节点重构...");
|
|
|
|
|
|
|
+ log.info("🛠️ 正在执行 MCP 节点重构...");
|
|
|
|
|
|
|
|
// 3. 资源释放:关闭旧客户端,防止内存泄漏
|
|
// 3. 资源释放:关闭旧客户端,防止内存泄漏
|
|
|
- mcpAsyncClients.forEach(client -> {
|
|
|
|
|
|
|
+ mcpAsyncClientMap.forEach((name, client) -> {
|
|
|
try {
|
|
try {
|
|
|
- if (client instanceof AutoCloseable) { // 兼容不同SDK实现
|
|
|
|
|
|
|
+ if (client instanceof AutoCloseable) { // 兼容不同 SDK 实现
|
|
|
((AutoCloseable) client).close();
|
|
((AutoCloseable) client).close();
|
|
|
}
|
|
}
|
|
|
} catch (Exception e) {
|
|
} catch (Exception e) {
|
|
|
- log.warn("释放旧客户端失败: {}", e.getMessage());
|
|
|
|
|
|
|
+ log.warn("释放旧客户端失败:{}", e.getMessage());
|
|
|
}
|
|
}
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- mcpAsyncClients.clear();
|
|
|
|
|
- // toolRegistry.clear();
|
|
|
|
|
-
|
|
|
|
|
- String sessionId = "";
|
|
|
|
|
- try {
|
|
|
|
|
- sessionId = getMCPSessionId("https://ai-paas-mcp-endpoint.njuu.top", "sqGYuMvKgdxmzmTM5lNBgLdVpl6XNnPX");
|
|
|
|
|
- } catch (Exception e) {
|
|
|
|
|
- throw new RuntimeException(e);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ mcpAsyncClientMap.clear();
|
|
|
|
|
|
|
|
List<String> failedNodes = new ArrayList<>();
|
|
List<String> failedNodes = new ArrayList<>();
|
|
|
|
|
|
|
|
- String finalSessionId = sessionId;
|
|
|
|
|
|
|
+ // 从配置中动态读取 MCP 节点
|
|
|
properties.getNodes().forEach((name, config) -> {
|
|
properties.getNodes().forEach((name, config) -> {
|
|
|
try {
|
|
try {
|
|
|
|
|
+ // 获取 session id(失败时返回空字符串)
|
|
|
|
|
+ String sessionId = getMCPSessionId(config.getUrl(), config.getHeaders().getOrDefault("Authorization", ""));
|
|
|
|
|
+
|
|
|
Builder dedicatedBuilder = webClientBuilder.clone()
|
|
Builder dedicatedBuilder = webClientBuilder.clone()
|
|
|
.baseUrl(config.getUrl())
|
|
.baseUrl(config.getUrl())
|
|
|
.defaultHeader(HttpHeaders.AUTHORIZATION, config.getHeaders().getOrDefault("Authorization", ""))
|
|
.defaultHeader(HttpHeaders.AUTHORIZATION, config.getHeaders().getOrDefault("Authorization", ""))
|
|
|
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
|
|
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
|
|
|
- .defaultHeader("mcp-session-id", finalSessionId);
|
|
|
|
|
|
|
+ .defaultHeader("mcp-session-id", sessionId);
|
|
|
|
|
+
|
|
|
|
|
+ // 获取 endpoint 配置,默认为 /mcp/airouting
|
|
|
|
|
+ String endpoint = config.getEndpoint() != null ? config.getEndpoint() : "/mcp/airouting";
|
|
|
|
|
|
|
|
// 5. 创建 SSE Transport(会在构造时立即发起 SSE 连接)
|
|
// 5. 创建 SSE Transport(会在构造时立即发起 SSE 连接)
|
|
|
// 4. 补全参数 resumableStreams(是否支持可恢复流,解决断连重连)
|
|
// 4. 补全参数 resumableStreams(是否支持可恢复流,解决断连重连)
|
|
@@ -146,8 +155,13 @@ public class McpClientService {
|
|
|
// MCP 协议使用日期格式版本号,常见:2024-11-05, 2025-03-26 等
|
|
// MCP 协议使用日期格式版本号,常见:2024-11-05, 2025-03-26 等
|
|
|
List<String> supportedProtocolVersions = List.of("2025-11-25", "2025-03-26", "2024-11-05");
|
|
List<String> supportedProtocolVersions = List.of("2025-11-25", "2025-03-26", "2024-11-05");
|
|
|
|
|
|
|
|
- var transport = WebClientStreamableHttpTransport.builder(dedicatedBuilder).openConnectionOnStartup(openConnectionOnStartup).endpoint("/mcp/airouting").jsonMapper(defaultMcpJsonMapper).resumableStreams(true)
|
|
|
|
|
- .supportedProtocolVersions(supportedProtocolVersions).build();
|
|
|
|
|
|
|
+ var transport = WebClientStreamableHttpTransport.builder(dedicatedBuilder)
|
|
|
|
|
+ .openConnectionOnStartup(openConnectionOnStartup)
|
|
|
|
|
+ .endpoint(endpoint)
|
|
|
|
|
+ .jsonMapper(defaultMcpJsonMapper)
|
|
|
|
|
+ .resumableStreams(true)
|
|
|
|
|
+ .supportedProtocolVersions(supportedProtocolVersions)
|
|
|
|
|
+ .build();
|
|
|
// 6. 建立连接
|
|
// 6. 建立连接
|
|
|
McpAsyncClient client = McpClient.async(transport).build();
|
|
McpAsyncClient client = McpClient.async(transport).build();
|
|
|
log.info("🛠️执行成功 {}", client.getClientInfo());
|
|
log.info("🛠️执行成功 {}", client.getClientInfo());
|
|
@@ -168,7 +182,7 @@ public class McpClientService {
|
|
|
|
|
|
|
|
if (toolResponse != null && toolResponse.tools() != null) {
|
|
if (toolResponse != null && toolResponse.tools() != null) {
|
|
|
List<McpSchema.Tool> tools = toolResponse.tools();
|
|
List<McpSchema.Tool> tools = toolResponse.tools();
|
|
|
- mcpAsyncClients.add(client);
|
|
|
|
|
|
|
+ mcpAsyncClientMap.put(name, client);
|
|
|
if (tools.isEmpty()) {
|
|
if (tools.isEmpty()) {
|
|
|
log.warn("⚠️ 节点 [{}] 挂载成功,但工具列表为空。可能原因:1) 服务端未注册工具;2) 认证权限不足;3) 工具注册延迟", name);
|
|
log.warn("⚠️ 节点 [{}] 挂载成功,但工具列表为空。可能原因:1) 服务端未注册工具;2) 认证权限不足;3) 工具注册延迟", name);
|
|
|
} else {
|
|
} else {
|
|
@@ -177,7 +191,7 @@ public class McpClientService {
|
|
|
}
|
|
}
|
|
|
} else {
|
|
} else {
|
|
|
log.warn("⚠️ 节点 [{}] 挂载成功,但工具列表返回为 null", name);
|
|
log.warn("⚠️ 节点 [{}] 挂载成功,但工具列表返回为 null", name);
|
|
|
- mcpAsyncClients.add(client);
|
|
|
|
|
|
|
+ mcpAsyncClientMap.put(name, client);
|
|
|
}
|
|
}
|
|
|
} catch (WebClientResponseException e) {
|
|
} catch (WebClientResponseException e) {
|
|
|
log.error("❌ 节点 [{}] 挂载失败:HTTP {} - 服务端返回错误,请检查服务端状态",
|
|
log.error("❌ 节点 [{}] 挂载失败:HTTP {} - 服务端返回错误,请检查服务端状态",
|
|
@@ -192,6 +206,8 @@ public class McpClientService {
|
|
|
if (!failedNodes.isEmpty()) {
|
|
if (!failedNodes.isEmpty()) {
|
|
|
log.error("🚫 以下 MCP 节点挂载失败:{}", String.join(", ", failedNodes));
|
|
log.error("🚫 以下 MCP 节点挂载失败:{}", String.join(", ", failedNodes));
|
|
|
log.error("💡 请检查:1) 服务端是否正常运行;2) URL 配置是否正确;3) 认证 token 是否有效");
|
|
log.error("💡 请检查:1) 服务端是否正常运行;2) URL 配置是否正确;3) 认证 token 是否有效");
|
|
|
|
|
+ } else {
|
|
|
|
|
+ log.info("✅ MCP 节点重构完成,共挂载 {} 个节点", mcpAsyncClientMap.size());
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
}
|
|
}
|