|
|
@@ -12,24 +12,23 @@ import org.springframework.http.HttpHeaders;
|
|
|
import org.springframework.http.MediaType;
|
|
|
import org.springframework.stereotype.Service;
|
|
|
import org.springframework.web.reactive.function.client.WebClient;
|
|
|
-import reactor.core.publisher.Flux;
|
|
|
import reactor.core.publisher.Mono;
|
|
|
-import reactor.core.publisher.Sinks;
|
|
|
|
|
|
import java.time.Duration;
|
|
|
import java.util.Map;
|
|
|
import java.util.UUID;
|
|
|
-import java.util.concurrent.CompletableFuture;
|
|
|
import java.util.concurrent.ConcurrentHashMap;
|
|
|
import java.util.concurrent.TimeUnit;
|
|
|
import java.util.concurrent.atomic.AtomicLong;
|
|
|
-import java.util.concurrent.atomic.AtomicReference;
|
|
|
+import java.util.regex.Matcher;
|
|
|
+import java.util.regex.Pattern;
|
|
|
|
|
|
/**
|
|
|
* MCP Client 服务
|
|
|
*
|
|
|
* 负责与 MCP Hub 建立 SSE 连接,发送 JSON-RPC 请求
|
|
|
* 支持工具调用:tools/list, tools/call
|
|
|
+ * 支持智能路由:search_tools, execute_tool
|
|
|
*
|
|
|
* 架构位置:client/mcp/ - 与未来的 rag/ 同级
|
|
|
*/
|
|
|
@@ -50,14 +49,13 @@ public class McpClientService {
|
|
|
|
|
|
// 请求 ID 生成器
|
|
|
private final AtomicLong requestIdGenerator = new AtomicLong(1);
|
|
|
-
|
|
|
- // 等待中的请求:requestId -> CompletableFuture
|
|
|
- private final Map<Long, CompletableFuture<JsonNode>> pendingRequests = new ConcurrentHashMap<>();
|
|
|
|
|
|
@PostConstruct
|
|
|
public void init() {
|
|
|
this.webClient = WebClient.builder()
|
|
|
.baseUrl(mcpServerConfig.getBaseUrl())
|
|
|
+ .defaultHeader(HttpHeaders.AUTHORIZATION, mcpServerConfig.getAuthorization())
|
|
|
+ .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
|
|
|
.build();
|
|
|
log.info("MCP Client 初始化完成,Hub地址: {}", mcpServerConfig.getBaseUrl());
|
|
|
}
|
|
|
@@ -81,32 +79,13 @@ public class McpClientService {
|
|
|
closeSession(sessions.get(group));
|
|
|
}
|
|
|
|
|
|
- String path = (group == null || group.isEmpty()) ? "/mcp" : "/mcp/" + group;
|
|
|
-
|
|
|
SessionInfo sessionInfo = new SessionInfo();
|
|
|
sessionInfo.setGroup(group);
|
|
|
sessionInfo.setSessionId(UUID.randomUUID().toString()); // 临时ID,会被覆盖
|
|
|
|
|
|
try {
|
|
|
- // 建立 SSE 连接
|
|
|
- Flux<SseEvent> sseFlux = webClient.get()
|
|
|
- .uri(path)
|
|
|
- .header(HttpHeaders.ACCEPT, MediaType.TEXT_EVENT_STREAM_VALUE)
|
|
|
- .retrieve()
|
|
|
- .bodyToFlux(String.class)
|
|
|
- .map(this::parseSseEvent)
|
|
|
- .filter(event -> event != null);
|
|
|
-
|
|
|
- // 订阅 SSE 流
|
|
|
- sseFlux.subscribe(
|
|
|
- event -> handleSseEvent(event, sessionInfo),
|
|
|
- error -> log.error("SSE 连接错误 [group={}]: {}", group, error.getMessage()),
|
|
|
- () -> log.info("SSE 连接关闭 [group={}]", group)
|
|
|
- );
|
|
|
-
|
|
|
- // 等待 endpoint 事件(最多 10 秒)
|
|
|
- String sessionId = sessionInfo.getEndpointFuture()
|
|
|
- .get(10, TimeUnit.SECONDS);
|
|
|
+ // 获取 sessionId
|
|
|
+ String sessionId = getMCPSessionId();
|
|
|
|
|
|
sessionInfo.setSessionId(sessionId);
|
|
|
sessions.put(group, sessionInfo);
|
|
|
@@ -120,6 +99,38 @@ public class McpClientService {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 获取智能路由的 sessionId
|
|
|
+ *
|
|
|
+ * @return 智能路由的 sessionId
|
|
|
+ */
|
|
|
+ public synchronized String connectIntelligence(String group) {
|
|
|
+ // 如果已有连接,先关闭
|
|
|
+ if (sessions.containsKey(group)) {
|
|
|
+ closeSession(sessions.get(group));
|
|
|
+ }
|
|
|
+
|
|
|
+ SessionInfo sessionInfo = new SessionInfo();
|
|
|
+ sessionInfo.setGroup(group);
|
|
|
+ sessionInfo.setSessionId(UUID.randomUUID().toString()); // 临时ID,会被覆盖
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 获取智能路由的 sessionId
|
|
|
+ String sessionId = getAirRoutingSessionId();
|
|
|
+
|
|
|
+ sessionInfo.setSessionId(sessionId);
|
|
|
+ sessionInfo.setMode("intelligence");
|
|
|
+ sessions.put(group, sessionInfo);
|
|
|
+
|
|
|
+ log.info("智能路由连接成功 [group={}, sessionId={}]", group, sessionId);
|
|
|
+ return sessionId;
|
|
|
+
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("智能路由连接失败 [group={}]: {}", group, e.getMessage());
|
|
|
+ throw new RuntimeException("无法建立智能路由连接: " + e.getMessage(), e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 获取工具列表
|
|
|
*
|
|
|
@@ -166,40 +177,107 @@ public class McpClientService {
|
|
|
return parseToolCallResult(response);
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 搜索工具(智能路由)
|
|
|
+ *
|
|
|
+ * @param group MCP 分组
|
|
|
+ * @param query 搜索关键词
|
|
|
+ * @param limit 结果数量限制
|
|
|
+ * @return 搜索结果
|
|
|
+ */
|
|
|
+ public ToolCallResult searchTools(String group, String query, int limit) {
|
|
|
+ SessionInfo session = getOrCreateSession(group);
|
|
|
+
|
|
|
+ if (!"intelligence".equals(session.getMode())) {
|
|
|
+ // 如果不是智能路由模式,先切换到智能路由
|
|
|
+ connectIntelligence(group);
|
|
|
+ session = sessions.get(group);
|
|
|
+ }
|
|
|
+
|
|
|
+ Map<String, Object> arguments = Map.of(
|
|
|
+ "query", query,
|
|
|
+ "limit", limit
|
|
|
+ );
|
|
|
+
|
|
|
+ ToolCallParams params = ToolCallParams.builder()
|
|
|
+ .name("search_tools")
|
|
|
+ .arguments(arguments)
|
|
|
+ .build();
|
|
|
+
|
|
|
+ JsonRpcRequest request = JsonRpcRequest.builder()
|
|
|
+ .jsonrpc("2.0")
|
|
|
+ .id(requestIdGenerator.incrementAndGet())
|
|
|
+ .method("tools/call")
|
|
|
+ .params(objectMapper.valueToTree(params))
|
|
|
+ .build();
|
|
|
+
|
|
|
+ JsonNode response = sendRequest(session, request);
|
|
|
+ return parseToolCallResult(response);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 执行工具(智能路由)
|
|
|
+ *
|
|
|
+ * @param group MCP 分组
|
|
|
+ * @param toolName 工具名称
|
|
|
+ * @param arguments 工具参数
|
|
|
+ * @return 工具调用结果
|
|
|
+ */
|
|
|
+ public ToolCallResult executeTool(String group, String toolName, Map<String, Object> arguments) {
|
|
|
+ SessionInfo session = getOrCreateSession(group);
|
|
|
+
|
|
|
+ if (!"intelligence".equals(session.getMode())) {
|
|
|
+ // 如果不是智能路由模式,先切换到智能路由
|
|
|
+ connectIntelligence(group);
|
|
|
+ session = sessions.get(group);
|
|
|
+ }
|
|
|
+
|
|
|
+ Map<String, Object> executeArguments = Map.of(
|
|
|
+ "toolName", toolName,
|
|
|
+ "arguments", arguments
|
|
|
+ );
|
|
|
+
|
|
|
+ ToolCallParams params = ToolCallParams.builder()
|
|
|
+ .name("execute_tool")
|
|
|
+ .arguments(executeArguments)
|
|
|
+ .build();
|
|
|
+
|
|
|
+ JsonRpcRequest request = JsonRpcRequest.builder()
|
|
|
+ .jsonrpc("2.0")
|
|
|
+ .id(requestIdGenerator.incrementAndGet())
|
|
|
+ .method("tools/call")
|
|
|
+ .params(objectMapper.valueToTree(params))
|
|
|
+ .build();
|
|
|
+
|
|
|
+ JsonNode response = sendRequest(session, request);
|
|
|
+ return parseToolCallResult(response);
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 发送 JSON-RPC 请求并等待响应
|
|
|
*/
|
|
|
private JsonNode sendRequest(SessionInfo session, JsonRpcRequest request) {
|
|
|
- CompletableFuture<JsonNode> future = new CompletableFuture<>();
|
|
|
- pendingRequests.put(request.getId(), future);
|
|
|
-
|
|
|
try {
|
|
|
- // 发送 POST 请求
|
|
|
- String path = "/mcp?sessionId=" + session.getSessionId();
|
|
|
+ // 构建请求路径
|
|
|
+ String path = "/mcp";
|
|
|
+ if ("intelligence".equals(session.getMode())) {
|
|
|
+ path = "/mcp/airouting";
|
|
|
+ }
|
|
|
|
|
|
- webClient.post()
|
|
|
+ // 发送请求
|
|
|
+ return webClient.post()
|
|
|
.uri(path)
|
|
|
.header("mcp-session-id", session.getSessionId())
|
|
|
- .contentType(MediaType.APPLICATION_JSON)
|
|
|
.bodyValue(request)
|
|
|
.retrieve()
|
|
|
- .bodyToMono(String.class)
|
|
|
- .subscribe(
|
|
|
- response -> log.debug("POST 响应: {}", response),
|
|
|
- error -> {
|
|
|
- log.error("POST 请求失败: {}", error.getMessage());
|
|
|
- future.completeExceptionally(error);
|
|
|
- }
|
|
|
- );
|
|
|
-
|
|
|
- // 等待响应(最多 60 秒)
|
|
|
- return future.get(60, TimeUnit.SECONDS);
|
|
|
+ .bodyToMono(JsonNode.class)
|
|
|
+ .doOnSuccess(response -> log.debug("POST 响应: {}", response))
|
|
|
+ .doOnError(error -> log.error("POST 请求失败: {}", error.getMessage()))
|
|
|
+ .block(Duration.ofSeconds(60));
|
|
|
|
|
|
} catch (Exception e) {
|
|
|
log.error("JSON-RPC 请求失败 [id={}]: {}", request.getId(), e.getMessage());
|
|
|
throw new RuntimeException("MCP 请求失败: " + e.getMessage(), e);
|
|
|
- } finally {
|
|
|
- pendingRequests.remove(request.getId());
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -227,64 +305,119 @@ public class McpClientService {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 解析 SSE 事件
|
|
|
+ * 获取 MCP Session ID
|
|
|
*/
|
|
|
- private SseEvent parseSseEvent(String line) {
|
|
|
- if (line.startsWith("event: ")) {
|
|
|
- return SseEvent.builder()
|
|
|
- .event(line.substring(7).trim())
|
|
|
- .build();
|
|
|
- } else if (line.startsWith("data: ")) {
|
|
|
- return SseEvent.builder()
|
|
|
- .data(line.substring(6).trim())
|
|
|
- .build();
|
|
|
- }
|
|
|
- return null;
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 处理 SSE 事件
|
|
|
- */
|
|
|
- private void handleSseEvent(SseEvent event, SessionInfo sessionInfo) {
|
|
|
- if ("endpoint".equals(event.getEvent())) {
|
|
|
- // 解析 endpoint URL 中的 sessionId
|
|
|
- String data = event.getData();
|
|
|
- String sessionId = extractSessionId(data);
|
|
|
- sessionInfo.getEndpointFuture().complete(sessionId);
|
|
|
-
|
|
|
- } else if ("message".equals(event.getEvent())) {
|
|
|
- // 处理 JSON-RPC 响应
|
|
|
- handleJsonRpcMessage(event.getData());
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 从 endpoint URL 提取 sessionId
|
|
|
- */
|
|
|
- private String extractSessionId(String endpointUrl) {
|
|
|
- // /mcp?sessionId=550e8400-e29b-41d4-a716-446655440000
|
|
|
- int idx = endpointUrl.indexOf("sessionId=");
|
|
|
- if (idx > 0) {
|
|
|
- return endpointUrl.substring(idx + 10);
|
|
|
+ private String getMCPSessionId() throws Exception {
|
|
|
+ log.info("正在获取 MCP Session ID...");
|
|
|
+
|
|
|
+ // 构建完整的 URL 以匹配 curl 命令
|
|
|
+ String url = mcpServerConfig.getBaseUrl() + "/mcp";
|
|
|
+ log.info("SSE 请求 URL: {}", url);
|
|
|
+
|
|
|
+ // 收集所有 SSE 事件数据以便分析
|
|
|
+ StringBuilder sseData = new StringBuilder();
|
|
|
+
|
|
|
+ // 使用更灵活的方式处理 SSE 流
|
|
|
+ String sessionId = webClient.get()
|
|
|
+ .uri("/mcp")
|
|
|
+ .header(HttpHeaders.ACCEPT, MediaType.TEXT_EVENT_STREAM_VALUE)
|
|
|
+ .header(HttpHeaders.AUTHORIZATION, mcpServerConfig.getAuthorization())
|
|
|
+ .retrieve()
|
|
|
+ .bodyToFlux(String.class)
|
|
|
+ .doOnNext(line -> {
|
|
|
+ log.debug("SSE 流数据: {}", line);
|
|
|
+ sseData.append(line).append("\n");
|
|
|
+ })
|
|
|
+ .filter(line -> line != null)
|
|
|
+ .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);
|
|
|
+ }
|
|
|
+ // 尝试从完整的 SSE 数据中提取
|
|
|
+ if (sseData.toString().contains("sessionId=")) {
|
|
|
+ String data = sseData.toString();
|
|
|
+ int start = data.indexOf("sessionId=") + 10;
|
|
|
+ int end = data.indexOf("\n", start);
|
|
|
+ if (end == -1) end = data.length();
|
|
|
+ return data.substring(start, end).trim();
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ })
|
|
|
+ .filter(sessionIdStr -> sessionIdStr != null && !sessionIdStr.isEmpty())
|
|
|
+ .timeout(Duration.ofSeconds(30))
|
|
|
+ .take(1)
|
|
|
+ .blockFirst();
|
|
|
+
|
|
|
+ if (sessionId == null || sessionId.isEmpty()) {
|
|
|
+ log.error("SSE 响应数据: {}", sseData.toString());
|
|
|
+ throw new Exception("无法从 SSE 响应中提取 sessionId");
|
|
|
}
|
|
|
- return endpointUrl;
|
|
|
+
|
|
|
+ log.info("获取到 MCP Session ID: {}", sessionId);
|
|
|
+ return sessionId;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 处理 JSON-RPC 消息
|
|
|
+ * 获取智能路由 Session ID
|
|
|
*/
|
|
|
- private void handleJsonRpcMessage(String data) {
|
|
|
- try {
|
|
|
- JsonNode node = objectMapper.readTree(data);
|
|
|
- Long id = node.has("id") ? node.get("id").asLong() : null;
|
|
|
-
|
|
|
- if (id != null && pendingRequests.containsKey(id)) {
|
|
|
- CompletableFuture<JsonNode> future = pendingRequests.get(id);
|
|
|
- future.complete(node);
|
|
|
- }
|
|
|
- } catch (Exception e) {
|
|
|
- log.error("解析 JSON-RPC 消息失败: {}", e.getMessage());
|
|
|
+ private String getAirRoutingSessionId() throws Exception {
|
|
|
+ log.info("正在获取智能路由 Session ID...");
|
|
|
+
|
|
|
+ // 构建完整的 URL 以匹配 curl 命令
|
|
|
+ String url = mcpServerConfig.getBaseUrl() + "/mcp/airouting";
|
|
|
+ log.info("SSE 请求 URL: {}", url);
|
|
|
+
|
|
|
+ // 收集所有 SSE 事件数据以便分析
|
|
|
+ StringBuilder sseData = new StringBuilder();
|
|
|
+
|
|
|
+ // 使用更灵活的方式处理 SSE 流
|
|
|
+ String sessionId = webClient.get()
|
|
|
+ .uri("/mcp/airouting")
|
|
|
+ .header(HttpHeaders.ACCEPT, MediaType.TEXT_EVENT_STREAM_VALUE)
|
|
|
+ .header(HttpHeaders.AUTHORIZATION, mcpServerConfig.getAuthorization())
|
|
|
+ .retrieve()
|
|
|
+ .bodyToFlux(String.class)
|
|
|
+ .doOnNext(line -> {
|
|
|
+ log.debug("SSE 流数据: {}", line);
|
|
|
+ sseData.append(line).append("\n");
|
|
|
+ })
|
|
|
+ .filter(line -> line != null)
|
|
|
+ .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);
|
|
|
+ }
|
|
|
+ // 尝试从完整的 SSE 数据中提取
|
|
|
+ if (sseData.toString().contains("sessionId=")) {
|
|
|
+ String data = sseData.toString();
|
|
|
+ int start = data.indexOf("sessionId=") + 10;
|
|
|
+ int end = data.indexOf("\n", start);
|
|
|
+ if (end == -1) end = data.length();
|
|
|
+ return data.substring(start, end).trim();
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ })
|
|
|
+ .filter(sessionIdStr -> sessionIdStr != null && !sessionIdStr.isEmpty())
|
|
|
+ .timeout(Duration.ofSeconds(30))
|
|
|
+ .take(1)
|
|
|
+ .blockFirst();
|
|
|
+
|
|
|
+ if (sessionId == null || sessionId.isEmpty()) {
|
|
|
+ log.error("SSE 响应数据: {}", sseData.toString());
|
|
|
+ throw new Exception("无法从 SSE 响应中提取 sessionId");
|
|
|
}
|
|
|
+
|
|
|
+ log.info("获取到智能路由 Session ID: {}", sessionId);
|
|
|
+ return sessionId;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -331,12 +464,13 @@ public class McpClientService {
|
|
|
private static class SessionInfo {
|
|
|
private String group;
|
|
|
private String sessionId;
|
|
|
- private final CompletableFuture<String> endpointFuture = new CompletableFuture<>();
|
|
|
+ private String mode = "default"; // default 或 intelligence
|
|
|
|
|
|
public String getGroup() { return group; }
|
|
|
public void setGroup(String group) { this.group = group; }
|
|
|
public String getSessionId() { return sessionId; }
|
|
|
public void setSessionId(String sessionId) { this.sessionId = sessionId; }
|
|
|
- public CompletableFuture<String> getEndpointFuture() { return endpointFuture; }
|
|
|
+ public String getMode() { return mode; }
|
|
|
+ public void setMode(String mode) { this.mode = mode; }
|
|
|
}
|
|
|
}
|