From b704d150f5bc263c2e1287d3488e3418ab3f32f3 Mon Sep 17 00:00:00 2001 From: mengnankkkk Date: Mon, 15 Sep 2025 14:34:23 +0800 Subject: [PATCH 1/3] feat(mcp):MCP supports multi-level data extraction --- .../spring-ai-alibaba-mcp-router/pom.xml | 18 ++ .../jsontemplate/ResponseTemplateParser.java | 64 ++++++- .../ResponseTemplateParserTest.java | 176 ++++++++++++++++++ 3 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 spring-ai-alibaba-mcp/spring-ai-alibaba-mcp-router/src/test/java/com/alibaba/cloud/ai/mcp/gateway/core/jsontemplate/ResponseTemplateParserTest.java diff --git a/spring-ai-alibaba-mcp/spring-ai-alibaba-mcp-router/pom.xml b/spring-ai-alibaba-mcp/spring-ai-alibaba-mcp-router/pom.xml index b352dc1530..d8156cadea 100644 --- a/spring-ai-alibaba-mcp/spring-ai-alibaba-mcp-router/pom.xml +++ b/spring-ai-alibaba-mcp/spring-ai-alibaba-mcp-router/pom.xml @@ -72,6 +72,13 @@ json-path + + + com.github.jknack + handlebars + 4.3.1 + + org.apache.commons commons-lang3 @@ -113,6 +120,17 @@ + + + org.junit.jupiter + junit-jupiter + test + + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/spring-ai-alibaba-mcp/spring-ai-alibaba-mcp-router/src/main/java/com/alibaba/cloud/ai/mcp/gateway/core/jsontemplate/ResponseTemplateParser.java b/spring-ai-alibaba-mcp/spring-ai-alibaba-mcp-router/src/main/java/com/alibaba/cloud/ai/mcp/gateway/core/jsontemplate/ResponseTemplateParser.java index 9cbe0c7821..b18d843fa0 100644 --- a/spring-ai-alibaba-mcp/spring-ai-alibaba-mcp-router/src/main/java/com/alibaba/cloud/ai/mcp/gateway/core/jsontemplate/ResponseTemplateParser.java +++ b/spring-ai-alibaba-mcp/spring-ai-alibaba-mcp-router/src/main/java/com/alibaba/cloud/ai/mcp/gateway/core/jsontemplate/ResponseTemplateParser.java @@ -16,7 +16,10 @@ package com.alibaba.cloud.ai.mcp.gateway.core.jsontemplate; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.jknack.handlebars.Handlebars; +import com.github.jknack.handlebars.Template; import com.jayway.jsonpath.JsonPath; import org.springframework.util.StringUtils; @@ -28,10 +31,15 @@ public class ResponseTemplateParser { private static final ObjectMapper objectMapper = new ObjectMapper(); - // 支持 {{.}} 或 {{.xxx}} 变量 + private static final Handlebars handlebars = new Handlebars(); + + // 支持 {{.}} 或 {{.xxx}} 或 {{.xxx.yyy}} 等多层级变量 private static final Pattern TEMPLATE_PATTERN = Pattern.compile("\\{\\{\\s*\\.([\\w\\$\\[\\]\\.]*)\\s*}}", Pattern.DOTALL); + // 检测是否包含多层级路径访问(如 {{.xxx.yyy}}) + private static final Pattern MULTI_LEVEL_PATTERN = Pattern.compile("\\{\\{\\s*\\.\\w+\\.[\\w\\.]+\\s*}}"); + /** * 处理响应模板 * @param rawResponse 原始响应(JSON或文本) @@ -55,12 +63,62 @@ public static String parse(String rawResponse, String responseTemplate) { } } - // 模板变量替换 + // 检测是否包含多层级路径访问,如果包含则使用 Handlebars 引擎 + if (MULTI_LEVEL_PATTERN.matcher(responseTemplate).find()) { + return parseWithHandlebars(rawResponse, responseTemplate); + } + + // 简单模板变量替换(保持原有逻辑以确保向后兼容) + return parseWithSimpleTemplate(rawResponse, responseTemplate); + } + + /** + * 使用 Handlebars 引擎处理多层级模板 兼容 higress.cn/ai/mcp-server 的模板语法 {{ .xxx.yyy }} + */ + private static String parseWithHandlebars(String rawResponse, String responseTemplate) { + try { + // 1. 预处理模板:转换语法以兼容 Handlebars + String handlebarsTemplateStr = responseTemplate + // 移除点号前缀:{{ .xxx.yyy }} -> {{xxx.yyy}} + .replaceAll("\\{\\{\\s*\\.", "{{") + // 转换数组访问语法:{{users.[0].name}} -> {{users.0.name}} + .replaceAll("\\[([0-9]+)\\]", "$1"); + + // 2. 编译模板 + Template template = handlebars.compileInline(handlebarsTemplateStr); + + // 3. 准备数据上下文:将JSON字符串解析为 Map + Map dataContext; + boolean isJson = rawResponse.trim().startsWith("{") || rawResponse.trim().startsWith("["); + if (isJson) { + dataContext = objectMapper.readValue(rawResponse, new TypeReference>() { + }); + } + else { + // 非JSON数据,创建一个包含原始响应的上下文 + dataContext = Map.of("_raw", rawResponse); + } + + // 4. 应用模板并返回结果 + return template.apply(dataContext); + + } + catch (Exception e) { + // Handlebars 处理失败,降级为简单模板处理 + return parseWithSimpleTemplate(rawResponse, responseTemplate); + } + } + + /** + * 简单模板变量替换(原有逻辑) + */ + private static String parseWithSimpleTemplate(String rawResponse, String responseTemplate) { try { Map context = null; boolean isJson = rawResponse.trim().startsWith("{") || rawResponse.trim().startsWith("["); if (isJson) { - context = objectMapper.readValue(rawResponse, Map.class); + context = objectMapper.readValue(rawResponse, new TypeReference>() { + }); } StringBuffer sb = new StringBuffer(); Matcher matcher = TEMPLATE_PATTERN.matcher(responseTemplate); diff --git a/spring-ai-alibaba-mcp/spring-ai-alibaba-mcp-router/src/test/java/com/alibaba/cloud/ai/mcp/gateway/core/jsontemplate/ResponseTemplateParserTest.java b/spring-ai-alibaba-mcp/spring-ai-alibaba-mcp-router/src/test/java/com/alibaba/cloud/ai/mcp/gateway/core/jsontemplate/ResponseTemplateParserTest.java new file mode 100644 index 0000000000..af2d720d01 --- /dev/null +++ b/spring-ai-alibaba-mcp/spring-ai-alibaba-mcp-router/src/test/java/com/alibaba/cloud/ai/mcp/gateway/core/jsontemplate/ResponseTemplateParserTest.java @@ -0,0 +1,176 @@ +/* + * Copyright 2024-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.alibaba.cloud.ai.mcp.gateway.core.jsontemplate; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; + + +class ResponseTemplateParserTest { + + @Test + void shouldReturnRawResponseWhenTemplateIsEmpty() { + String rawResponse = "{\"status\": \"success\"}"; + String template = ""; + + String result = ResponseTemplateParser.parse(rawResponse, template); + + assertEquals(rawResponse, result); + } + + @Test + void shouldReturnRawResponseWhenTemplateIsRootAccess() { + String rawResponse = "{\"status\": \"success\"}"; + String template = "{{.}}"; + + String result = ResponseTemplateParser.parse(rawResponse, template); + + assertEquals(rawResponse, result); + } + + @Test + void shouldExtractSingleLevelValue() { + String rawResponse = "{\"status\": \"success\", \"code\": 200}"; + String template = "Status: {{.status}}"; + + String result = ResponseTemplateParser.parse(rawResponse, template); + + assertEquals("Status: success", result); + } + + @Test + void shouldExtractNestedValue() { + String rawResponse = "{\"location\": {\"province\": \"Zhejiang\", \"city\": \"Hangzhou\"}, \"code\": 200}"; + String template = "The city is {{.location.city}} and the code is {{.code}}."; + + String result = ResponseTemplateParser.parse(rawResponse, template); + + assertEquals("The city is Hangzhou and the code is 200.", result); + } + + @Test + void shouldExtractDeeplyNestedValue() { + String rawResponse = "{\"data\": {\"user\": {\"profile\": {\"name\": \"Alice\", \"age\": 30}}, \"timestamp\": 1234567890}}"; + String template = "User {{.data.user.profile.name}} is {{.data.user.profile.age}} years old."; + + String result = ResponseTemplateParser.parse(rawResponse, template); + + assertEquals("User Alice is 30 years old.", result); + } + + @Test + void shouldHandleSpacesInTemplate() { + String rawResponse = "{\"data\": {\"value\": \"test\"}}"; + String template = "The value is {{ .data.value }}."; + + String result = ResponseTemplateParser.parse(rawResponse, template); + + assertEquals("The value is test.", result); + } + + @Test + void shouldHandleMultipleNestedReplacements() { + String rawResponse = "{\"weather\": {\"temperature\": 25, \"humidity\": 60}, \"location\": {\"city\": \"Beijing\"}}"; + String template = "Temperature in {{.location.city}} is {{.weather.temperature}}°C with {{.weather.humidity}}% humidity."; + + String result = ResponseTemplateParser.parse(rawResponse, template); + + assertEquals("Temperature in Beijing is 25°C with 60% humidity.", result); + } + + @Test + void shouldHandleMissingNestedKey() { + String rawResponse = "{\"location\": {\"city\": \"Hangzhou\"}}"; + String template = "City: {{.location.city}}, Country: {{.location.country}}"; + + String result = ResponseTemplateParser.parse(rawResponse, template); + + assertEquals("City: Hangzhou, Country: ", result); + } + + @Test + void shouldHandleArrayAccess() { + String rawResponse = "{\"users\": [{\"name\": \"Alice\"}, {\"name\": \"Bob\"}]}"; + String template = "First user: {{.users.0.name}}"; + + String result = ResponseTemplateParser.parse(rawResponse, template); + + assertEquals("First user: Alice", result); + } + + @Test + void shouldWorkWithJsonPathWhenStartsWithDollar() { + String rawResponse = "{\"location\": {\"city\": \"Hangzhou\"}}"; + String template = "$.location.city"; + + String result = ResponseTemplateParser.parse(rawResponse, template); + + assertEquals("Hangzhou", result); + } + + @Test + void shouldFallbackToSimpleTemplateWhenNoMultiLevel() { + String rawResponse = "{\"status\": \"success\", \"message\": \"OK\"}"; + String template = "{{.status}}: {{.message}}"; + + String result = ResponseTemplateParser.parse(rawResponse, template); + + assertEquals("success: OK", result); + } + + @Test + void shouldHandleNonJsonResponse() { + String rawResponse = "Simple text response"; + String template = "Response: {{.}}"; + + String result = ResponseTemplateParser.parse(rawResponse, template); + + assertEquals("Response: Simple text response", result); + } + + @Test + void shouldHandleComplexNestedStructure() { + String rawResponse = "{\"api\": {\"response\": {\"data\": {\"items\": [{\"id\": 1, \"name\": \"Item1\"}, {\"id\": 2, \"name\": \"Item2\"}], \"total\": 2}, \"status\": {\"code\": 200, \"message\": \"success\"}}}}"; + String template = "Status: {{.api.response.status.message}}, Total items: {{.api.response.data.total}}, First item: {{.api.response.data.items.[0].name}}"; + + String result = ResponseTemplateParser.parse(rawResponse, template); + + assertEquals("Status: success, Total items: 2, First item: Item1", result); + } + + @Test + void shouldHandleNumbersAndBooleans() { + String rawResponse = "{\"config\": {\"enabled\": true, \"maxRetries\": 3, \"timeout\": 30.5}}"; + String template = "Enabled: {{.config.enabled}}, Max retries: {{.config.maxRetries}}, Timeout: {{.config.timeout}}"; + + String result = ResponseTemplateParser.parse(rawResponse, template); + + assertEquals("Enabled: true, Max retries: 3, Timeout: 30.5", result); + } + + @Test + void shouldHandleEmptyObjectsAndNulls() { + String rawResponse = "{\"data\": null, \"empty\": {}, \"info\": {\"value\": \"test\"}}"; + String template = "Data: {{.data}}, Info: {{.info.value}}"; + + String result = ResponseTemplateParser.parse(rawResponse, template); + + // Handlebars 将 null 值渲染为空字符串,这是符合模板引擎标准行为的 + assertEquals("Data: , Info: test", result); + } + +} From fe519b78edb81104972a363fb4bcdc977895d837 Mon Sep 17 00:00:00 2001 From: mengnankkkk Date: Mon, 15 Sep 2025 14:53:52 +0800 Subject: [PATCH 2/3] Update ResponseTemplateParserTest.java --- .../gateway/core/jsontemplate/ResponseTemplateParserTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/spring-ai-alibaba-mcp/spring-ai-alibaba-mcp-router/src/test/java/com/alibaba/cloud/ai/mcp/gateway/core/jsontemplate/ResponseTemplateParserTest.java b/spring-ai-alibaba-mcp/spring-ai-alibaba-mcp-router/src/test/java/com/alibaba/cloud/ai/mcp/gateway/core/jsontemplate/ResponseTemplateParserTest.java index af2d720d01..6458cba067 100644 --- a/spring-ai-alibaba-mcp/spring-ai-alibaba-mcp-router/src/test/java/com/alibaba/cloud/ai/mcp/gateway/core/jsontemplate/ResponseTemplateParserTest.java +++ b/spring-ai-alibaba-mcp/spring-ai-alibaba-mcp-router/src/test/java/com/alibaba/cloud/ai/mcp/gateway/core/jsontemplate/ResponseTemplateParserTest.java @@ -19,7 +19,6 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; - class ResponseTemplateParserTest { @Test From c3151a439f4c2011d251f7bf77bf9b807096cd00 Mon Sep 17 00:00:00 2001 From: mengnankkkk Date: Tue, 16 Sep 2025 10:53:34 +0800 Subject: [PATCH 3/3] fear(mcp):Add regular expression test and modify comments --- .../jsontemplate/ResponseTemplateParser.java | 42 +++++++---------- .../ResponseTemplateParserTest.java | 46 ++++++++++++++++++- 2 files changed, 62 insertions(+), 26 deletions(-) diff --git a/spring-ai-alibaba-mcp/spring-ai-alibaba-mcp-router/src/main/java/com/alibaba/cloud/ai/mcp/gateway/core/jsontemplate/ResponseTemplateParser.java b/spring-ai-alibaba-mcp/spring-ai-alibaba-mcp-router/src/main/java/com/alibaba/cloud/ai/mcp/gateway/core/jsontemplate/ResponseTemplateParser.java index b18d843fa0..dbe69a3d02 100644 --- a/spring-ai-alibaba-mcp/spring-ai-alibaba-mcp-router/src/main/java/com/alibaba/cloud/ai/mcp/gateway/core/jsontemplate/ResponseTemplateParser.java +++ b/spring-ai-alibaba-mcp/spring-ai-alibaba-mcp-router/src/main/java/com/alibaba/cloud/ai/mcp/gateway/core/jsontemplate/ResponseTemplateParser.java @@ -33,61 +33,58 @@ public class ResponseTemplateParser { private static final Handlebars handlebars = new Handlebars(); - // 支持 {{.}} 或 {{.xxx}} 或 {{.xxx.yyy}} 等多层级变量 + // Supports {{.}} or {{.xxx}} or {{.xxx.yyy}} multi-level variables private static final Pattern TEMPLATE_PATTERN = Pattern.compile("\\{\\{\\s*\\.([\\w\\$\\[\\]\\.]*)\\s*}}", Pattern.DOTALL); - // 检测是否包含多层级路径访问(如 {{.xxx.yyy}}) + // Detects multi-level path access patterns like {{.xxx.yyy}} + // This regex is fully covered by unit tests in ResponseTemplateParserTest.java private static final Pattern MULTI_LEVEL_PATTERN = Pattern.compile("\\{\\{\\s*\\.\\w+\\.[\\w\\.]+\\s*}}"); /** - * 处理响应模板 - * @param rawResponse 原始响应(JSON或文本) - * @param responseTemplate 模板字符串(可为jsonPath、模板、null/空) - * @return 处理后的字符串 + * Process response template + * @param rawResponse raw response (JSON or text) + * @param responseTemplate template string (can be jsonPath, template, null/empty) + * @return processed string */ public static String parse(String rawResponse, String responseTemplate) { if (!StringUtils.hasText(responseTemplate) || "{{.}}".equals(responseTemplate.trim())) { - // 原样输出 + // Return raw output return rawResponse; } - // jsonPath 提取 + // JsonPath extraction if (responseTemplate.trim().startsWith("$.") || responseTemplate.trim().startsWith("$[")) { try { Object result = JsonPath.read(rawResponse, responseTemplate.trim()); return result != null ? result.toString() : ""; } catch (Exception e) { - // jsonPath 失败,降级为模板处理 + // JsonPath failed, fallback to template processing } } - // 检测是否包含多层级路径访问,如果包含则使用 Handlebars 引擎 + // Detect multi-level path access if (MULTI_LEVEL_PATTERN.matcher(responseTemplate).find()) { return parseWithHandlebars(rawResponse, responseTemplate); } - // 简单模板变量替换(保持原有逻辑以确保向后兼容) + // Simple template variable replacement (maintain backward compatibility) return parseWithSimpleTemplate(rawResponse, responseTemplate); } - /** - * 使用 Handlebars 引擎处理多层级模板 兼容 higress.cn/ai/mcp-server 的模板语法 {{ .xxx.yyy }} - */ private static String parseWithHandlebars(String rawResponse, String responseTemplate) { try { - // 1. 预处理模板:转换语法以兼容 Handlebars + // 1. Preprocess template: convert syntax to be compatible with Handlebars String handlebarsTemplateStr = responseTemplate - // 移除点号前缀:{{ .xxx.yyy }} -> {{xxx.yyy}} + // Remove dot prefix: {{ .xxx.yyy }} -> {{xxx.yyy}} .replaceAll("\\{\\{\\s*\\.", "{{") - // 转换数组访问语法:{{users.[0].name}} -> {{users.0.name}} + // Convert array access syntax: {{users.[0].name}} -> {{users.0.name}} .replaceAll("\\[([0-9]+)\\]", "$1"); - // 2. 编译模板 + // 2. Compile template Template template = handlebars.compileInline(handlebarsTemplateStr); - // 3. 准备数据上下文:将JSON字符串解析为 Map Map dataContext; boolean isJson = rawResponse.trim().startsWith("{") || rawResponse.trim().startsWith("["); if (isJson) { @@ -95,23 +92,18 @@ private static String parseWithHandlebars(String rawResponse, String responseTem }); } else { - // 非JSON数据,创建一个包含原始响应的上下文 + // Non-JSON data, create a context containing the raw response dataContext = Map.of("_raw", rawResponse); } - // 4. 应用模板并返回结果 return template.apply(dataContext); } catch (Exception e) { - // Handlebars 处理失败,降级为简单模板处理 return parseWithSimpleTemplate(rawResponse, responseTemplate); } } - /** - * 简单模板变量替换(原有逻辑) - */ private static String parseWithSimpleTemplate(String rawResponse, String responseTemplate) { try { Map context = null; diff --git a/spring-ai-alibaba-mcp/spring-ai-alibaba-mcp-router/src/test/java/com/alibaba/cloud/ai/mcp/gateway/core/jsontemplate/ResponseTemplateParserTest.java b/spring-ai-alibaba-mcp/spring-ai-alibaba-mcp-router/src/test/java/com/alibaba/cloud/ai/mcp/gateway/core/jsontemplate/ResponseTemplateParserTest.java index 6458cba067..832aad20e8 100644 --- a/spring-ai-alibaba-mcp/spring-ai-alibaba-mcp-router/src/test/java/com/alibaba/cloud/ai/mcp/gateway/core/jsontemplate/ResponseTemplateParserTest.java +++ b/spring-ai-alibaba-mcp/spring-ai-alibaba-mcp-router/src/test/java/com/alibaba/cloud/ai/mcp/gateway/core/jsontemplate/ResponseTemplateParserTest.java @@ -18,6 +18,10 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import java.util.regex.Pattern; class ResponseTemplateParserTest { @@ -168,8 +172,48 @@ void shouldHandleEmptyObjectsAndNulls() { String result = ResponseTemplateParser.parse(rawResponse, template); - // Handlebars 将 null 值渲染为空字符串,这是符合模板引擎标准行为的 + // Handlebars renders null values as empty strings, which is standard template + // engine behavior assertEquals("Data: , Info: test", result); } + // Regex pattern tests to validate MULTI_LEVEL_PATTERN detection + @Test + void shouldDetectMultiLevelPatternsCorrectly() { + Pattern MULTI_LEVEL_PATTERN = Pattern.compile("\\{\\{\\s*\\.\\w+\\.[\\w\\.]+\\s*}}"); + + // Should match multi-level patterns + assertTrue(MULTI_LEVEL_PATTERN.matcher("{{.user.name}}").find()); + assertTrue(MULTI_LEVEL_PATTERN.matcher("{{ .location.city }}").find()); + assertTrue(MULTI_LEVEL_PATTERN.matcher("{{.data.user.profile.name}}").find()); + assertTrue(MULTI_LEVEL_PATTERN.matcher("{{.api.response.status.code}}").find()); + + // Should NOT match single-level patterns + assertFalse(MULTI_LEVEL_PATTERN.matcher("{{.}}").find()); + assertFalse(MULTI_LEVEL_PATTERN.matcher("{{.status}}").find()); + assertFalse(MULTI_LEVEL_PATTERN.matcher("{{.message}}").find()); + + // Should NOT match non-template strings + assertFalse(MULTI_LEVEL_PATTERN.matcher("plain text").find()); + assertFalse(MULTI_LEVEL_PATTERN.matcher("$.location.city").find()); + } + + @Test + void shouldMatchTemplatePatternCorrectly() { + Pattern TEMPLATE_PATTERN = Pattern.compile("\\{\\{\\s*\\.([\\w\\$\\[\\]\\.]*)\\s*}}", Pattern.DOTALL); + + // Should match all template patterns + assertTrue(TEMPLATE_PATTERN.matcher("{{.}}").find()); + assertTrue(TEMPLATE_PATTERN.matcher("{{.status}}").find()); + assertTrue(TEMPLATE_PATTERN.matcher("{{.user.name}}").find()); + assertTrue(TEMPLATE_PATTERN.matcher("{{ .location.city }}").find()); + assertTrue(TEMPLATE_PATTERN.matcher("{{.users.[0].name}}").find()); + assertTrue(TEMPLATE_PATTERN.matcher("{{.data$}}").find()); + + // Should NOT match non-template patterns + assertFalse(TEMPLATE_PATTERN.matcher("$.location.city").find()); + assertFalse(TEMPLATE_PATTERN.matcher("plain text").find()); + assertFalse(TEMPLATE_PATTERN.matcher("{single brace}").find()); + } + }