From 3d4e6ba9402c35e3efb7084bf79361d1546f0e8b Mon Sep 17 00:00:00 2001 From: chenrui Date: Fri, 22 Aug 2025 16:31:39 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E6=B7=BB=E5=8A=A0McpServer=E5=92=8Cfunctio?= =?UTF-8?q?nCall=E7=9A=84=E7=A4=BA=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jeecg/config/security/SecurityConfig.java | 3 + .../jeecg-module-mcp-server/pom.xml | 36 +++++ .../modules/mcp/LlmToolsTestController.java | 130 ++++++++++++++++++ .../jeecg/modules/mcp/McpConfiguration.java | 20 +++ .../org/jeecg/modules/mcp/UserMcpTool.java | 47 +++++++ .../jeecg-system-start/pom.xml | 6 + .../src/main/resources/application-dev.yml | 10 +- jeecg-boot/pom.xml | 10 +- 8 files changed, 259 insertions(+), 3 deletions(-) create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-mcp-server/pom.xml create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-mcp-server/src/main/java/org/jeecg/modules/mcp/LlmToolsTestController.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-mcp-server/src/main/java/org/jeecg/modules/mcp/McpConfiguration.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-mcp-server/src/main/java/org/jeecg/modules/mcp/UserMcpTool.java diff --git a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/security/SecurityConfig.java b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/security/SecurityConfig.java index 58af2ba22..911a9a090 100644 --- a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/security/SecurityConfig.java +++ b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/security/SecurityConfig.java @@ -188,6 +188,9 @@ public class SecurityConfig { .requestMatchers(AntPathRequestMatcher.antMatcher("/openapi/call/**")).permitAll() // APP版本信息 .requestMatchers(AntPathRequestMatcher.antMatcher("/sys/version/app3version")).permitAll() + // mcp接口 + .requestMatchers(AntPathRequestMatcher.antMatcher("/sse")).permitAll() + .requestMatchers(AntPathRequestMatcher.antMatcher("/mcp/message")).permitAll() .anyRequest().authenticated() ) .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)) diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-mcp-server/pom.xml b/jeecg-boot/jeecg-boot-module/jeecg-module-mcp-server/pom.xml new file mode 100644 index 000000000..5817a07df --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-mcp-server/pom.xml @@ -0,0 +1,36 @@ + + + 4.0.0 + + org.jeecgframework.boot + jeecg-boot-parent + 3.8.2 + ../../pom.xml + + + jeecg-module-mcp-server + + + 17 + 17 + UTF-8 + + + + + + + org.jeecgframework.boot + jeecg-system-biz + ${jeecgboot.version} + + + + org.springframework.ai + spring-ai-starter-mcp-server-webmvc + + + + \ No newline at end of file diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-mcp-server/src/main/java/org/jeecg/modules/mcp/LlmToolsTestController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-mcp-server/src/main/java/org/jeecg/modules/mcp/LlmToolsTestController.java new file mode 100644 index 000000000..74c837648 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-mcp-server/src/main/java/org/jeecg/modules/mcp/LlmToolsTestController.java @@ -0,0 +1,130 @@ +package org.jeecg.modules.mcp; + +import com.alibaba.fastjson.JSONObject; +import dev.langchain4j.agent.tool.ToolSpecification; +import dev.langchain4j.mcp.McpToolProvider; +import dev.langchain4j.mcp.client.DefaultMcpClient; +import dev.langchain4j.mcp.client.McpClient; +import dev.langchain4j.mcp.client.transport.McpTransport; +import dev.langchain4j.mcp.client.transport.http.HttpMcpTransport; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.json.JsonObjectSchema; +import dev.langchain4j.model.chat.request.json.JsonStringSchema; +import dev.langchain4j.service.AiServices; +import dev.langchain4j.service.tool.ToolExecutor; +import lombok.extern.slf4j.Slf4j; +import org.jeecg.ai.assistant.AiChatAssistant; +import org.jeecg.ai.factory.AiModelFactory; +import org.jeecg.ai.factory.AiModelOptions; +import org.jeecg.common.api.vo.Result; +import org.jeecg.common.system.api.ISysBaseAPI; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +/** + * @Description: llm工具调用测试接口 + * @Author: chenrui + * @Date: 2025/8/22 14:13 + */ +@RestController +@RequestMapping("/ai/tools/test") +@Slf4j +public class LlmToolsTestController { + + + @Autowired + ISysBaseAPI sysBaseAPI; + + // 根据环境构建模型配置;缺少关键项则返回 null 以便测试跳过 + private static AiModelOptions buildModelOptionsFromEnv() { + String baseUrl = "https://api.gpt.ge"; + String apiKey = "sk-ZLhvUUGPGyERkPya632f3f18209946F7A51d4479081a3dFb"; + String modelName = "gpt-4.1-mini"; + return AiModelOptions.builder() + .provider(AiModelFactory.AIMODEL_TYPE_OPENAI) + .modelName(modelName) + .baseUrl(baseUrl) + .apiKey(apiKey) + .build(); + } + + + @GetMapping(value = "/queryUser") + public Result queryUser(@RequestParam(value = "prompt", required = true) String prompt) { + AiModelOptions modelOp = buildModelOptionsFromEnv(); + ToolSpecification toolSpecification = ToolSpecification.builder() + .name("query_user_by_name") + .description("通过通过用户名查询用户信息,返回用户信息列表") + .parameters(JsonObjectSchema.builder() + .addProperties(Map.of( + "username", JsonStringSchema.builder() + .description("用户名,多个可以使用逗号分隔") + .build() + )) + .build()) + .build(); + ToolExecutor toolExecutor = (toolExecutionRequest, memoryId) -> { + Map arguments = JSONObject.parseObject(toolExecutionRequest.arguments()); + String username = arguments.get("username").toString(); + List users = sysBaseAPI.queryUsersByUsernames(username); + return JSONObject.toJSONString(users); + }; + + // 构建同步聊天模型并通过 AiChatAssistant 发起一次非流式对话 + ChatModel chatModel = AiModelFactory.createChatModel(modelOp); + AiChatAssistant bot = AiServices.builder(AiChatAssistant.class) + .chatModel(chatModel) + .tools(Map.of(toolSpecification, toolExecutor)) + .tools() + .build(); + String chat = bot.chat(prompt); + log.info("聊天回复: " + chat); + return Result.OK(chat); + } + + // 根据环境构建 MCP 工具提供者;缺少 URL 则返回 null 以便测试跳过 + private static McpToolProvider buildMcpTool(String sseUrl) { + McpTransport transport = new HttpMcpTransport.Builder() + .sseUrl(sseUrl) + .logRequests(true) + .logResponses(true) + .build(); + McpClient mcpClient = new DefaultMcpClient.Builder() + .transport(transport) + .build(); + return McpToolProvider.builder() + .mcpClients(List.of(mcpClient)) + .build(); + } + + /** + * 测试JeecgMcp + * @author chenrui + * @date 2025/8/22 10:10 + */ + @GetMapping(value = "/queryUserByMcp") + public Result testJeecgToolProvider(@RequestParam(value = "prompt", required = true) String prompt) { +// prompt = "查询一下有没有用户名是admin的用户信息"; +// prompt = "新建一个顶级部门,部门名称是:测试部门,机构类别是公司"; + AiModelOptions modelOp = buildModelOptionsFromEnv(); + McpToolProvider toolProvider = buildMcpTool("http://localhost:8080/jeecgboot/sse"); + + // 构建同步聊天模型并通过 AiChatAssistant 发起一次非流式对话 + ChatModel chatModel = AiModelFactory.createChatModel(modelOp); + AiChatAssistant bot = AiServices.builder(AiChatAssistant.class) + .chatModel(chatModel) + .toolProvider(toolProvider) + .build(); + String chat = bot.chat(prompt); + log.info("聊天回复: " + chat); + return Result.OK(chat); + } + + +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-mcp-server/src/main/java/org/jeecg/modules/mcp/McpConfiguration.java b/jeecg-boot/jeecg-boot-module/jeecg-module-mcp-server/src/main/java/org/jeecg/modules/mcp/McpConfiguration.java new file mode 100644 index 000000000..5fb81717b --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-mcp-server/src/main/java/org/jeecg/modules/mcp/McpConfiguration.java @@ -0,0 +1,20 @@ +package org.jeecg.modules.mcp; + +import org.springframework.ai.tool.ToolCallbackProvider; +import org.springframework.ai.tool.method.MethodToolCallbackProvider; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * @Description: + * @Author: chenrui + * @Date: 2025/8/21 17:19 + */ +@Configuration +public class McpConfiguration { + + @Bean + public ToolCallbackProvider weatherTools(UserMcpTool userMcpTool) { + return MethodToolCallbackProvider.builder().toolObjects(userMcpTool).build(); + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-mcp-server/src/main/java/org/jeecg/modules/mcp/UserMcpTool.java b/jeecg-boot/jeecg-boot-module/jeecg-module-mcp-server/src/main/java/org/jeecg/modules/mcp/UserMcpTool.java new file mode 100644 index 000000000..b97f3886a --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-mcp-server/src/main/java/org/jeecg/modules/mcp/UserMcpTool.java @@ -0,0 +1,47 @@ +package org.jeecg.modules.mcp; + +import com.alibaba.fastjson.JSONObject; +import org.jeecg.common.system.api.ISysBaseAPI; +import org.jeecg.modules.system.entity.SysDepart; +import org.jeecg.modules.system.service.ISysDepartService; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * @Description: + * @Author: chenrui + * @Date: 2025/8/21 17:10 + */ +@Service("userMcpService") +public class UserMcpTool { + + @Autowired + ISysBaseAPI sysBaseAPI; + + @Autowired + ISysDepartService sysDepartmentService; + + @Tool(name = "query_user_by_name", description = "通过通过用户名查询用户信息,返回用户信息列表") + public String queryUserByUsername(@ToolParam(description = "用户名,多个可以使用逗号分隔", required = true) String username) { + if (username == null || username.isEmpty()) { + return "Username cannot be null or empty"; + } + List users = sysBaseAPI.queryUsersByUsernames(username); + return JSONObject.toJSONString(users); + } + + @Tool(name = "add_depart", description = "新增部门信息,返回操作结果") + public String saveDepartData(@ToolParam(description = "部门信息,departName(部门名称,必填),orgCategory(机构类别,必填, 1=公司,2=组织机构,3=岗位),parentId(父部门:父级部门的id,非必填)", required = true) SysDepart sysDepart){ + try { + sysDepartmentService.saveDepartData(sysDepart, "mcpService"); + } catch (Exception e) { + return "创建部门失败,原因:"+e.getMessage(); + } + return "创建部门成功"; + } + +} diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/pom.xml b/jeecg-boot/jeecg-module-system/jeecg-system-start/pom.xml index e5f50200d..b21355a0e 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-start/pom.xml +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/pom.xml @@ -24,6 +24,12 @@ jeecg-module-demo ${jeecgboot.version} + + + org.jeecgframework.boot + jeecg-module-mcp-server + ${jeecgboot.version} + + + org.springframework.ai + spring-ai-bom + 1.0.1 + pom + import + @@ -481,7 +489,7 @@ org.jeecgframework.boot jeecg-boot-starter3-chatgpt - ${jeecgboot.version} + 3.8.3 From 016ab6e5ba9931cb89ff2034ab66dfc18b2d6b34 Mon Sep 17 00:00:00 2001 From: chenrui Date: Fri, 22 Aug 2025 16:32:09 +0800 Subject: [PATCH 2/2] =?UTF-8?q?undertow=E4=B8=8BWebSocket=E4=B8=8Emcp?= =?UTF-8?q?=E4=B8=8D=E5=85=BC=E5=AE=B9=E9=97=AE=E9=A2=98(=E4=B8=B4?= =?UTF-8?q?=E6=97=B6=E5=A4=84=E7=90=86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/jeecg/config/WebSocketConfig.java | 8 +- .../config/security/CopyTokenFilter.java | 91 ++++++++++++++++--- .../demo/mock/vxe/websocket/VxeSocket.java | 2 +- .../modules/message/websocket/WebSocket.java | 2 +- 4 files changed, 84 insertions(+), 19 deletions(-) diff --git a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/WebSocketConfig.java b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/WebSocketConfig.java index 801544b2c..013e62264 100644 --- a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/WebSocketConfig.java +++ b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/WebSocketConfig.java @@ -16,10 +16,10 @@ public class WebSocketConfig { * 注入ServerEndpointExporter, * 这个bean会自动注册使用了@ServerEndpoint注解声明的Websocket endpoint */ - @Bean - public ServerEndpointExporter serverEndpointExporter() { - return new ServerEndpointExporter(); - } +// @Bean +// public ServerEndpointExporter serverEndpointExporter() { +// return new ServerEndpointExporter(); +// } @Bean public WebsocketFilter websocketFilter(){ diff --git a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/security/CopyTokenFilter.java b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/security/CopyTokenFilter.java index de18fbeb0..c8f6b9614 100644 --- a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/security/CopyTokenFilter.java +++ b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/security/CopyTokenFilter.java @@ -1,10 +1,9 @@ package org.jeecg.config.security; -import io.undertow.servlet.spec.HttpServletRequestImpl; -import io.undertow.util.HttpString; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; import jakarta.servlet.http.HttpServletResponse; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; @@ -12,34 +11,100 @@ import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.util.Collections; +import java.util.Enumeration; +import java.util.LinkedHashSet; +import java.util.Set; /** * 复制仪盘表请求query体携带的token - * @author eightmonth - * @date 2024/7/3 14:04 + * 注意:改为容器无关实现,避免 Undertow 专有类型转换导致在 Tomcat 下 ClassCastException。 + * + * 来源优先级: + * 1. Authorization 头(若存在则规范为 Bearer 格式) + * 2. 查询参数 token + * 3. 自定义头 X-Access-Token + * + * 若最终获得 token,则通过请求包装器注入 Authorization 头,保持对下游过滤器/安全链可见。 */ @Component @Order(value = Integer.MIN_VALUE) public class CopyTokenFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - // 以下为undertow定制代码,如切换其它servlet容器,需要同步更换 - HttpServletRequestImpl undertowRequest = (HttpServletRequestImpl) request; - String token = request.getHeader("Authorization"); - if (StringUtils.hasText(token)) { - undertowRequest.getExchange().getRequestHeaders().remove("Authorization"); - undertowRequest.getExchange().getRequestHeaders().add(new HttpString("Authorization"), "bearer " + token); + // 容器无关实现:根据 header/参数提取 token,并以 Authorization 注入 + String tokenHeader = request.getHeader("Authorization"); + String candidate = null; + if (StringUtils.hasText(tokenHeader)) { + String trimmed = tokenHeader.trim(); + if (startsWithIgnoreCase(trimmed, "Bearer ")) { + candidate = trimmed; + } else if (!trimmed.contains(" ")) { // 纯 token,无空格,视为需要规范化 + candidate = trimmed; + } // 其他认证方案(如 Basic ...)保持不处理 } else { String bearerToken = request.getParameter("token"); String headerBearerToken = request.getHeader("X-Access-Token"); if (StringUtils.hasText(bearerToken)) { - undertowRequest.getExchange().getRequestHeaders().add(new HttpString("Authorization"), "bearer " + bearerToken); + candidate = bearerToken.trim(); } else if (StringUtils.hasText(headerBearerToken)) { - undertowRequest.getExchange().getRequestHeaders().add(new HttpString("Authorization"), "bearer " + headerBearerToken); + candidate = headerBearerToken.trim(); } } - filterChain.doFilter(undertowRequest, response); + + if (StringUtils.hasText(candidate)) { + final String authValue = startsWithIgnoreCase(candidate, "Bearer ") ? candidate : ("Bearer " + candidate); + HttpServletRequest wrapped = new AuthorizationHeaderRequestWrapper(request, authValue); + filterChain.doFilter(wrapped, response); + return; + } + + filterChain.doFilter(request, response); } + private boolean startsWithIgnoreCase(String str, String prefix) { + if (str == null || prefix == null) { + return false; + } + if (prefix.length() > str.length()) { + return false; + } + return str.regionMatches(true, 0, prefix, 0, prefix.length()); + } + private static class AuthorizationHeaderRequestWrapper extends HttpServletRequestWrapper { + private final String authorization; + + AuthorizationHeaderRequestWrapper(HttpServletRequest request, String authorization) { + super(request); + this.authorization = authorization; + } + + @Override + public String getHeader(String name) { + if ("Authorization".equalsIgnoreCase(name)) { + return authorization; + } + return super.getHeader(name); + } + + @Override + public Enumeration getHeaders(String name) { + if ("Authorization".equalsIgnoreCase(name)) { + return Collections.enumeration(Collections.singletonList(authorization)); + } + return super.getHeaders(name); + } + + @Override + public Enumeration getHeaderNames() { + Set names = new LinkedHashSet<>(); + Enumeration e = super.getHeaderNames(); + while (e.hasMoreElements()) { + names.add(e.nextElement()); + } + names.add("Authorization"); + return Collections.enumeration(names); + } + } } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-demo/src/main/java/org/jeecg/modules/demo/mock/vxe/websocket/VxeSocket.java b/jeecg-boot/jeecg-boot-module/jeecg-module-demo/src/main/java/org/jeecg/modules/demo/mock/vxe/websocket/VxeSocket.java index 017e8b66e..6643ca9c5 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-demo/src/main/java/org/jeecg/modules/demo/mock/vxe/websocket/VxeSocket.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-demo/src/main/java/org/jeecg/modules/demo/mock/vxe/websocket/VxeSocket.java @@ -22,7 +22,7 @@ import java.util.Map; */ @Slf4j @Component -@ServerEndpoint("/vxeSocket/{userId}/{pageId}") +//@ServerEndpoint("/vxeSocket/{userId}/{pageId}") public class VxeSocket { /** diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/message/websocket/WebSocket.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/message/websocket/WebSocket.java index a6cc51d65..6848a6f11 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/message/websocket/WebSocket.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/message/websocket/WebSocket.java @@ -21,7 +21,7 @@ import lombok.extern.slf4j.Slf4j; */ @Component @Slf4j -@ServerEndpoint("/websocket/{userId}") +//@ServerEndpoint("/websocket/{userId}") public class WebSocket { /**线程安全Map*/