HttpTripPlanningAiClient.java 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611
  1. package com.travel.intellitravel_pioneer.service.ai;
  2. import com.fasterxml.jackson.core.type.TypeReference;
  3. import com.fasterxml.jackson.databind.JsonNode;
  4. import com.fasterxml.jackson.databind.ObjectMapper;
  5. import com.fasterxml.jackson.databind.node.ArrayNode;
  6. import com.fasterxml.jackson.databind.node.ObjectNode;
  7. import com.travel.intellitravel_pioneer.exception.IntellitravelException;
  8. import com.travel.intellitravel_pioneer.vo.itinerary.ItineraryPlan;
  9. import com.travel.intellitravel_pioneer.vo.itinerary.ItineraryRequest;
  10. import org.springframework.beans.factory.annotation.Value;
  11. import org.springframework.http.MediaType;
  12. import org.springframework.stereotype.Component;
  13. import org.springframework.web.client.RestClient;
  14. import java.net.URI;
  15. import java.net.http.HttpClient;
  16. import java.net.http.HttpRequest;
  17. import java.net.http.HttpResponse;
  18. import java.util.ArrayList;
  19. import java.util.HashMap;
  20. import java.util.List;
  21. import java.util.Map;
  22. import java.util.concurrent.CompletableFuture;
  23. import java.util.concurrent.TimeUnit;
  24. @Component
  25. public class HttpTripPlanningAiClient implements TripPlanningAiClient {
  26. private final ObjectMapper objectMapper;
  27. @Value("${trip.ai.provider:llm}")
  28. private String aiProvider;
  29. @Value("${trip.ai.base-url:}")
  30. private String aiBaseUrl;
  31. @Value("${trip.ai.plan-path:/api/trips/plan}")
  32. private String aiPlanPath;
  33. @Value("${trip.ai.status-analysis-path:/api/trips/status/analyze}")
  34. private String aiStatusAnalysisPath;
  35. @Value("${trip.ai.llm.base-url:https://api.siliconflow.cn}")
  36. private String llmBaseUrl;
  37. @Value("${trip.ai.llm.chat-path:/v1/chat/completions}")
  38. private String llmChatPath;
  39. @Value("${trip.ai.llm.api-key:}")
  40. private String llmApiKey;
  41. @Value("${trip.ai.llm.model:Qwen/Qwen3-8B}")
  42. private String llmModel;
  43. @Value("${trip.ai.llm.temperature:0.2}")
  44. private Double llmTemperature;
  45. public HttpTripPlanningAiClient(ObjectMapper objectMapper) {
  46. this.objectMapper = objectMapper;
  47. }
  48. @Override
  49. public ItineraryPlan generateItinerary(String token, ItineraryRequest request) {
  50. return generateItinerary(token, request, null);
  51. }
  52. @Override
  53. public ItineraryPlan generateItinerary(String token, ItineraryRequest request,
  54. java.util.function.Consumer<String> toolNotifier) {
  55. if (isLlmProvider()) {
  56. return callLlmForItinerary(request, toolNotifier);
  57. }
  58. Map<String, Object> requestBody = new HashMap<>();
  59. requestBody.put("request", request);
  60. String rawResponse = callCustomAiService(aiPlanPath, token, requestBody);
  61. return parseItinerary(rawResponse);
  62. }
  63. private String callCustomAiService(String path, String token, Object requestBody) {
  64. if (aiBaseUrl == null || aiBaseUrl.isBlank()) {
  65. throw new IntellitravelException("未配置 AI 规划服务");
  66. }
  67. int maxRetries = 3;
  68. int attempt = 0;
  69. long backoff = 1000;
  70. Exception lastException = null;
  71. while (attempt < maxRetries) {
  72. try {
  73. return RestClient.create()
  74. .post()
  75. .uri(aiBaseUrl + path)
  76. .contentType(MediaType.APPLICATION_JSON)
  77. .headers(headers -> {
  78. if (token != null && !token.isBlank()) {
  79. headers.set("token", token);
  80. }
  81. })
  82. .body(requestBody)
  83. .retrieve()
  84. .body(String.class);
  85. } catch (Exception e) {
  86. lastException = e;
  87. attempt++;
  88. if (attempt >= maxRetries)
  89. break;
  90. try {
  91. Thread.sleep(backoff);
  92. backoff *= 2;
  93. } catch (InterruptedException ie) {
  94. Thread.currentThread().interrupt();
  95. throw new IntellitravelException("AI 调用被迫中断: " + ie.getMessage());
  96. }
  97. }
  98. }
  99. throw new IntellitravelException("AI 服务当前比较繁忙,请稍等片刻再试哦~");
  100. }
  101. private boolean isLlmProvider() {
  102. return "llm".equalsIgnoreCase(aiProvider);
  103. }
  104. private ItineraryPlan callLlmForItinerary(ItineraryRequest request,
  105. java.util.function.Consumer<String> toolNotifier) {
  106. // 设置环境变量 AMAP_MCP_KEY (申请: https://lbs.amap.com/dev/key/app)
  107. String amapKey = System.getenv().getOrDefault("AMAP_MCP_KEY", "");
  108. String amapUrl = "https://mcp.amap.com/sse?key=" + amapKey;
  109. try (AmapMcpClient mcpClient = new AmapMcpClient(amapUrl)) {
  110. mcpClient.initialize();
  111. JsonNode tools = mcpClient.listTools();
  112. List<Map<String, Object>> messages = new ArrayList<>();
  113. // System prompt
  114. String systemInstructions = "你是一位专业的旅行规划助手。请根据用户请求,生成一个详细的行程计划。\n" +
  115. "【工作流程与思考机制】\n" +
  116. "1. 在调用任何工具之前,你必须先输出一个【思考过程】(Thought),简要分析当前需求,并解释为何需要调用该工具(例如验证天气、查询票价等)。\n" +
  117. " 格式示例:\n" +
  118. " Thought: 用户想去北京,我需要先查询北京的天气情况来决定行程。\n" +
  119. " (随后发起工具调用)\n" +
  120. "2. 必须使用提供的工具查询和验证每一个景点的真实性和经纬度。严禁伪造、猜测或生成不存在的地点名称和坐标。只有在工具返回结果中真实存在的地点,才能加入最终的行程计划。\n" +
  121. "3. 如果工具调用失败或未找到信息,请在思考过程中说明情况,并尝试更换关键词重新搜索,绝不能凭空捏造地点。\n" +
  122. "\n" +
  123. "【最终交付物】\n" +
  124. "当所有规划完成且不再调用工具时,请直接且仅输出最终的行程计划JSON。\n" +
  125. "注意:\n" +
  126. "1. 最终输出必须是纯粹合法的JSON字符串,严禁在JSON文本内部或周围夹杂Markdown代码块(如```json)、【思考过程】或任何其他解释性文字。\n" +
  127. "2. 必须确保JSON格式完全合法。特别注意:所有的字符串值如果包含双引号必须进行转义,各个属性之间必须有逗号分隔!\n" +
  128. "3. 规划中禁止使用泛指词汇,每一项具体行程必须为具体且真实存在的地点,严禁出现“当地特色餐厅”、“网红小吃店”这类模糊描述,并且必须提供真实准确的经纬度。\n" +
  129. "4. 每天的行程项不得少于三项,最终规划中不得有重复的行程节点。\n" +
  130. "JSON结构如下:\n" +
  131. "{\n" +
  132. " \"title\": \"整个行程的标题,如:北京三日游\",\n" +
  133. " \"estimatedCost\": 1500.0, // 预算总花费\n" +
  134. " \"totalDays\": 3, // 总天数\n" +
  135. " \"dailyPlans\": [\n" +
  136. " {\n" +
  137. " \"dayNumber\": 1, // 第几天,依次为1,2,3...\n" +
  138. " \"items\": [\n" +
  139. " {\n" +
  140. " \"type\": \"景点|餐饮|住宿|购物\", // 只能填这4个中的1个\n" +
  141. " \"name\": \"具体的地点名称,如:故宫博物院\",\n" +
  142. " \"description\": \"该行程节点的详细描述,如游玩时间,门票,交通建议等\",\n" +
  143. " \"location\": {\n" +
  144. " \"lat\": 39.9163, // 必须是 Double 类型的真实纬度\n" +
  145. " \"lng\": 116.3971, // 必须是 Double 类型的真实经度\n" +
  146. " \"name\": \"位置名称\"\n" +
  147. " }\n" +
  148. " }\n" +
  149. " ]\n" +
  150. " }\n" +
  151. " ]\n" +
  152. "}";
  153. messages.add(Map.of("role", "system", "content", systemInstructions));
  154. Map<String, Object> input = new HashMap<>();
  155. input.put("request", request);
  156. messages.add(Map.of("role", "user", "content", toJsonString(input)));
  157. // Agent Loop
  158. int maxTurns = 30; // 增加最大轮数以支持更多的搜索和验证
  159. for (int i = 0; i < maxTurns; i++) {
  160. System.out.println("Agent turn " + (i + 1));
  161. JsonNode response = callLlmChat(messages, tools);
  162. JsonNode choices = response.path("choices");
  163. if (choices.isEmpty()) {
  164. throw new IntellitravelException("AI 没有返回任何内容,请稍候再试呀~");
  165. }
  166. JsonNode message = choices.get(0).path("message");
  167. Map<String, Object> assistantMsg = new HashMap<>();
  168. assistantMsg.put("role", message.path("role").asText());
  169. if (message.has("content") && !message.get("content").isNull()) {
  170. assistantMsg.put("content", message.get("content").asText());
  171. }
  172. if (message.has("tool_calls")) {
  173. assistantMsg.put("tool_calls", message.get("tool_calls"));
  174. }
  175. messages.add(assistantMsg);
  176. if (message.has("tool_calls")) {
  177. JsonNode toolCalls = message.get("tool_calls");
  178. for (JsonNode toolCall : toolCalls) {
  179. String id = toolCall.path("id").asText();
  180. JsonNode functionNode = toolCall.path("function");
  181. String name = functionNode.path("name").asText();
  182. String args = functionNode.path("arguments").asText("{}");
  183. System.out.println("Invoking tool: " + name);
  184. if (toolNotifier != null) {
  185. try {
  186. String cleanArgs = args.replaceAll("\\s+", " ").trim();
  187. toolNotifier.accept("Invoking tool: " + name + " with args: " + cleanArgs);
  188. } catch (Exception e) {
  189. System.err.println("Tool notifier error: " + e.getMessage());
  190. }
  191. }
  192. String result;
  193. try {
  194. if (name.isEmpty()) {
  195. result = "Error: Tool name is missing";
  196. } else {
  197. result = mcpClient.callTool(name, args);
  198. // 防止工具返回结果过长导致Token超出限制
  199. if (result != null && result.length() > 5000) {
  200. result = result.substring(0, 5000) + "... [Result truncated due to length]";
  201. }
  202. }
  203. } catch (Exception e) {
  204. result = "Error executing tool " + name + ": " + e.toString();
  205. }
  206. if (toolNotifier != null) {
  207. try {
  208. toolNotifier.accept("Tool " + name + " completed");
  209. } catch (Exception e) {
  210. System.err.println("Tool notifier error: " + e.getMessage());
  211. }
  212. }
  213. Map<String, Object> toolMsg = new HashMap<>();
  214. toolMsg.put("role", "tool");
  215. toolMsg.put("tool_call_id", id);
  216. toolMsg.put("name", name);
  217. toolMsg.put("content", result);
  218. messages.add(toolMsg);
  219. }
  220. } else {
  221. // Final answer found
  222. String content = message.path("content").asText();
  223. ItineraryPlan plan = parseItinerary(content);
  224. if (plan != null && plan.getDestination() == null && request.getDestination() != null) {
  225. plan.setDestination(request.getDestination());
  226. }
  227. return plan;
  228. }
  229. }
  230. throw new IntellitravelException("AI 认真思考了太久,暂时无法完成复杂的规划,请换个简单点的要求试试吧~");
  231. } catch (IntellitravelException ie) {
  232. throw ie;
  233. } catch (Exception e) {
  234. e.printStackTrace();
  235. throw new IntellitravelException("啊哦,生成行程时遇到了小阻碍(网络不稳定或服务器繁忙),请稍后重试哦~");
  236. }
  237. }
  238. private String toJsonString(Object value) {
  239. try {
  240. return objectMapper.writeValueAsString(value);
  241. } catch (Exception e) {
  242. throw new IntellitravelException("Failed to build GPT request: " + e.getMessage());
  243. }
  244. }
  245. private String stripCodeFence(String text) {
  246. if (text == null) {
  247. return null;
  248. }
  249. String trimmed = text.trim();
  250. if (trimmed.startsWith("```") && trimmed.endsWith("```")) {
  251. int firstLineBreak = trimmed.indexOf('\n');
  252. if (firstLineBreak > 0) {
  253. trimmed = trimmed.substring(firstLineBreak + 1, trimmed.length() - 3).trim();
  254. }
  255. }
  256. return trimmed;
  257. }
  258. private ItineraryPlan parseItinerary(String rawResponse) {
  259. String cleaned = stripCodeFence(rawResponse);
  260. String jsonToParse = cleaned;
  261. // 尝试提取最外层的JSON对象(应对包含思考过程或Markdown标记的情况)
  262. int firstBrace = cleaned.indexOf('{');
  263. int lastBrace = cleaned.lastIndexOf('}');
  264. if (firstBrace >= 0 && lastBrace >= 0 && lastBrace > firstBrace) {
  265. jsonToParse = cleaned.substring(firstBrace, lastBrace + 1);
  266. }
  267. try {
  268. JsonNode root = objectMapper.readTree(jsonToParse);
  269. // 直接尝试解析为ItineraryPlan(期望根节点就是行程计划对象)
  270. ItineraryPlan plan = objectMapper.treeToValue(root, ItineraryPlan.class);
  271. return plan;
  272. } catch (Exception e) {
  273. // 如果直接解析失败,可以尝试回退到旧格式(兼容性处理)
  274. try {
  275. JsonNode fallbackRoot = objectMapper.readTree(jsonToParse);
  276. JsonNode itineraryNode = null;
  277. if (fallbackRoot.has("data") && fallbackRoot.get("data") != null
  278. && fallbackRoot.get("data").has("itinerary")) {
  279. itineraryNode = fallbackRoot.get("data").get("itinerary");
  280. } else if (fallbackRoot.has("itinerary")) {
  281. itineraryNode = fallbackRoot.get("itinerary");
  282. }
  283. if (itineraryNode != null && !itineraryNode.isNull()) {
  284. ItineraryPlan plan = objectMapper.treeToValue(itineraryNode, ItineraryPlan.class);
  285. return plan;
  286. }
  287. throw new IntellitravelException("AI 未能返回符合规范的行程,请再次尝试哦~");
  288. } catch (Exception ex) {
  289. throw new IntellitravelException("AI 生成的排版有点小偏差,请稍后刷新重试呀~");
  290. }
  291. }
  292. }
  293. private JsonNode callLlmChat(List<Map<String, Object>> messages, JsonNode tools) {
  294. ObjectNode body = objectMapper.createObjectNode();
  295. body.put("model", llmModel);
  296. body.put("temperature", llmTemperature);
  297. body.set("messages", objectMapper.valueToTree(messages));
  298. if (tools != null && !tools.isEmpty()) {
  299. body.set("tools", tools);
  300. }
  301. String jsonBody;
  302. try {
  303. jsonBody = objectMapper.writeValueAsString(body);
  304. } catch (Exception e) {
  305. throw new IntellitravelException("准备 AI 请求数据失败,请重试~");
  306. }
  307. String raw = executeGptRequest(jsonBody);
  308. try {
  309. return objectMapper.readTree(raw);
  310. } catch (Exception e) {
  311. throw new IntellitravelException("解读 AI 响应数据出现小问题,请再试一次哦~");
  312. }
  313. }
  314. private String executeGptRequest(Object body) {
  315. if (llmApiKey == null || llmApiKey.isBlank()) {
  316. throw new IntellitravelException("未配置 AI 密钥,请联系系统管理员");
  317. }
  318. int maxRetries = 3;
  319. int attempt = 0;
  320. long backoff = 1000;
  321. Exception lastException = null;
  322. while (attempt < maxRetries) {
  323. try {
  324. return RestClient.create()
  325. .post()
  326. .uri(llmBaseUrl + llmChatPath)
  327. .contentType(MediaType.APPLICATION_JSON)
  328. .headers(headers -> headers.setBearerAuth(llmApiKey))
  329. .body(body)
  330. .retrieve()
  331. .body(String.class);
  332. } catch (Exception e) {
  333. lastException = e;
  334. attempt++;
  335. if (attempt >= maxRetries)
  336. break;
  337. try {
  338. Thread.sleep(backoff);
  339. backoff *= 2;
  340. } catch (InterruptedException ie) {
  341. Thread.currentThread().interrupt();
  342. throw new IntellitravelException("AI 调用被意外中断: " + ie.getMessage());
  343. }
  344. }
  345. }
  346. throw new IntellitravelException("AI 规划师服务器当前可能比较繁忙或处于维护中,请稍等片刻再试哦~");
  347. }
  348. private class AmapMcpClient implements AutoCloseable {
  349. private final String sseUrl;
  350. private final HttpClient client;
  351. private String postEndpoint;
  352. private CompletableFuture<Void> sseTask;
  353. private int requestId = 0;
  354. private final java.util.concurrent.ConcurrentMap<Integer, CompletableFuture<JsonNode>> pendingRequests = new java.util.concurrent.ConcurrentHashMap<>();
  355. public AmapMcpClient(String sseUrl) {
  356. this.sseUrl = sseUrl;
  357. this.client = HttpClient.newBuilder().executor(java.util.concurrent.Executors.newCachedThreadPool())
  358. .build();
  359. }
  360. public void initialize() throws Exception {
  361. // 1. Connect to SSE and find endpoint
  362. URI uri = URI.create(sseUrl);
  363. HttpRequest request = HttpRequest.newBuilder().uri(uri).GET().build();
  364. CompletableFuture<String> endpointFuture = new CompletableFuture<>();
  365. sseTask = client.sendAsync(request, HttpResponse.BodyHandlers.ofLines())
  366. .whenComplete((r, e) -> {
  367. if (e != null) {
  368. endpointFuture.completeExceptionally(e);
  369. }
  370. })
  371. .thenAccept(response -> {
  372. if (response.statusCode() != 200) {
  373. endpointFuture.completeExceptionally(new RuntimeException(
  374. "SSE Connection failed with status: " + response.statusCode()));
  375. return;
  376. }
  377. java.util.concurrent.atomic.AtomicReference<String> currentEvent = new java.util.concurrent.atomic.AtomicReference<>(
  378. "");
  379. java.util.concurrent.atomic.AtomicReference<StringBuilder> currentData = new java.util.concurrent.atomic.AtomicReference<>(
  380. new StringBuilder());
  381. response.body().forEach(line -> {
  382. String trimmedLine = line.trim();
  383. if (trimmedLine.isEmpty()) {
  384. // Dispatch event
  385. String event = currentEvent.get();
  386. String data = currentData.get().toString();
  387. if ("endpoint".equals(event)) {
  388. if (!endpointFuture.isDone() && !data.isBlank()) {
  389. endpointFuture.complete(data.trim());
  390. }
  391. } else if ("message".equals(event)) {
  392. try {
  393. JsonNode msg = objectMapper.readTree(data);
  394. if (msg.has("id")) {
  395. int id = msg.get("id").asInt();
  396. CompletableFuture<JsonNode> future = pendingRequests.remove(id);
  397. if (future != null) {
  398. if (msg.has("error")) {
  399. // Convert MCP error to Exception if easier, or just pass the full
  400. // node
  401. // Here we pass the full node and let caller check "error"
  402. future.complete(msg);
  403. } else {
  404. future.complete(msg);
  405. }
  406. }
  407. }
  408. } catch (Exception e) {
  409. System.err.println("Failed to parse SSE message: " + e.getMessage());
  410. }
  411. }
  412. // Reset
  413. currentEvent.set("");
  414. currentData.set(new StringBuilder());
  415. } else if (trimmedLine.startsWith("event:")) {
  416. currentEvent.set(trimmedLine.substring(6).trim());
  417. } else if (trimmedLine.startsWith("data:")) {
  418. currentData.get().append(trimmedLine.substring(5).trim()).append("\n");
  419. }
  420. });
  421. });
  422. String relativeEndpoint = endpointFuture.get(120, TimeUnit.SECONDS);
  423. // Append query parameters from original URL if not present in the new endpoint?
  424. // Usually the session ID is enough, but as seen in testing, original key might
  425. // be irrelevant if session is active.
  426. // But let's just resolve.
  427. this.postEndpoint = uri.resolve(relativeEndpoint).toString();
  428. // 2. Initialize MCP session
  429. JsonNode initResponse = callJsonRpc("initialize", Map.of(
  430. "protocolVersion", "2024-11-05",
  431. "capabilities", Map.of("sampling", Map.of()),
  432. "clientInfo", Map.of("name", "java-client", "version", "1.0")));
  433. if (initResponse.has("error")) {
  434. throw new RuntimeException(
  435. "MCP Initialization failed: " + initResponse.get("error").path("message").asText());
  436. }
  437. sendJsonRpcNotification("notifications/initialized", Map.of());
  438. }
  439. public JsonNode listTools() throws Exception {
  440. JsonNode response = callJsonRpc("tools/list", Map.of());
  441. if (response.has("error")) {
  442. System.err.println("Error listing tools: " + response.get("error").toPrettyString());
  443. return objectMapper.createArrayNode();
  444. }
  445. JsonNode tools = response.path("result").path("tools");
  446. if (tools.isMissingNode()) {
  447. return objectMapper.createArrayNode();
  448. }
  449. // Convert to OpenAI Tool format
  450. ArrayNode llmTools = objectMapper.createArrayNode();
  451. if (tools.isArray()) {
  452. for (JsonNode tool : tools) {
  453. ObjectNode llmTool = objectMapper.createObjectNode();
  454. llmTool.put("type", "function");
  455. ObjectNode function = llmTool.putObject("function");
  456. function.put("name", tool.path("name").asText());
  457. function.put("description", tool.path("description").asText());
  458. function.set("parameters", tool.path("inputSchema"));
  459. llmTools.add(llmTool);
  460. }
  461. }
  462. return llmTools;
  463. }
  464. public String callTool(String name, String argumentsJson) throws Exception {
  465. Map<String, Object> args = objectMapper.readValue(argumentsJson, new TypeReference<Map<String, Object>>() {
  466. });
  467. JsonNode response = callJsonRpc("tools/call", Map.of(
  468. "name", name,
  469. "arguments", args));
  470. if (response.has("error")) {
  471. return "Error: " + response.get("error").path("message").asText();
  472. }
  473. JsonNode content = response.path("result").path("content");
  474. List<String> texts = new ArrayList<>();
  475. if (content.isArray()) {
  476. for (JsonNode item : content) {
  477. if ("text".equals(item.path("type").asText())) {
  478. texts.add(item.path("text").asText());
  479. }
  480. }
  481. }
  482. return String.join("\n", texts);
  483. }
  484. private JsonNode callJsonRpc(String method, Object params) throws Exception {
  485. int id;
  486. synchronized (this) {
  487. id = requestId++;
  488. }
  489. CompletableFuture<JsonNode> future = new CompletableFuture<>();
  490. pendingRequests.put(id, future);
  491. try {
  492. Map<String, Object> request = new HashMap<>();
  493. request.put("jsonrpc", "2.0");
  494. request.put("id", id);
  495. request.put("method", method);
  496. request.put("params", params);
  497. HttpResponse<String> response = sendRpcHttpRequest(request);
  498. if (response.statusCode() >= 400) {
  499. throw new RuntimeException("MCP RPC failed: " + response.body());
  500. }
  501. // Wait for response from SSE
  502. return future.get(30, TimeUnit.SECONDS);
  503. } catch (Exception e) {
  504. pendingRequests.remove(id);
  505. throw e;
  506. }
  507. }
  508. private void sendJsonRpcNotification(String method, Object params) throws Exception {
  509. Map<String, Object> request = new HashMap<>();
  510. request.put("jsonrpc", "2.0");
  511. request.put("method", method);
  512. request.put("params", params);
  513. HttpResponse<String> response = sendRpcHttpRequest(request);
  514. if (response.statusCode() >= 400) {
  515. throw new RuntimeException("MCP Notification failed: " + response.body());
  516. }
  517. }
  518. private HttpResponse<String> sendRpcHttpRequest(Map<String, Object> request) throws Exception {
  519. String requestBody = objectMapper.writeValueAsString(request);
  520. HttpRequest postReq = HttpRequest.newBuilder()
  521. .uri(URI.create(postEndpoint))
  522. .header("Content-Type", "application/json")
  523. .POST(HttpRequest.BodyPublishers.ofString(requestBody))
  524. .build();
  525. return client.send(postReq, HttpResponse.BodyHandlers.ofString());
  526. }
  527. @Override
  528. public void close() {
  529. if (sseTask != null) {
  530. sseTask.cancel(true);
  531. }
  532. }
  533. }
  534. }