pull/8757/merge
陈锐 2025-09-05 01:09:47 +08:00 committed by GitHub
commit 2a370d746a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 343 additions and 22 deletions

View File

@ -16,10 +16,10 @@ public class WebSocketConfig {
* ServerEndpointExporter
* bean使@ServerEndpointWebsocket endpoint
*/
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
// @Bean
// public ServerEndpointExporter serverEndpointExporter() {
// return new ServerEndpointExporter();
// }
@Bean
public WebsocketFilter websocketFilter(){

View File

@ -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;
/**
* querytoken
* @author eightmonth
* @date 2024/7/3 14:04
* Undertow Tomcat ClassCastException
*
*
* 1. Authorization Bearer <token>
* 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<String> getHeaders(String name) {
if ("Authorization".equalsIgnoreCase(name)) {
return Collections.enumeration(Collections.singletonList(authorization));
}
return super.getHeaders(name);
}
@Override
public Enumeration<String> getHeaderNames() {
Set<String> names = new LinkedHashSet<>();
Enumeration<String> e = super.getHeaderNames();
while (e.hasMoreElements()) {
names.add(e.nextElement());
}
names.add("Authorization");
return Collections.enumeration(names);
}
}
}

View File

@ -207,6 +207,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))

View File

@ -22,7 +22,7 @@ import java.util.Map;
*/
@Slf4j
@Component
@ServerEndpoint("/vxeSocket/{userId}/{pageId}")
//@ServerEndpoint("/vxeSocket/{userId}/{pageId}")
public class VxeSocket {
/**

View File

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.jeecgframework.boot</groupId>
<artifactId>jeecg-boot-parent</artifactId>
<version>3.8.2</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<artifactId>jeecg-module-mcp-server</artifactId>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- SYSTEM 系统管理模块 -->
<dependency>
<groupId>org.jeecgframework.boot</groupId>
<artifactId>jeecg-system-biz</artifactId>
<version>${jeecgboot.version}</version>
</dependency>
<!-- MCP-Server -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
</dependencies>
</project>

View File

@ -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<String, Object> arguments = JSONObject.parseObject(toolExecutionRequest.arguments());
String username = arguments.get("username").toString();
List<JSONObject> 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);
}
}

View File

@ -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();
}
}

View File

@ -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<JSONObject> 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 "创建部门成功";
}
}

View File

@ -21,7 +21,7 @@ import lombok.extern.slf4j.Slf4j;
*/
@Component
@Slf4j
@ServerEndpoint("/websocket/{userId}")
//@ServerEndpoint("/websocket/{userId}")
public class WebSocket {
/**线程安全Map*/

View File

@ -25,6 +25,12 @@
<version>${jeecgboot.version}</version>
</dependency>
<dependency>
<groupId>org.jeecgframework.boot</groupId>
<artifactId>jeecg-module-mcp-server</artifactId>
<version>${jeecgboot.version}</version>
</dependency>
<!-- flyway 数据库自动升级
<dependency>
<groupId>org.flywaydb</groupId>

View File

@ -25,6 +25,12 @@ management:
include: metrics,httpexchanges,jeecghttptrace
spring:
ai:
mcp:
server:
name: jeecg-mcp-server
version: 1.0.0
base-url: /jeecgboot
flyway:
# 是否启用flyway
enabled: true
@ -151,7 +157,7 @@ spring:
slow-sql-millis: 5000
datasource:
master:
url: jdbc:mysql://127.0.0.1:3306/jeecg-boot?characterEncoding=UTF-8&useUnicode=true&useSSL=false&tinyInt1isBit=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai
url: jdbc:mysql://jeecg-boot-mysql:3306/jeecg-boot?characterEncoding=UTF-8&useUnicode=true&useSSL=false&tinyInt1isBit=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
@ -165,7 +171,7 @@ spring:
data:
redis:
database: 0
host: 127.0.0.1
host: jeecg-boot-redis
port: 6379
password:
#mybatis plus 设置

View File

@ -167,6 +167,14 @@
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- spring-ai -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.0.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- system 模块-->
<dependency>
@ -481,7 +489,7 @@
<dependency>
<groupId>org.jeecgframework.boot</groupId>
<artifactId>jeecg-boot-starter3-chatgpt</artifactId>
<version>${jeecgboot.version}</version>
<version>3.8.3</version>
</dependency>
<!--flyway 支持 mysql5.7+、MariaDB10.3.16-->
<!--mysql5.6需要把版本号改成5.2.1-->