增加mcp工具模块

This commit is contained in:
evo
2026-02-23 16:07:13 +08:00
parent d4f8f91893
commit 593a0d0049
48 changed files with 3643 additions and 56 deletions

View File

@@ -3647,3 +3647,162 @@ INSERT INTO `test_tree` VALUES (12, '000000', 10, 108, 3, '子节点88', 0, 103,
INSERT INTO `test_tree` VALUES (13, '000000', 10, 108, 3, '子节点99', 0, 103, '2026-02-03 05:14:54', 1, NULL, NULL, 0);
SET FOREIGN_KEY_CHECKS = 1;
-- MCP 模块数据库表结构
-- 版本: V3.0.0
-- 描述: MCP 工具管理和 MCP 市场管理表
-- ----------------------------
-- MCP 工具表
-- ----------------------------
DROP TABLE IF EXISTS `mcp_tool_info`;
CREATE TABLE `mcp_tool_info`
(
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '工具ID',
`name` varchar(200) NOT NULL COMMENT '工具名称',
`description` text COMMENT '工具描述',
`type` varchar(20) DEFAULT 'LOCAL' COMMENT '工具类型LOCAL-本地, REMOTE-远程, BUILTIN-内置',
`status` varchar(20) DEFAULT 'ENABLED' COMMENT '状态ENABLED-启用, DISABLED-禁用',
`config_json` text COMMENT '配置信息JSON格式',
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
`create_dept` bigint DEFAULT NULL COMMENT '创建部门',
`create_by` bigint DEFAULT NULL COMMENT '创建者',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` bigint DEFAULT NULL COMMENT '更新者',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`del_flag` char(1) DEFAULT '0' COMMENT '删除标志0代表存在 2代表删除',
PRIMARY KEY (`id`),
KEY `idx_name` (`name`),
KEY `idx_type` (`type`),
KEY `idx_status` (`status`),
KEY `idx_tenant_id` (`tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='MCP工具表';
-- ----------------------------
-- MCP 市场表
-- ----------------------------
DROP TABLE IF EXISTS `mcp_market_info`;
CREATE TABLE `mcp_market_info`
(
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '市场ID',
`name` varchar(200) NOT NULL COMMENT '市场名称',
`url` varchar(500) NOT NULL COMMENT '市场URL',
`description` text COMMENT '市场描述',
`auth_config` text COMMENT '认证配置JSON格式',
`status` varchar(20) DEFAULT 'ENABLED' COMMENT '状态ENABLED-启用, DISABLED-禁用',
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
`create_dept` bigint DEFAULT NULL COMMENT '创建部门',
`create_by` bigint DEFAULT NULL COMMENT '创建者',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` bigint DEFAULT NULL COMMENT '更新者',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`del_flag` char(1) DEFAULT '0' COMMENT '删除标志0代表存在 2代表删除',
PRIMARY KEY (`id`),
KEY `idx_name` (`name`),
KEY `idx_status` (`status`),
KEY `idx_tenant_id` (`tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='MCP市场表';
-- ----------------------------
-- MCP 市场工具关联表
-- ----------------------------
DROP TABLE IF EXISTS `mcp_market_tool`;
CREATE TABLE `mcp_market_tool`
(
`id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID',
`market_id` bigint NOT NULL COMMENT '市场ID',
`tool_name` varchar(200) NOT NULL COMMENT '工具名称',
`tool_description` text COMMENT '工具描述',
`tool_version` varchar(50) COMMENT '工具版本',
`tool_metadata` json COMMENT '工具元数据JSON格式',
`is_loaded` tinyint(1) DEFAULT 0 COMMENT '是否已加载到本地',
`local_tool_id` bigint COMMENT '关联的本地工具ID',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_market_id` (`market_id`),
KEY `idx_tool_name` (`tool_name`),
KEY `idx_is_loaded` (`is_loaded`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='MCP市场工具关联表';
-- MCP 模块菜单权限 SQL
-- 版本: V3.0.1
-- 描述: MCP 工具管理和 MCP 市场管理菜单权限
-- 菜单 ID 规划: 2000-2199
-- ----------------------------
-- MCP 主菜单
-- ----------------------------
INSERT INTO sys_menu
VALUES (2000, 'MCP管理', 0, 5, 'mcp', '', '', 1, 0, 'M', '0', '0', '',
'mdi:robot-industrial', 103, 1, NOW(), NULL, NULL, 'MCP模块管理菜单');
-- ----------------------------
-- MCP 工具管理
-- ----------------------------
INSERT INTO sys_menu
VALUES (2001, 'MCP工具管理', 2000, 1, 'tool', 'mcp/tool/index', '', 1, 0, 'C', '0',
'0', 'mcp:tool:list', 'material-symbols:tools-hammer-outline', 103, 1, NOW(), NULL,
NULL, 'MCP工具管理菜单');
-- MCP 工具管理按钮权限
INSERT INTO sys_menu
VALUES (2002, 'MCP工具查询', 2001, 1, '#', '', '', 1, 0, 'F', '0', '0',
'mcp:tool:query', '#', 103, 1, NOW(), NULL, NULL, '');
INSERT INTO sys_menu
VALUES (2003, 'MCP工具新增', 2001, 2, '#', '', '', 1, 0, 'F', '0', '0',
'mcp:tool:add', '#', 103, 1, NOW(), NULL, NULL, '');
INSERT INTO sys_menu
VALUES (2004, 'MCP工具修改', 2001, 3, '#', '', '', 1, 0, 'F', '0', '0',
'mcp:tool:edit', '#', 103, 1, NOW(), NULL, NULL, '');
INSERT INTO sys_menu
VALUES (2005, 'MCP工具删除', 2001, 4, '#', '', '', 1, 0, 'F', '0', '0',
'mcp:tool:remove', '#', 103, 1, NOW(), NULL, NULL, '');
INSERT INTO sys_menu
VALUES (2006, 'MCP工具测试', 2001, 5, '#', '', '', 1, 0, 'F', '0', '0',
'mcp:tool:test', '#', 103, 1, NOW(), NULL, NULL, '');
INSERT INTO sys_menu
VALUES (2007, 'MCP工具导出', 2001, 6, '#', '', '', 1, 0, 'F', '0', '0',
'mcp:tool:export', '#', 103, 1, NOW(), NULL, NULL, '');
-- ----------------------------
-- MCP 市场管理
-- ----------------------------
INSERT INTO sys_menu
VALUES (2010, 'MCP市场管理', 2000, 2, 'market', 'mcp/market/index', '', 1, 0, 'C', '0',
'0', 'mcp:market:list', 'mdi:storefront-outline', 103, 1, NOW(), NULL, NULL,
'MCP市场管理菜单');
-- MCP 市场管理按钮权限
INSERT INTO sys_menu
VALUES (2011, 'MCP市场查询', 2010, 1, '#', '', '', 1, 0, 'F', '0', '0',
'mcp:market:query', '#', 103, 1, NOW(), NULL, NULL, '');
INSERT INTO sys_menu
VALUES (2012, 'MCP市场新增', 2010, 2, '#', '', '', 1, 0, 'F', '0', '0',
'mcp:market:add', '#', 103, 1, NOW(), NULL, NULL, '');
INSERT INTO sys_menu
VALUES (2013, 'MCP市场修改', 2010, 3, '#', '', '', 1, 0, 'F', '0', '0',
'mcp:market:edit', '#', 103, 1, NOW(), NULL, NULL, '');
INSERT INTO sys_menu
VALUES (2014, 'MCP市场删除', 2010, 4, '#', '', '', 1, 0, 'F', '0', '0',
'mcp:market:remove', '#', 103, 1, NOW(), NULL, NULL, '');
INSERT INTO sys_menu
VALUES (2015, 'MCP市场刷新', 2010, 5, '#', '', '', 1, 0, 'F', '0', '0',
'mcp:market:refresh', '#', 103, 1, NOW(), NULL, NULL, '');
INSERT INTO sys_menu
VALUES (2016, 'MCP工具加载', 2010, 6, '#', '', '', 1, 0, 'F', '0', '0',
'mcp:market:load', '#', 103, 1, NOW(), NULL, NULL, '');
INSERT INTO sys_menu
VALUES (2017, 'MCP市场导出', 2010, 7, '#', '', '', 1, 0, 'F', '0', '0',
'mcp:market:export', '#', 103, 1, NOW(), NULL, NULL, '');
-- ----------------------------
-- MCP 配置管理 (可选,预留扩展)
-- ----------------------------
-- INSERT INTO sys_menu VALUES (2020, 'MCP配置管理', 2000, 3, 'config', 'mcp/config/index', '', 1, 0, 'C', '0',
-- '0', 'mcp:config:list', 'ant-design:setting-outlined', 103, 1, NOW(), NULL, NULL,
-- 'MCP配置管理菜单');

16
pom.xml
View File

@@ -59,6 +59,8 @@
<langgraph4j.version>1.5.3</langgraph4j.version>
<weaviate.version>1.19.6</weaviate.version>
<dify.version>1.0.7</dify.version>
<!-- Apache Commons Compress - 用于POI处理ZIP格式 -->
<commons-compress.version>1.27.1</commons-compress.version>
<avatar-generator.version>1.1.0</avatar-generator.version>
@@ -402,6 +404,13 @@
<version>${revision}</version>
</dependency>
<!-- MCP模块 -->
<dependency>
<groupId>org.ruoyi</groupId>
<artifactId>ruoyi-mcp</artifactId>
<version>${revision}</version>
</dependency>
<!-- 企业微信SDK -->
<dependency>
<groupId>com.github.binarywang</groupId>
@@ -416,6 +425,13 @@
<version>${jackson-dataformat-xml.version}</version>
</dependency>
<!-- Apache Commons Compress - 用于POI处理ZIP格式解决导出Excel时的NoSuchMethodError -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId>
<version>${commons-compress.version}</version>
</dependency>
</dependencies>
</dependencyManagement>

View File

@@ -116,6 +116,12 @@
<artifactId>ruoyi-aiflow</artifactId>
</dependency>
<!-- MCP模块 -->
<dependency>
<groupId>org.ruoyi</groupId>
<artifactId>ruoyi-mcp</artifactId>
</dependency>
<dependency>
<groupId>de.codecentric</groupId>
<artifactId>spring-boot-admin-starter-client</artifactId>

View File

@@ -25,6 +25,12 @@
<groupId>cn.idev.excel</groupId>
<artifactId>fastexcel</artifactId>
</dependency>
<!-- Apache Commons Compress - 用于POI处理ZIP格式解决Excel导出时的依赖冲突 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -6,9 +6,6 @@ import lombok.extern.slf4j.Slf4j;
import org.ruoyi.common.core.utils.SpringUtils;
import org.ruoyi.common.sse.core.SseEmitterManager;
import org.ruoyi.common.sse.dto.SseMessageDto;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
/**
* SSE工具类

View File

@@ -16,6 +16,7 @@
<module>ruoyi-demo</module>
<module>ruoyi-generator</module>
<module>ruoyi-job</module>
<module>ruoyi-mcp</module>
<module>ruoyi-system</module>
<module>ruoyi-wechat</module>
<module>ruoyi-workflow</module>

View File

@@ -17,7 +17,6 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.List;
import static org.ruoyi.workflow.cosntant.AdiConstant.SSE_TIMEOUT;
import static org.ruoyi.workflow.enums.ErrorEnum.*;
@Slf4j

View File

@@ -6,7 +6,6 @@ import lombok.Data;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.SerializationUtils;
import org.apache.commons.lang3.StringUtils;
import org.ruoyi.common.core.exception.base.BaseException;
import org.ruoyi.workflow.base.NodeInputConfigTypeHandler;
import org.ruoyi.workflow.entity.WorkflowComponent;

View File

@@ -1,7 +1,6 @@
package org.ruoyi.workflow.workflow.node.keywordExtractor;
import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.UserMessage;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.ruoyi.workflow.entity.WorkflowComponent;

View File

@@ -19,6 +19,11 @@
<artifactId>ruoyi-common-chat</artifactId>
</dependency>
<dependency>
<groupId>org.ruoyi</groupId>
<artifactId>ruoyi-mcp</artifactId>
</dependency>
<dependency>
<groupId>org.ruoyi</groupId>
<artifactId>ruoyi-common-sensitive</artifactId>

View File

@@ -1,10 +1,7 @@
package org.ruoyi.agent.tool;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import dev.langchain4j.agent.tool.Tool;
import lombok.extern.slf4j.Slf4j;
import org.ruoyi.agent.config.AgentMysqlProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;

View File

@@ -10,8 +10,6 @@ import org.ruoyi.domain.entity.knowledge.KnowledgeGraphInstance;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
/**

View File

@@ -10,8 +10,6 @@ import org.ruoyi.domain.entity.knowledge.KnowledgeGraphSegment;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
/**

View File

@@ -1,6 +1,5 @@
package org.ruoyi.service.chat.impl.provider;
import dev.langchain4j.agent.tool.ToolSpecification;
import dev.langchain4j.agentic.AgenticServices;
import dev.langchain4j.agentic.supervisor.SupervisorAgent;
import dev.langchain4j.agentic.supervisor.SupervisorResponseStrategy;
@@ -9,20 +8,15 @@ import dev.langchain4j.community.model.dashscope.QwenStreamingChatModel;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.UserMessage;
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.StreamableHttpMcpTransport;
import dev.langchain4j.model.chat.StreamingChatModel;
import dev.langchain4j.model.chat.response.StreamingChatResponseHandler;
import dev.langchain4j.service.tool.ToolProvider;
import lombok.extern.slf4j.Slf4j;
import org.ruoyi.agent.McpAgent;
import org.ruoyi.config.McpSseConfig;
import org.ruoyi.enums.ChatModeType;
import org.ruoyi.mcp.service.core.ToolProviderFactory;
import org.ruoyi.service.chat.impl.AbstractStreamingChatService;
import org.springframework.beans.factory.annotation.Autowired;
import org.ruoyi.common.core.utils.SpringUtils;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import org.ruoyi.common.chat.domain.dto.request.ChatRequest;
@@ -41,9 +35,6 @@ import java.util.List;
@Slf4j
public class QianWenChatServiceImpl extends AbstractStreamingChatService {
@Autowired
private McpSseConfig mcpSseConfig;
/**
* 千问开发者默认地址
*/
@@ -103,50 +94,53 @@ public class QianWenChatServiceImpl extends AbstractStreamingChatService {
/**
* 调用MCP服务智能体
* 使用统一的ToolProviderFactory获取所有已配置的工具BUILTIN + MCP
*
* @param userMessage 用户信息
* @param chatModelVo 模型信息
* @return 返回LLM信息
*/
protected String doAgent(String userMessage,ChatModelVo chatModelVo) {
// 判断是否开启MCP服务
if (!mcpSseConfig.isEnabled()) {
return "";
}
protected String doAgent(String userMessage, ChatModelVo chatModelVo) {
// 步骤1: 获取统一工具提供工厂
ToolProviderFactory toolProviderFactory = SpringUtils.getBean(ToolProviderFactory.class);
// 步骤1根据SSE对外暴露端点连接
McpTransport httpMcpTransport = new StreamableHttpMcpTransport.Builder().
url(mcpSseConfig.getUrl()).
logRequests(true).
build();
// 步骤2: 获取 BUILTIN 工具对象
List<Object> builtinTools = toolProviderFactory.getAllBuiltinToolObjects();
// 步骤2开启客户端连接
McpClient mcpClient = new DefaultMcpClient.Builder()
.transport(httpMcpTransport)
.build();
// 步骤3: 获取 MCP 工具提供者
ToolProvider mcpToolProvider = toolProviderFactory.getAllEnabledMcpToolsProvider();
// 获取所有mcp工具
List<ToolSpecification> toolSpecifications = mcpClient.listTools();
System.out.println(toolSpecifications);
log.info("doAgent: BUILTIN tools count = {}, MCP tools enabled = {}",
builtinTools.size(), mcpToolProvider != null);
// 步骤3将mcp对象包装
ToolProvider toolProvider = McpToolProvider.builder()
.mcpClients(List.of(mcpClient))
.build();
// 步骤4加载LLM模型对话
// 步骤4: 加载LLM模型
QwenChatModel qwenChatModel = QwenChatModel.builder()
.baseUrl(QWEN_API_HOST)
.apiKey(chatModelVo.getApiKey())
.modelName(chatModelVo.getModelName())
.build();
// 步骤5将MCP对象由智能体Agent管控
McpAgent mcpAgent = AgenticServices.agentBuilder(McpAgent.class)
.chatModel(qwenChatModel)
.toolProvider(toolProvider)
.build();
// 步骤5: 创建MCP Agent使用所有已配置的工具
// 使用 .tools() 传入 BUILTIN 工具对象Java 对象,带 @Tool 注解的方法)
// 使用 .toolProvider() 传入 MCP 工具提供者MCP 协议工具)
var agentBuilder = AgenticServices.agentBuilder(McpAgent.class)
.chatModel(qwenChatModel);
// 步骤6将所有MCP对象由超级智能体管控
// 添加 BUILTIN 工具(如果有)
if (!builtinTools.isEmpty()) {
agentBuilder.tools(builtinTools.toArray(new Object[0]));
log.debug("Added {} BUILTIN tools to agent", builtinTools.size());
}
// 添加 MCP 工具(如果有)
if (mcpToolProvider != null) {
agentBuilder.toolProvider(mcpToolProvider);
log.debug("Added MCP tool provider to agent");
}
McpAgent mcpAgent = agentBuilder.build();
// 步骤6: 创建超级智能体协调MCP Agent
SupervisorAgent supervisor = AgenticServices
.supervisorBuilder()
.chatModel(qwenChatModel)
@@ -154,7 +148,7 @@ public class QianWenChatServiceImpl extends AbstractStreamingChatService {
.responseStrategy(SupervisorResponseStrategy.LAST)
.build();
// 步骤7调用大模型LLM
// 步骤7: 调用大模型LLM
return supervisor.invoke(userMessage);
}

View File

@@ -12,8 +12,6 @@ import org.ruoyi.common.json.utils.JsonUtils;
import org.ruoyi.common.mybatis.helper.DataBaseHelper;
import org.ruoyi.generator.domain.GenTable;
import org.ruoyi.generator.domain.GenTableColumn;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import org.apache.velocity.VelocityContext;
import java.util.*;

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://maven.apache.org/POM/4.0.0"
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.ruoyi</groupId>
<artifactId>ruoyi-modules</artifactId>
<version>${revision}</version>
</parent>
<artifactId>ruoyi-mcp</artifactId>
<description>
MCP模块 - 管理MCP工具连接、市场集成和内置工具
</description>
<dependencies>
<!-- 通用模块依赖 -->
<dependency>
<groupId>org.ruoyi</groupId>
<artifactId>ruoyi-common-core</artifactId>
</dependency>
<dependency>
<groupId>org.ruoyi</groupId>
<artifactId>ruoyi-common-web</artifactId>
</dependency>
<dependency>
<groupId>org.ruoyi</groupId>
<artifactId>ruoyi-common-mybatis</artifactId>
</dependency>
<dependency>
<groupId>org.ruoyi</groupId>
<artifactId>ruoyi-common-log</artifactId>
</dependency>
<dependency>
<groupId>org.ruoyi</groupId>
<artifactId>ruoyi-common-tenant</artifactId>
</dependency>
<dependency>
<groupId>org.ruoyi</groupId>
<artifactId>ruoyi-common-security</artifactId>
</dependency>
<dependency>
<groupId>org.ruoyi</groupId>
<artifactId>ruoyi-common-excel</artifactId>
</dependency>
<dependency>
<groupId>org.ruoyi</groupId>
<artifactId>ruoyi-common-idempotent</artifactId>
</dependency>
<!-- LangChain4j MCP - 用于 LangChain4j 集成 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-mcp</artifactId>
<version>${langchain4j.community.version}</version>
</dependency>
<!-- Jackson for JSON processing -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,40 @@
package org.ruoyi.mcp.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* MCP 配置属性
*
* @author ruoyi team
*/
@Data
@Component
@ConfigurationProperties(prefix = "app.mcp")
public class McpProperties {
/**
* 客户端配置
*/
private ClientConfig client = new ClientConfig();
@Data
public static class ClientConfig {
/**
* 请求超时时间(秒)
*/
private int requestTimeout = 30;
/**
* 连接超时时间(秒)
*/
private int connectionTimeout = 10;
/**
* 最大重试次数
*/
private int maxRetries = 3;
}
}

View File

@@ -0,0 +1,93 @@
package org.ruoyi.mcp.config;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.ruoyi.mcp.domain.entity.McpTool;
import org.ruoyi.mcp.enums.McpToolStatus;
import org.ruoyi.mcp.mapper.McpToolMapper;
import org.ruoyi.mcp.service.core.BuiltinToolDefinition;
import org.ruoyi.mcp.service.core.BuiltinToolRegistry;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* 系统工具初始化器
* 在应用启动时,将系统内置工具同步到数据库
* 这样可以统一管理所有工具,支持动态启用/禁用
*
* @author ruoyi team
*/
@Slf4j
@Component
@Order(999) // 确保在其他初始化器之后执行
@RequiredArgsConstructor
public class SystemToolInitializer implements ApplicationRunner {
private final McpToolMapper mcpToolMapper;
private final BuiltinToolRegistry builtinToolRegistry;
@Override
@Transactional
public void run(ApplicationArguments args) {
log.info("开始同步系统内置工具到数据库...");
int addedCount = 0;
int existingCount = 0;
for (BuiltinToolDefinition tool : builtinToolRegistry.getAllBuiltinTools()) {
try {
boolean added = syncBuiltinTool(tool);
if (added) {
addedCount++;
} else {
existingCount++;
}
} catch (Exception e) {
log.error("同步内置工具失败: {}", tool.name(), e);
}
}
log.info("系统内置工具同步完成: 新增 {} 个, 已存在 {} 个", addedCount, existingCount);
}
/**
* 同步单个内置工具到数据库
*
* @param tool 工具定义
* @return 是否新增true=新增, false=已存在)
*/
private boolean syncBuiltinTool(BuiltinToolDefinition tool) {
// 检查是否已存在
LambdaQueryWrapper<McpTool> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(McpTool::getName, tool.name())
.eq(McpTool::getType, BuiltinToolRegistry.TYPE_BUILTIN);
McpTool existing = mcpToolMapper.selectOne(wrapper);
if (existing != null) {
// 已存在,更新描述信息(保留状态不变)
if (!tool.description().equals(existing.getDescription())) {
existing.setDescription(tool.description());
mcpToolMapper.updateById(existing);
log.debug("更新内置工具描述: {}", tool.name());
}
return false;
}
// 新增
McpTool newTool = new McpTool();
newTool.setName(tool.name());
newTool.setDescription(tool.description());
newTool.setType(BuiltinToolRegistry.TYPE_BUILTIN);
newTool.setStatus(McpToolStatus.ENABLED.getValue()); // 默认启用
newTool.setConfigJson(null); // 内置工具不需要配置
mcpToolMapper.insert(newTool);
log.info("新增内置工具: {} ({})", tool.name(), tool.displayName());
return true;
}
}

View File

@@ -0,0 +1,171 @@
package org.ruoyi.mcp.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.ruoyi.common.core.domain.R;
import org.ruoyi.common.excel.utils.ExcelUtil;
import org.ruoyi.common.idempotent.annotation.RepeatSubmit;
import org.ruoyi.common.log.annotation.Log;
import org.ruoyi.common.log.enums.BusinessType;
import org.ruoyi.common.mybatis.core.page.PageQuery;
import org.ruoyi.common.mybatis.core.page.TableDataInfo;
import org.ruoyi.common.web.core.BaseController;
import org.ruoyi.mcp.domain.bo.McpMarketBo;
import org.ruoyi.mcp.domain.dto.McpMarketListResult;
import org.ruoyi.mcp.domain.dto.McpMarketToolListResult;
import org.ruoyi.mcp.domain.vo.McpMarketVo;
import org.ruoyi.mcp.service.IMcpMarketService;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* MCP 市场管理 Controller
*
* @author ruoyi team
*/
@Validated
@RequiredArgsConstructor
@RestController
@RequestMapping("/mcp/market")
public class McpMarketController extends BaseController {
private final IMcpMarketService mcpMarketService;
/**
* 查询市场列表
*/
@SaCheckPermission("mcp:market:list")
@GetMapping("/list")
public TableDataInfo<McpMarketVo> list(McpMarketBo bo, PageQuery pageQuery) {
return mcpMarketService.selectPageList(bo, pageQuery);
}
/**
* 查询市场列表(不分页)
*/
@SaCheckPermission("mcp:market:list")
@GetMapping("/all")
public McpMarketListResult listAll(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String status) {
return mcpMarketService.listMarkets(keyword, status);
}
/**
* 导出 MCP 市场列表
*/
@SaCheckPermission("mcp:market:export")
@Log(title = "MCP市场管理", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(McpMarketBo bo, HttpServletResponse response) {
List<McpMarketVo> list = mcpMarketService.queryList(bo);
ExcelUtil.exportExcel(list, "MCP市场", McpMarketVo.class, response);
}
/**
* 根据市场ID获取详细信息
*
* @param id 市场ID
*/
@SaCheckPermission("mcp:market:query")
@GetMapping("/{id}")
public R<McpMarketVo> getInfo(@PathVariable Long id) {
return R.ok(mcpMarketService.selectById(id));
}
/**
* 新增市场
*/
@SaCheckPermission("mcp:market:add")
@Log(title = "MCP市场管理", businessType = BusinessType.INSERT)
@RepeatSubmit()
@PostMapping
public R<Void> add(@Validated @RequestBody McpMarketBo bo) {
mcpMarketService.insert(bo);
return R.ok();
}
/**
* 修改市场
*/
@SaCheckPermission("mcp:market:edit")
@Log(title = "MCP市场管理", businessType = BusinessType.UPDATE)
@RepeatSubmit()
@PutMapping
public R<Void> edit(@Validated @RequestBody McpMarketBo bo) {
mcpMarketService.update(bo);
return R.ok();
}
/**
* 删除市场
*
* @param ids 市场ID串
*/
@SaCheckPermission("mcp:market:remove")
@Log(title = "MCP市场管理", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public R<Void> remove(@PathVariable Long[] ids) {
mcpMarketService.deleteByIds(List.of(ids));
return R.ok();
}
/**
* 更新市场状态
*/
@SaCheckPermission("mcp:market:edit")
@Log(title = "MCP市场管理", businessType = BusinessType.UPDATE)
@PutMapping("/{id}/status")
public R<Void> updateStatus(@PathVariable Long id, @RequestParam String status) {
mcpMarketService.updateStatus(id, status);
return R.ok();
}
/**
* 获取市场工具列表(分页)
*/
@SaCheckPermission("mcp:market:query")
@GetMapping("/{marketId}/tools")
public McpMarketToolListResult getMarketTools(
@PathVariable Long marketId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
return mcpMarketService.getMarketTools(marketId, page, size);
}
/**
* 刷新市场工具列表
*/
@SaCheckPermission("mcp:market:edit")
@Log(title = "MCP市场管理", businessType = BusinessType.UPDATE)
@PostMapping("/{marketId}/refresh")
public R<org.ruoyi.mcp.domain.dto.McpMarketRefreshResult> refreshMarketTools(@PathVariable Long marketId) {
return R.ok(mcpMarketService.refreshMarketTools(marketId));
}
/**
* 加载单个工具到本地
*/
@SaCheckPermission("mcp:market:add")
@Log(title = "MCP市场管理", businessType = BusinessType.INSERT)
@PostMapping("/tools/{toolId}/load")
public R<Void> loadToolToLocal(@PathVariable Long toolId) {
mcpMarketService.loadToolToLocal(toolId);
return R.ok();
}
/**
* 批量加载工具到本地
*/
@SaCheckPermission("mcp:market:add")
@Log(title = "MCP市场管理", businessType = BusinessType.INSERT)
@PostMapping("/tools/batch-load")
public R<Map<String, Object>> batchLoadTools(@RequestBody List<Long> toolIds) {
int successCount = mcpMarketService.batchLoadTools(toolIds);
return R.ok(Map.of("successCount", successCount));
}
}

View File

@@ -0,0 +1,136 @@
package org.ruoyi.mcp.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.ruoyi.common.core.domain.R;
import org.ruoyi.common.excel.utils.ExcelUtil;
import org.ruoyi.common.idempotent.annotation.RepeatSubmit;
import org.ruoyi.common.log.annotation.Log;
import org.ruoyi.common.log.enums.BusinessType;
import org.ruoyi.common.mybatis.core.page.PageQuery;
import org.ruoyi.common.mybatis.core.page.TableDataInfo;
import org.ruoyi.common.web.core.BaseController;
import org.ruoyi.mcp.domain.bo.McpToolBo;
import org.ruoyi.mcp.domain.dto.McpToolListResult;
import org.ruoyi.mcp.domain.dto.McpToolTestResult;
import org.ruoyi.mcp.domain.vo.McpToolVo;
import org.ruoyi.mcp.service.IMcpToolService;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* MCP 工具管理 Controller
*
* @author ruoyi team
*/
@Validated
@RequiredArgsConstructor
@RestController
@RequestMapping("/mcp/tool")
public class McpToolController extends BaseController {
private final IMcpToolService mcpToolService;
/**
* 查询 MCP 工具列表
*/
@SaCheckPermission("mcp:tool:list")
@GetMapping("/list")
public TableDataInfo<McpToolVo> list(McpToolBo bo, PageQuery pageQuery) {
return mcpToolService.selectPageList(bo, pageQuery);
}
/**
* 查询 MCP 工具列表(不分页)
*/
@SaCheckPermission("mcp:tool:list")
@GetMapping("/all")
public McpToolListResult listAll(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String type,
@RequestParam(required = false) String status) {
return mcpToolService.listTools(keyword, type, status);
}
/**
* 导出 MCP 工具列表
*/
@SaCheckPermission("mcp:tool:export")
@Log(title = "MCP工具管理", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(McpToolBo bo, HttpServletResponse response) {
List<McpToolVo> list = mcpToolService.queryList(bo);
ExcelUtil.exportExcel(list, "MCP工具", McpToolVo.class, response);
}
/**
* 根据工具ID获取详细信息
*
* @param id 工具ID
*/
@SaCheckPermission("mcp:tool:query")
@GetMapping("/{id}")
public R<McpToolVo> getInfo(@PathVariable Long id) {
return R.ok(mcpToolService.selectById(id));
}
/**
* 新增 MCP 工具
*/
@SaCheckPermission("mcp:tool:add")
@Log(title = "MCP工具管理", businessType = BusinessType.INSERT)
@RepeatSubmit()
@PostMapping
public R<Void> add(@Validated @RequestBody McpToolBo bo) {
mcpToolService.insert(bo);
return R.ok();
}
/**
* 修改 MCP 工具
*/
@SaCheckPermission("mcp:tool:edit")
@Log(title = "MCP工具管理", businessType = BusinessType.UPDATE)
@RepeatSubmit()
@PutMapping
public R<Void> edit(@Validated @RequestBody McpToolBo bo) {
mcpToolService.update(bo);
return R.ok();
}
/**
* 删除 MCP 工具
*
* @param ids 工具ID串
*/
@SaCheckPermission("mcp:tool:remove")
@Log(title = "MCP工具管理", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public R<Void> remove(@PathVariable Long[] ids) {
mcpToolService.deleteByIds(List.of(ids));
return R.ok();
}
/**
* 更新工具状态
*/
@SaCheckPermission("mcp:tool:edit")
@Log(title = "MCP工具管理", businessType = BusinessType.UPDATE)
@PutMapping("/{id}/status")
public R<Void> updateStatus(@PathVariable Long id, @RequestParam String status) {
mcpToolService.updateStatus(id, status);
return R.ok();
}
/**
* 测试工具连接
*/
@SaCheckPermission("mcp:tool:query")
@PostMapping("/{id}/test")
public R<McpToolTestResult> testTool(@PathVariable Long id) {
return R.ok(mcpToolService.testTool(id));
}
}

View File

@@ -0,0 +1,55 @@
package org.ruoyi.mcp.domain.bo;
import io.github.linpeilie.annotations.AutoMapper;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.ruoyi.common.mybatis.core.domain.BaseEntity;
import org.ruoyi.mcp.domain.entity.McpMarket;
/**
* MCP 市场业务对象
*
* @author ruoyi team
*/
@Data
@EqualsAndHashCode(callSuper = true)
@AutoMapper(target = McpMarket.class, reverseConvertGenerate = false)
public class McpMarketBo extends BaseEntity {
/**
* 市场ID
*/
private Long id;
/**
* 市场名称
*/
@NotBlank(message = "市场名称不能为空")
@Size(min = 0, max = 200, message = "市场名称不能超过{max}个字符")
private String name;
/**
* 市场 URL
*/
@NotBlank(message = "市场URL不能为空")
@Size(min = 0, max = 500, message = "市场URL不能超过{max}个字符")
private String url;
/**
* 市场描述
*/
private String description;
/**
* 认证配置JSON格式
*/
private String authConfig;
/**
* 状态ENABLED-启用, DISABLED-禁用
*/
private String status;
}

View File

@@ -0,0 +1,59 @@
package org.ruoyi.mcp.domain.bo;
import io.github.linpeilie.annotations.AutoMapper;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.ruoyi.common.mybatis.core.domain.BaseEntity;
import org.ruoyi.mcp.domain.entity.McpTool;
import java.io.Serial;
/**
* MCP 工具业务对象
*
* @author ruoyi team
*/
@Data
@EqualsAndHashCode(callSuper = true)
@AutoMapper(target = McpTool.class, reverseConvertGenerate = false)
public class McpToolBo extends BaseEntity {
@Serial
private static final long serialVersionUID = 1L;
/**
* 工具ID
*/
private Long id;
/**
* 工具名称
*/
@NotBlank(message = "工具名称不能为空")
@Size(min = 0, max = 200, message = "工具名称不能超过{max}个字符")
private String name;
/**
* 工具描述
*/
private String description;
/**
* 工具类型LOCAL-本地, REMOTE-远程, BUILTIN-内置
*/
@NotBlank(message = "工具类型不能为空")
private String type;
/**
* 状态ENABLED-启用, DISABLED-禁用
*/
private String status;
/**
* 配置信息JSON格式
*/
private String configJson;
}

View File

@@ -0,0 +1,44 @@
package org.ruoyi.mcp.domain.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.ruoyi.mcp.domain.entity.McpMarket;
import java.util.List;
/**
* MCP 市场列表返回结果
*
* @author ruoyi team
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class McpMarketListResult {
/**
* 是否成功
*/
private boolean success;
/**
* 市场列表
*/
private List<McpMarket> data;
/**
* 总数
*/
private int total;
public static McpMarketListResult of(List<McpMarket> data) {
return McpMarketListResult.builder()
.success(true)
.data(data)
.total(data != null ? data.size() : 0)
.build();
}
}

View File

@@ -0,0 +1,38 @@
package org.ruoyi.mcp.domain.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* MCP 市场工具刷新结果
*
* @author ruoyi team
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class McpMarketRefreshResult {
/**
* 是否成功
*/
private boolean success;
/**
* 消息
*/
private String message;
/**
* 新增工具数量
*/
private int addedCount;
/**
* 更新工具数量
*/
private int updatedCount;
}

View File

@@ -0,0 +1,63 @@
package org.ruoyi.mcp.domain.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.ruoyi.mcp.domain.entity.McpMarketTool;
import java.util.List;
/**
* MCP 市场工具列表返回结果(分页)
*
* @author ruoyi team
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class McpMarketToolListResult {
/**
* 是否成功
*/
private boolean success;
/**
* 工具列表
*/
private List<McpMarketTool> data;
/**
* 总数
*/
private long total;
/**
* 当前页
*/
private int page;
/**
* 每页大小
*/
private int size;
/**
* 总页数
*/
private long pages;
public static McpMarketToolListResult of(List<McpMarketTool> data, long total, int page, int size) {
long pages = (total + size - 1) / size;
return McpMarketToolListResult.builder()
.success(true)
.data(data)
.total(total)
.page(page)
.size(size)
.pages(pages)
.build();
}
}

View File

@@ -0,0 +1,44 @@
package org.ruoyi.mcp.domain.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.ruoyi.mcp.domain.entity.McpTool;
import java.util.List;
/**
* MCP 工具列表返回结果
*
* @author ruoyi team
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class McpToolListResult {
/**
* 是否成功
*/
private boolean success;
/**
* 工具列表
*/
private List<McpTool> data;
/**
* 总数
*/
private int total;
public static McpToolListResult of(List<McpTool> data) {
return McpToolListResult.builder()
.success(true)
.data(data)
.total(data != null ? data.size() : 0)
.build();
}
}

View File

@@ -0,0 +1,56 @@
package org.ruoyi.mcp.domain.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* MCP 工具测试结果
*
* @author ruoyi team
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class McpToolTestResult {
/**
* 是否成功
*/
private boolean success;
/**
* 消息
*/
private String message;
/**
* 发现的工具数量
*/
private Integer toolCount;
/**
* 工具名称列表
*/
private List<String> tools;
public static McpToolTestResult success(String message, int toolCount, List<String> tools) {
return McpToolTestResult.builder()
.success(true)
.message(message)
.toolCount(toolCount)
.tools(tools)
.build();
}
public static McpToolTestResult fail(String message) {
return McpToolTestResult.builder()
.success(false)
.message(message)
.build();
}
}

View File

@@ -0,0 +1,51 @@
package org.ruoyi.mcp.domain.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.ruoyi.common.tenant.core.TenantEntity;
/**
* MCP 市场信息实体
*
* @author ruoyi team
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("mcp_market_info")
public class McpMarket extends TenantEntity {
/**
* 市场ID
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 市场名称
*/
private String name;
/**
* 市场 URL
*/
private String url;
/**
* 市场描述
*/
private String description;
/**
* 认证配置JSON格式
*/
private String authConfig;
/**
* 状态ENABLED-启用, DISABLED-禁用
*/
private String status;
}

View File

@@ -0,0 +1,61 @@
package org.ruoyi.mcp.domain.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.ruoyi.common.mybatis.core.domain.BaseEntity;
/**
* MCP 市场工具关联实体
*
* @author ruoyi team
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("mcp_market_tool")
public class McpMarketTool extends BaseEntity {
/**
* ID
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 市场 ID
*/
private Long marketId;
/**
* 工具名称
*/
private String toolName;
/**
* 工具描述
*/
private String toolDescription;
/**
* 工具版本
*/
private String toolVersion;
/**
* 工具元数据JSON格式
*/
private String toolMetadata;
/**
* 是否已加载到本地
*/
private Boolean isLoaded;
/**
* 关联的本地工具 ID
*/
private Long localToolId;
}

View File

@@ -0,0 +1,55 @@
package org.ruoyi.mcp.domain.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.ruoyi.common.tenant.core.TenantEntity;
/**
* MCP 工具信息实体
*
* @author ruoyi team
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("mcp_tool_info")
public class McpTool extends TenantEntity {
/**
* 工具ID
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 工具名称
*/
private String name;
/**
* 工具描述
*/
private String description;
/**
* 工具类型LOCAL-本地, REMOTE-远程, BUILTIN-内置
*/
private String type;
/**
* 状态ENABLED-启用, DISABLED-禁用
*/
private String status;
/**
* 配置信息JSON格式
* LOCAL: {"command": "npx", "args": ["-y", "@example/mcp-server"], "env": {...}}
* REMOTE: {"baseUrl": "http://localhost:8080/mcp"}
* BUILTIN: null
*/
private String configJson;
}

View File

@@ -0,0 +1,74 @@
package org.ruoyi.mcp.domain.vo;
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
import cn.idev.excel.annotation.ExcelProperty;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import org.ruoyi.mcp.domain.entity.McpMarket;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
/**
* MCP 市场视图对象
*
* @author ruoyi team
*/
@Data
@ExcelIgnoreUnannotated
@AutoMapper(target = McpMarket.class)
public class McpMarketVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 市场ID
*/
@ExcelProperty(value = "市场ID")
private Long id;
/**
* 市场名称
*/
@ExcelProperty(value = "市场名称")
private String name;
/**
* 市场 URL
*/
@ExcelProperty(value = "市场URL")
private String url;
/**
* 市场描述
*/
@ExcelProperty(value = "市场描述")
private String description;
/**
* 认证配置
*/
@ExcelProperty(value = "认证配置")
private String authConfig;
/**
* 状态
*/
@ExcelProperty(value = "状态")
private String status;
/**
* 创建时间
*/
@ExcelProperty(value = "创建时间")
private Date createTime;
/**
* 更新时间
*/
@ExcelProperty(value = "更新时间")
private Date updateTime;
}

View File

@@ -0,0 +1,74 @@
package org.ruoyi.mcp.domain.vo;
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
import cn.idev.excel.annotation.ExcelProperty;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import org.ruoyi.mcp.domain.entity.McpTool;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
/**
* MCP 工具视图对象
*
* @author ruoyi team
*/
@Data
@ExcelIgnoreUnannotated
@AutoMapper(target = McpTool.class)
public class McpToolVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 工具ID
*/
@ExcelProperty(value = "工具ID")
private Long id;
/**
* 工具名称
*/
@ExcelProperty(value = "工具名称")
private String name;
/**
* 工具描述
*/
@ExcelProperty(value = "工具描述")
private String description;
/**
* 工具类型
*/
@ExcelProperty(value = "工具类型")
private String type;
/**
* 状态
*/
@ExcelProperty(value = "状态")
private String status;
/**
* 配置信息
*/
@ExcelProperty(value = "配置信息")
private String configJson;
/**
* 创建时间
*/
@ExcelProperty(value = "创建时间")
private Date createTime;
/**
* 更新时间
*/
@ExcelProperty(value = "更新时间")
private Date updateTime;
}

View File

@@ -0,0 +1,47 @@
package org.ruoyi.mcp.enums;
import lombok.Getter;
/**
* MCP 工具状态枚举
*
* @author ruoyi team
*/
@Getter
public enum McpToolStatus {
/**
* 启用状态
*/
ENABLED("ENABLED", "启用"),
/**
* 禁用状态
*/
DISABLED("DISABLED", "禁用");
/**
* 状态值(存储到数据库)
*/
private final String value;
/**
* 状态描述
*/
private final String description;
McpToolStatus(String value, String description) {
this.value = value;
this.description = description;
}
/**
* 判断是否为启用状态
*
* @param value 状态值
* @return 是否启用
*/
public static boolean isEnabled(String value) {
return ENABLED.value.equals(value);
}
}

View File

@@ -0,0 +1,15 @@
package org.ruoyi.mcp.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.ruoyi.common.mybatis.core.mapper.BaseMapperPlus;
import org.ruoyi.mcp.domain.entity.McpMarket;
import org.ruoyi.mcp.domain.vo.McpMarketVo;
/**
* MCP 市场信息 Mapper
*
* @author ruoyi team
*/
@Mapper
public interface McpMarketMapper extends BaseMapperPlus<McpMarket, McpMarketVo> {
}

View File

@@ -0,0 +1,14 @@
package org.ruoyi.mcp.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.ruoyi.common.mybatis.core.mapper.BaseMapperPlus;
import org.ruoyi.mcp.domain.entity.McpMarketTool;
/**
* MCP 市场工具关联 Mapper
*
* @author ruoyi team
*/
@Mapper
public interface McpMarketToolMapper extends BaseMapperPlus<McpMarketTool, Void> {
}

View File

@@ -0,0 +1,15 @@
package org.ruoyi.mcp.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.ruoyi.common.mybatis.core.mapper.BaseMapperPlus;
import org.ruoyi.mcp.domain.entity.McpTool;
import org.ruoyi.mcp.domain.vo.McpToolVo;
/**
* MCP 工具信息 Mapper
*
* @author ruoyi team
*/
@Mapper
public interface McpToolMapper extends BaseMapperPlus<McpTool, McpToolVo> {
}

View File

@@ -0,0 +1,117 @@
package org.ruoyi.mcp.service;
import org.ruoyi.common.mybatis.core.page.PageQuery;
import org.ruoyi.common.mybatis.core.page.TableDataInfo;
import org.ruoyi.mcp.domain.bo.McpMarketBo;
import org.ruoyi.mcp.domain.dto.McpMarketListResult;
import org.ruoyi.mcp.domain.dto.McpMarketRefreshResult;
import org.ruoyi.mcp.domain.dto.McpMarketToolListResult;
import org.ruoyi.mcp.domain.vo.McpMarketVo;
import java.util.List;
/**
* MCP 市场服务接口
*
* @author ruoyi team
*/
public interface IMcpMarketService {
/**
* 分页查询市场列表
*
* @param bo 查询条件
* @param pageQuery 分页参数
* @return 市场分页列表
*/
TableDataInfo<McpMarketVo> selectPageList(McpMarketBo bo, PageQuery pageQuery);
/**
* 查询市场列表(不分页)
*
* @param keyword 关键词
* @param status 状态
* @return 市场列表结果
*/
McpMarketListResult listMarkets(String keyword, String status);
/**
* 查询市场列表(用于导出)
*
* @param bo 查询条件
* @return 市场列表
*/
List<McpMarketVo> queryList(McpMarketBo bo);
/**
* 根据ID查询市场
*
* @param id 市场ID
* @return 市场信息
*/
McpMarketVo selectById(Long id);
/**
* 新增市场
*
* @param bo 市场信息
* @return 新增后的市场ID
*/
String insert(McpMarketBo bo);
/**
* 更新市场
*
* @param bo 市场信息
* @return 结果
*/
String update(McpMarketBo bo);
/**
* 删除市场
*
* @param ids 市场 ID 列表
*/
void deleteByIds(List<Long> ids);
/**
* 更新市场状态
*
* @param id 市场 ID
* @param status 状态
*/
void updateStatus(Long id, String status);
/**
* 获取市场工具列表
*
* @param marketId 市场 ID
* @param page 页码
* @param size 每页大小
* @return 工具列表结果
*/
McpMarketToolListResult getMarketTools(Long marketId, int page, int size);
/**
* 刷新市场工具列表
*
* @param marketId 市场 ID
* @return 刷新结果
*/
McpMarketRefreshResult refreshMarketTools(Long marketId);
/**
* 加载工具到本地
*
* @param toolId 市场工具 ID
*/
void loadToolToLocal(Long toolId);
/**
* 批量加载工具到本地
*
* @param toolIds 工具 ID 列表
* @return 成功加载的数量
*/
int batchLoadTools(List<Long> toolIds);
}

View File

@@ -0,0 +1,92 @@
package org.ruoyi.mcp.service;
import org.ruoyi.common.mybatis.core.page.PageQuery;
import org.ruoyi.common.mybatis.core.page.TableDataInfo;
import org.ruoyi.mcp.domain.bo.McpToolBo;
import org.ruoyi.mcp.domain.dto.McpToolListResult;
import org.ruoyi.mcp.domain.dto.McpToolTestResult;
import org.ruoyi.mcp.domain.vo.McpToolVo;
import java.util.List;
/**
* MCP 工具服务接口
*
* @author ruoyi team
*/
public interface IMcpToolService {
/**
* 分页查询工具列表
*
* @param bo 查询条件
* @param pageQuery 分页参数
* @return 工具分页列表
*/
TableDataInfo<McpToolVo> selectPageList(McpToolBo bo, PageQuery pageQuery);
/**
* 查询工具列表(不分页)
*
* @param keyword 关键词
* @param type 类型
* @param status 状态
* @return 工具列表结果
*/
McpToolListResult listTools(String keyword, String type, String status);
/**
* 查询工具列表(用于导出)
*
* @param bo 查询条件
* @return 工具列表
*/
List<McpToolVo> queryList(McpToolBo bo);
/**
* 根据ID查询工具
*
* @param id 工具ID
* @return 工具信息
*/
McpToolVo selectById(Long id);
/**
* 新增工具
*
* @param bo 工具信息
* @return 新增后的工具ID
*/
String insert(McpToolBo bo);
/**
* 更新工具
*
* @param bo 工具信息
* @return 结果
*/
String update(McpToolBo bo);
/**
* 删除工具
*
* @param ids 工具 ID 列表
*/
void deleteByIds(List<Long> ids);
/**
* 更新工具状态
*
* @param id 工具 ID
* @param status 状态
*/
void updateStatus(Long id, String status);
/**
* 测试工具连接
*
* @param id 工具 ID
* @return 测试结果
*/
McpToolTestResult testTool(Long id);
}

View File

@@ -0,0 +1,13 @@
package org.ruoyi.mcp.service.core;
/**
* 内置工具定义
* 用于描述系统内置的工具信息
*
* @param name 工具名称(唯一标识)
* @param displayName 显示名称
* @param description 工具描述
* @author ruoyi team
*/
public record BuiltinToolDefinition(String name, String displayName, String description) {
}

View File

@@ -0,0 +1,55 @@
package org.ruoyi.mcp.service.core;
/**
* 内置工具提供者接口
* 所有系统内置工具都应实现此接口,以便自动注册到 BuiltinToolRegistry
*
* @author ruoyi team
*
* <p>使用方式:
* <pre>
* {@code
* @Component
* public class MyTool implements BuiltinToolProvider {
* @Override
* public String getToolName() {
* return "my_tool";
* }
*
* @Override
* public String getDisplayName() {
* return "我的工具";
* }
*
* @Override
* public String getDescription() {
* return "工具描述...";
* }
* }
* }
* </pre>
*/
public interface BuiltinToolProvider {
/**
* 获取工具名称(唯一标识,用于数据库存储)
* 建议使用 snake_case 格式list_directory, edit_file
*
* @return 工具名称
*/
String getToolName();
/**
* 获取工具显示名称(用于 UI 展示)
*
* @return 显示名称
*/
String getDisplayName();
/**
* 获取工具描述(用于 AI 理解工具用途)
*
* @return 工具描述
*/
String getDescription();
}

View File

@@ -0,0 +1,129 @@
package org.ruoyi.mcp.service.core;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 内置工具注册表
* 自动发现并注册所有实现 {@link BuiltinToolProvider} 接口的工具
*
* <p>工具注册流程:
* <ol>
* <li>Spring 自动注入所有 {@link BuiltinToolProvider} 实现</li>
* <li>{@link #init()} 方法在 Bean 初始化后自动调用</li>
* <li>将所有工具注册到内部 Map</li>
* </ol>
*
* <p>添加新工具只需:
* <ol>
* <li>创建一个类实现 {@link BuiltinToolProvider} 接口</li>
* <li>添加 {@code @Component} 注解</li>
* <li>工具会自动被发现和注册</li>
* </ol>
*
* @author ruoyi team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class BuiltinToolRegistry {
/**
* 工具类型常量
*/
public static final String TYPE_BUILTIN = "BUILTIN";
/**
* Spring 自动注入所有实现 BuiltinToolProvider 接口的 Bean
*/
private final List<BuiltinToolProvider> toolProviders;
/**
* 内置工具定义映射表 (工具名称 -> 工具提供者)
*/
private final Map<String, BuiltinToolProvider> registeredTools = new ConcurrentHashMap<>();
/**
* 初始化方法,在 Bean 创建后自动调用
* 将所有 BuiltinToolProvider 注册到内部 Map
*/
@PostConstruct
public void init() {
log.info("开始注册内置工具,发现 {} 个工具提供者", toolProviders.size());
for (BuiltinToolProvider provider : toolProviders) {
String toolName = provider.getToolName();
if (registeredTools.containsKey(toolName)) {
log.warn("工具名称重复: {},将覆盖原有注册", toolName);
}
registeredTools.put(toolName, provider);
log.info("注册内置工具: {} ({})", toolName, provider.getDisplayName());
}
log.info("内置工具注册完成,共 {} 个工具", registeredTools.size());
}
/**
* 获取工具提供者
*
* @param toolName 工具名称
* @return 工具提供者,如果不存在则返回 null
*/
public BuiltinToolProvider getToolProvider(String toolName) {
return registeredTools.get(toolName);
}
/**
* 检查工具是否已注册
*
* @param toolName 工具名称
* @return 是否已注册
*/
public boolean hasTool(String toolName) {
return registeredTools.containsKey(toolName);
}
/**
* 获取所有内置工具定义
*
* @return 内置工具定义集合
*/
public Collection<BuiltinToolDefinition> getAllBuiltinTools() {
return registeredTools.values().stream()
.map(provider -> new BuiltinToolDefinition(
provider.getToolName(),
provider.getDisplayName(),
provider.getDescription()
))
.toList();
}
/**
* 获取所有内置工具对象
* 这些对象包含 @Tool 注解的方法,可直接用于 AgenticServices
*
* @return 内置工具对象列表
*/
public List<Object> getAllBuiltinToolObjects() {
return List.copyOf(registeredTools.values());
}
/**
* 根据工具名称获取工具对象
*
* @param toolName 工具名称
* @return 工具对象,如果不存在则返回 null
*/
public Object getBuiltinToolObject(String toolName) {
return registeredTools.get(toolName);
}
}

View File

@@ -0,0 +1,445 @@
package org.ruoyi.mcp.service.core;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
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.StreamableHttpMcpTransport;
import dev.langchain4j.mcp.client.transport.stdio.StdioMcpTransport;
import dev.langchain4j.service.tool.ToolProvider;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.ruoyi.mcp.domain.entity.McpTool;
import org.ruoyi.mcp.enums.McpToolStatus;
import org.ruoyi.mcp.mapper.McpToolMapper;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* LangChain4j MCP 工具提供者服务
* 从数据库读取 MCP 工具配置,创建 LangChain4j 的 McpToolProvider 供 Agent 使用
*
* @author ruoyi team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class LangChain4jMcpToolProviderService {
/**
* 最大失败次数,超过此次数将暂时禁用工具
*/
private static final int MAX_FAILURE_COUNT = 3;
/**
* 工具禁用时长(毫秒),默认 5 分钟
*/
private static final long DISABLE_DURATION = 5 * 60 * 1000;
private final McpToolMapper mcpToolMapper;
private final ObjectMapper objectMapper;
/**
* 缓存活跃的 MCP Client
*/
private final Map<Long, McpClient> activeClients = new ConcurrentHashMap<>();
/**
* 工具健康状态缓存工具ID -> 是否健康)
*/
private final Map<Long, Boolean> toolHealthStatus = new ConcurrentHashMap<>();
/**
* 工具失败次数工具ID -> 失败次数)
*/
private final Map<Long, Integer> toolFailureCount = new ConcurrentHashMap<>();
/**
* 工具禁用时间工具ID -> 禁用截止时间戳)
*/
private final Map<Long, Long> toolDisabledUntil = new ConcurrentHashMap<>();
/**
* 根据工具 ID 列表获取 ToolProvider
*
* @param toolIds 工具 ID 列表
* @return ToolProvider 实例
*/
public ToolProvider getToolProvider(List<Long> toolIds) {
if (toolIds == null || toolIds.isEmpty()) {
return McpToolProvider.builder().build();
}
List<McpClient> clients = new ArrayList<>();
for (Long toolId : toolIds) {
try {
McpClient client = getOrCreateClient(toolId);
if (client != null) {
clients.add(client);
}
} catch (Exception e) {
log.error("Failed to create MCP client for tool {}: {}", toolId, e.getMessage());
}
}
if (clients.isEmpty()) {
return McpToolProvider.builder().build();
}
return McpToolProvider.builder()
.mcpClients(clients)
.build();
}
/**
* 获取所有启用的 MCP 工具的 ToolProvider
*
* @return ToolProvider 实例
*/
public ToolProvider getAllEnabledToolsProvider() {
List<McpTool> enabledTools = mcpToolMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<McpTool>()
.eq(McpTool::getStatus, McpToolStatus.ENABLED.getValue())
);
if (enabledTools.isEmpty()) {
return McpToolProvider.builder().build();
}
List<Long> toolIds = enabledTools.stream()
.map(McpTool::getId)
.toList();
return getToolProvider(toolIds);
}
/**
* 获取指定名称的 MCP 工具的 ToolProvider
*
* @param toolNames 工具名称列表
* @return ToolProvider 实例
*/
public ToolProvider getToolProviderByNames(List<String> toolNames) {
if (toolNames == null || toolNames.isEmpty()) {
return McpToolProvider.builder().build();
}
List<McpTool> tools = mcpToolMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<McpTool>()
.in(McpTool::getName, toolNames)
.eq(McpTool::getStatus, McpToolStatus.ENABLED.getValue())
);
if (tools.isEmpty()) {
return McpToolProvider.builder().build();
}
List<Long> toolIds = tools.stream()
.map(McpTool::getId)
.toList();
return getToolProvider(toolIds);
}
/**
* 获取或创建 MCP Client
* 包含健康检查和失败重试逻辑
*/
private McpClient getOrCreateClient(Long toolId) {
// 检查工具是否被禁用
if (isToolDisabled(toolId)) {
log.warn("Tool {} is temporarily disabled due to previous failures", toolId);
return null;
}
// 尝试从缓存获取
McpClient cachedClient = activeClients.get(toolId);
if (cachedClient != null && isToolHealthy(toolId)) {
return cachedClient;
}
// 创建新的客户端
return activeClients.compute(toolId, (id, existingClient) -> {
McpTool tool = mcpToolMapper.selectById(id);
if (tool == null || !McpToolStatus.isEnabled(tool.getStatus())) {
return null;
}
// 跳过内置工具BUILTIN 类型)
if ("BUILTIN".equals(tool.getType())) {
log.debug("Skipping builtin tool: {}", tool.getName());
return null;
}
try {
McpClient client = createMcpClient(tool);
// 标记工具为健康状态
markToolHealthy(id);
log.info("Successfully created LangChain4j MCP client for tool: {}", tool.getName());
return client;
} catch (Exception e) {
log.error("Failed to create MCP client for tool {}: {}", tool.getName(), e.getMessage());
// 记录失败并可能禁用工具
handleToolFailure(id);
return null;
}
});
}
/**
* 检查工具是否被暂时禁用
*/
private boolean isToolDisabled(Long toolId) {
Long disabledUntil = toolDisabledUntil.get(toolId);
if (disabledUntil == null) {
return false;
}
if (System.currentTimeMillis() > disabledUntil) {
// 禁用时间已过,重新启用
toolDisabledUntil.remove(toolId);
toolFailureCount.put(toolId, 0);
log.info("Tool {} is re-enabled after disable period", toolId);
return false;
}
return true;
}
/**
* 检查工具是否健康
*/
private boolean isToolHealthy(Long toolId) {
return toolHealthStatus.getOrDefault(toolId, true);
}
/**
* 标记工具为健康状态
*/
private void markToolHealthy(Long toolId) {
toolHealthStatus.put(toolId, true);
toolFailureCount.put(toolId, 0);
}
/**
* 处理工具失败
*/
private void handleToolFailure(Long toolId) {
int failures = toolFailureCount.getOrDefault(toolId, 0) + 1;
toolFailureCount.put(toolId, failures);
toolHealthStatus.put(toolId, false);
if (failures >= MAX_FAILURE_COUNT) {
// 禁用工具一段时间
long disableUntil = System.currentTimeMillis() + DISABLE_DURATION;
toolDisabledUntil.put(toolId, disableUntil);
log.warn("Tool {} has failed {} times, disabling until {}",
toolId, failures, new java.util.Date(disableUntil));
} else {
log.warn("Tool {} has failed {} times (max: {})",
toolId, failures, MAX_FAILURE_COUNT);
}
}
/**
* 手动检查工具健康状态
*
* @param toolId 工具 ID
* @return 工具是否健康
*/
public boolean checkToolHealth(Long toolId) {
McpTool tool = mcpToolMapper.selectById(toolId);
if (tool == null || !McpToolStatus.isEnabled(tool.getStatus())) {
return false;
}
try {
// 尝试创建客户端来验证连接
McpClient client = createMcpClient(tool);
if (client != null) {
markToolHealthy(toolId);
return true;
}
return false;
} catch (Exception e) {
log.error("Health check failed for tool {}: {}", tool.getName(), e.getMessage());
handleToolFailure(toolId);
return false;
}
}
/**
* 获取所有工具的健康状态
*
* @return 工具 ID -> 健康状态的映射
*/
public Map<Long, Boolean> getAllToolsHealthStatus() {
List<McpTool> allTools = mcpToolMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<McpTool>()
.eq(McpTool::getStatus, McpToolStatus.ENABLED.getValue())
);
Map<Long, Boolean> statusMap = new ConcurrentHashMap<>();
for (McpTool tool : allTools) {
boolean isHealthy = isToolHealthy(tool.getId()) && !isToolDisabled(tool.getId());
statusMap.put(tool.getId(), isHealthy);
}
return statusMap;
}
/**
* 根据工具配置创建 MCP Client
*/
private McpClient createMcpClient(McpTool tool) throws Exception {
if ("LOCAL".equals(tool.getType())) {
return createStdioClient(tool);
} else if ("REMOTE".equals(tool.getType())) {
return createRemoteClient(tool);
}
return null;
}
/**
* 创建 STDIO Client (本地命令行工具)
*/
private McpClient createStdioClient(McpTool tool) throws Exception {
String configJson = tool.getConfigJson();
if (configJson == null || configJson.isBlank()) {
throw new IllegalArgumentException("Config JSON is required for LOCAL type tool");
}
JsonNode configNode = objectMapper.readTree(configJson);
// 解析命令
String command = null;
List<String> args = new ArrayList<>();
if (configNode.has("command")) {
command = configNode.get("command").asText();
}
if (configNode.has("args") && configNode.get("args").isArray()) {
for (JsonNode arg : configNode.get("args")) {
args.add(arg.asText());
}
}
if (command == null || command.isBlank()) {
throw new IllegalArgumentException("Command is required in config JSON");
}
// 处理 Windows 系统的命令
command = resolveCommand(command);
// 构建完整命令列表
List<String> fullCommand = new ArrayList<>();
fullCommand.add(command);
fullCommand.addAll(args);
log.info("Creating STDIO MCP client for tool: {}, command: {}", tool.getName(), fullCommand);
// 创建传输层
McpTransport transport = StdioMcpTransport.builder()
.command(fullCommand)
.logEvents(true)
.build();
// 创建客户端
return new DefaultMcpClient.Builder()
.transport(transport)
.build();
}
/**
* 创建远程 HTTP/SSE Client
*/
private McpClient createRemoteClient(McpTool tool) throws Exception {
String configJson = tool.getConfigJson();
if (configJson == null || configJson.isBlank()) {
throw new IllegalArgumentException("Config JSON is required for REMOTE type tool");
}
JsonNode configNode = objectMapper.readTree(configJson);
if (!configNode.has("baseUrl")) {
throw new IllegalArgumentException("baseUrl is required in config JSON for REMOTE type tool");
}
String baseUrl = configNode.get("baseUrl").asText();
log.info("Creating HTTP/SSE MCP client for tool: {}, baseUrl: {}", tool.getName(), baseUrl);
// 创建 HTTP/SSE 传输层
McpTransport transport = StreamableHttpMcpTransport.builder()
.url(baseUrl)
.logRequests(true)
.build();
// 创建客户端
return new DefaultMcpClient.Builder()
.transport(transport)
.build();
}
/**
* 解析命令,处理 Windows 系统的兼容性问题
*/
private String resolveCommand(String command) {
if (command == null || command.isBlank()) {
return command;
}
boolean isWindows = System.getProperty("os.name").toLowerCase().contains("win");
if (isWindows) {
String lowerCommand = command.toLowerCase();
if (lowerCommand.equals("npx") || lowerCommand.equals("npm") ||
lowerCommand.equals("node") || lowerCommand.equals("pnpm") ||
lowerCommand.equals("yarn") || lowerCommand.equals("uvx") ||
lowerCommand.equals("uv")) {
String resolvedCommand = command + ".cmd";
log.debug("Windows detected, resolved command: {} -> {}", command, resolvedCommand);
return resolvedCommand;
}
}
return command;
}
/**
* 刷新指定工具的客户端连接
*/
public void refreshClient(Long toolId) {
closeClient(toolId);
log.info("Refreshed MCP client for tool: {}", toolId);
}
/**
* 关闭指定工具的客户端连接
*/
private void closeClient(Long toolId) {
McpClient client = activeClients.remove(toolId);
if (client != null) {
try {
// LangChain4j McpClient 没有 close 方法,直接移除即可
log.info("Removed MCP client for tool: {}", toolId);
} catch (Exception e) {
log.warn("Error closing MCP client for tool {}: {}", toolId, e.getMessage());
}
}
}
/**
* 应用关闭时清理所有连接
*/
@PreDestroy
public void cleanup() {
log.info("Cleaning up {} MCP clients...", activeClients.size());
activeClients.keySet().forEach(this::closeClient);
}
/**
* 获取当前活跃的客户端数量
*/
public int getActiveClientCount() {
return activeClients.size();
}
}

View File

@@ -0,0 +1,171 @@
package org.ruoyi.mcp.service.core;
import dev.langchain4j.mcp.McpToolProvider;
import dev.langchain4j.service.tool.ToolProvider;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.ruoyi.mcp.domain.entity.McpTool;
import org.ruoyi.mcp.enums.McpToolStatus;
import org.ruoyi.mcp.mapper.McpToolMapper;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 统一工具提供工厂
* 整合所有类型的MCP工具提供者为Agent和Chat服务提供统一的工具获取入口
*
* <p>支持的工具类型:
* <ul>
* <li>BUILTIN - 内置工具(如文件操作工具)</li>
* <li>LOCAL - 本地STDIO工具通过命令行启动的MCP服务器</li>
* <li>REMOTE - 远程HTTP/SSE工具通过网络连接的MCP服务器</li>
* </ul>
*
* @author ruoyi team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ToolProviderFactory {
/**
* 工具类型常量
*/
public static final String TYPE_BUILTIN = "BUILTIN";
public static final String TYPE_LOCAL = "LOCAL";
public static final String TYPE_REMOTE = "REMOTE";
private final BuiltinToolRegistry builtinToolRegistry;
private final LangChain4jMcpToolProviderService langChain4jMcpToolProviderService;
private final McpToolMapper mcpToolMapper;
/**
* 根据工具ID列表获取LangChain4j的ToolProvider
* 用于LangChain4j Agent框架使用工具
*
* @param toolIds 工具ID列表
* @return ToolProvider实例
*/
public ToolProvider getToolProvider(List<Long> toolIds) {
if (toolIds == null || toolIds.isEmpty()) {
return McpToolProvider.builder().build();
}
// 只获取非内置工具LangChain4j的MCP工具
List<Long> mcpToolIds = new ArrayList<>();
for (Long toolId : toolIds) {
McpTool tool = mcpToolMapper.selectById(toolId);
if (tool != null && McpToolStatus.isEnabled(tool.getStatus())) {
if (!TYPE_BUILTIN.equals(tool.getType())) {
mcpToolIds.add(toolId);
}
}
}
// 使用LangChain4j服务获取MCP工具的ToolProvider
return langChain4jMcpToolProviderService.getToolProvider(mcpToolIds);
}
/**
* 根据工具名称列表获取LangChain4j的ToolProvider
*
* @param toolNames 工具名称列表
* @return ToolProvider实例
*/
public ToolProvider getToolProviderByNames(List<String> toolNames) {
if (toolNames == null || toolNames.isEmpty()) {
return McpToolProvider.builder().build();
}
// 直接使用LangChain4j服务它已经实现了按名称查询
return langChain4jMcpToolProviderService.getToolProviderByNames(toolNames);
}
/**
* 获取所有已启用的MCP工具的ToolProvider
*
* @return ToolProvider实例
*/
public ToolProvider getAllEnabledMcpToolsProvider() {
return langChain4jMcpToolProviderService.getAllEnabledToolsProvider();
}
/**
* 检查工具是否为内置工具
*
* @param toolName 工具名称
* @return 是否为内置工具
*/
public boolean isBuiltinTool(String toolName) {
return builtinToolRegistry.hasTool(toolName);
}
/**
* 根据工具名称获取工具ID
*
* @param toolName 工具名称
* @return 工具ID未找到返回null
*/
public Long getToolIdByName(String toolName) {
McpTool tool = mcpToolMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<McpTool>()
.eq(McpTool::getName, toolName)
.last("LIMIT 1")
);
return tool != null ? tool.getId() : null;
}
/**
* 根据工具名称列表获取工具ID列表
*
* @param toolNames 工具名称列表
* @return 工具ID列表
*/
public List<Long> getToolIdsByNames(List<String> toolNames) {
if (toolNames == null || toolNames.isEmpty()) {
return List.of();
}
List<McpTool> tools = mcpToolMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<McpTool>()
.in(McpTool::getName, toolNames)
.eq(McpTool::getStatus, McpToolStatus.ENABLED.getValue())
);
return tools.stream()
.map(McpTool::getId)
.toList();
}
/**
* 刷新工具连接
*
* @param toolId 工具ID
*/
public void refreshTool(Long toolId) {
langChain4jMcpToolProviderService.refreshClient(toolId);
log.info("已刷新工具连接: toolId={}", toolId);
}
/**
* 获取工具健康状态
*
* @return 工具ID -> 健康状态的映射
*/
public Map<Long, Boolean> getToolsHealthStatus() {
return langChain4jMcpToolProviderService.getAllToolsHealthStatus();
}
/**
* 获取所有 BUILTIN 工具对象
* 这些对象包含 @Tool 注解的方法,可直接用于 AgenticServices
*
* @return BUILTIN 工具对象列表
*/
public List<Object> getAllBuiltinToolObjects() {
return builtinToolRegistry.getAllBuiltinToolObjects();
}
}

View File

@@ -0,0 +1,328 @@
package org.ruoyi.mcp.service.impl;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.ruoyi.common.core.exception.ServiceException;
import org.ruoyi.common.core.utils.MapstructUtils;
import org.ruoyi.common.mybatis.core.page.PageQuery;
import org.ruoyi.common.mybatis.core.page.TableDataInfo;
import org.ruoyi.mcp.domain.bo.McpMarketBo;
import org.ruoyi.mcp.domain.dto.McpMarketListResult;
import org.ruoyi.mcp.domain.dto.McpMarketRefreshResult;
import org.ruoyi.mcp.domain.dto.McpMarketToolListResult;
import org.ruoyi.mcp.domain.entity.McpMarket;
import org.ruoyi.mcp.domain.entity.McpMarketTool;
import org.ruoyi.mcp.domain.entity.McpTool;
import org.ruoyi.mcp.domain.vo.McpMarketVo;
import org.ruoyi.mcp.enums.McpToolStatus;
import org.ruoyi.mcp.mapper.McpMarketMapper;
import org.ruoyi.mcp.mapper.McpMarketToolMapper;
import org.ruoyi.mcp.mapper.McpToolMapper;
import org.ruoyi.mcp.service.IMcpMarketService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* MCP 市场服务实现
*
* @author ruoyi team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class McpMarketServiceImpl implements IMcpMarketService {
private final McpMarketMapper baseMapper;
private final McpMarketToolMapper mcpMarketToolMapper;
private final McpToolMapper mcpToolMapper;
private final ObjectMapper objectMapper;
@Override
public TableDataInfo<McpMarketVo> selectPageList(McpMarketBo bo, PageQuery pageQuery) {
LambdaQueryWrapper<McpMarket> wrapper = buildQueryWrapper(bo);
Page<McpMarketVo> page = baseMapper.selectVoPage(pageQuery.build(), wrapper);
return TableDataInfo.build(page);
}
@Override
public McpMarketListResult listMarkets(String keyword, String status) {
LambdaQueryWrapper<McpMarket> wrapper = new LambdaQueryWrapper<>();
if (StringUtils.hasText(keyword)) {
wrapper.and(w -> w.like(McpMarket::getName, keyword)
.or()
.like(McpMarket::getDescription, keyword));
}
if (StringUtils.hasText(status)) {
wrapper.eq(McpMarket::getStatus, status);
}
wrapper.orderByDesc(McpMarket::getUpdateTime);
List<McpMarket> list = baseMapper.selectList(wrapper);
return McpMarketListResult.of(list);
}
@Override
public List<McpMarketVo> queryList(McpMarketBo bo) {
LambdaQueryWrapper<McpMarket> wrapper = buildQueryWrapper(bo);
return baseMapper.selectVoList(wrapper);
}
@Override
public McpMarketVo selectById(Long id) {
return baseMapper.selectVoById(id);
}
@Override
@Transactional
public String insert(McpMarketBo bo) {
McpMarket market = MapstructUtils.convert(bo, McpMarket.class);
if (market.getStatus() == null) {
market.setStatus(McpToolStatus.ENABLED.getValue());
}
baseMapper.insert(market);
return String.valueOf(market.getId());
}
@Override
@Transactional
public String update(McpMarketBo bo) {
McpMarket market = MapstructUtils.convert(bo, McpMarket.class);
baseMapper.updateById(market);
return String.valueOf(market.getId());
}
@Override
@Transactional
public void deleteByIds(List<Long> ids) {
for (Long id : ids) {
// 先删除关联的市场工具
LambdaQueryWrapper<McpMarketTool> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(McpMarketTool::getMarketId, id);
mcpMarketToolMapper.delete(wrapper);
}
// 删除市场
baseMapper.deleteBatchIds(ids);
}
@Override
@Transactional
public void updateStatus(Long id, String status) {
McpMarket market = new McpMarket();
market.setId(id);
market.setStatus(status);
baseMapper.updateById(market);
}
@Override
public McpMarketToolListResult getMarketTools(Long marketId, int page, int size) {
LambdaQueryWrapper<McpMarketTool> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(McpMarketTool::getMarketId, marketId);
wrapper.orderByDesc(McpMarketTool::getCreateTime);
Page<McpMarketTool> pageResult = mcpMarketToolMapper.selectPage(new Page<>(page, size), wrapper);
return McpMarketToolListResult.of(
pageResult.getRecords(),
pageResult.getTotal(),
(int) pageResult.getCurrent(),
(int) pageResult.getSize()
);
}
@Override
@Transactional
public McpMarketRefreshResult refreshMarketTools(Long marketId) {
McpMarket market = baseMapper.selectById(marketId);
if (market == null) {
throw new ServiceException("市场不存在");
}
int addedCount = 0;
int updatedCount = 0;
try {
// 从市场 URL 获取工具列表使用hutool的HttpUtil
HttpResponse response = HttpRequest.get(market.getUrl())
.timeout(30000) // 30秒超时
.execute();
String responseBody = response.body();
JsonNode rootNode = objectMapper.readTree(responseBody);
// 假设响应格式为 { "data": [...] } 或直接是数组
JsonNode toolsNode = rootNode.has("data") ? rootNode.get("data") : rootNode;
if (toolsNode.isArray()) {
// 获取现有工具
LambdaQueryWrapper<McpMarketTool> existingWrapper = new LambdaQueryWrapper<>();
existingWrapper.eq(McpMarketTool::getMarketId, marketId);
List<McpMarketTool> existingTools = mcpMarketToolMapper.selectList(existingWrapper);
// 创建现有工具的名称到ID映射
Map<String, McpMarketTool> existingToolMap = existingTools.stream()
.collect(Collectors.toMap(McpMarketTool::getToolName, t -> t));
// 处理新工具
for (JsonNode toolNode : toolsNode) {
String toolName = getTextValue(toolNode, "name", "title");
McpMarketTool existingTool = existingToolMap.get(toolName);
if (existingTool != null) {
// 更新现有工具
existingTool.setToolDescription(getTextValue(toolNode, "description", "desc"));
existingTool.setToolVersion(getTextValue(toolNode, "version"));
existingTool.setToolMetadata(toolNode.toString());
mcpMarketToolMapper.updateById(existingTool);
updatedCount++;
} else {
// 插入新工具
McpMarketTool tool = new McpMarketTool();
tool.setMarketId(marketId);
tool.setToolName(toolName);
tool.setToolDescription(getTextValue(toolNode, "description", "desc"));
tool.setToolVersion(getTextValue(toolNode, "version"));
tool.setToolMetadata(toolNode.toString());
tool.setIsLoaded(false);
mcpMarketToolMapper.insert(tool);
addedCount++;
}
}
}
log.info("Successfully refreshed market tools for market: {}, added: {}, updated: {}",
market.getName(), addedCount, updatedCount);
return McpMarketRefreshResult.builder()
.success(true)
.message("刷新成功")
.addedCount(addedCount)
.updatedCount(updatedCount)
.build();
} catch (Exception e) {
log.error("Failed to refresh market tools for market {}: {}", marketId, e.getMessage());
return McpMarketRefreshResult.builder()
.success(false)
.message("刷新市场工具列表失败: " + e.getMessage())
.addedCount(0)
.updatedCount(0)
.build();
}
}
/**
* 从 JSON 节点获取文本值,尝试多个字段名
*/
private String getTextValue(JsonNode node, String... fieldNames) {
for (String fieldName : fieldNames) {
if (node.has(fieldName) && !node.get(fieldName).isNull()) {
return node.get(fieldName).asText();
}
}
return null;
}
@Override
@Transactional
public void loadToolToLocal(Long toolId) {
McpMarketTool marketTool = mcpMarketToolMapper.selectById(toolId);
if (marketTool == null) {
throw new ServiceException("市场工具不存在");
}
if (marketTool.getIsLoaded()) {
throw new ServiceException("工具已加载到本地");
}
try {
// 解析工具元数据
JsonNode metadata = objectMapper.readTree(marketTool.getToolMetadata());
// 创建本地工具
McpTool localTool = new McpTool();
localTool.setName(marketTool.getToolName());
localTool.setDescription(marketTool.getToolDescription());
// 根据元数据判断类型
if (metadata.has("baseUrl") || metadata.has("url")) {
localTool.setType("REMOTE");
String baseUrl = metadata.has("baseUrl") ? metadata.get("baseUrl").asText() :
metadata.has("url") ? metadata.get("url").asText() : null;
localTool.setConfigJson(objectMapper.writeValueAsString(Map.of("baseUrl", baseUrl != null ? baseUrl : "")));
} else {
localTool.setType("LOCAL");
// 构建本地工具配置
Map<String, Object> config = new HashMap<>();
if (metadata.has("command")) {
config.put("command", metadata.get("command").asText());
}
if (metadata.has("args") && metadata.get("args").isArray()) {
config.put("args", objectMapper.convertValue(metadata.get("args"), List.class));
}
if (metadata.has("env") && metadata.get("env").isObject()) {
config.put("env", objectMapper.convertValue(metadata.get("env"), Map.class));
}
// 如果有 npm 包名,使用 npx 启动
if (metadata.has("package") || metadata.has("npmPackage")) {
String packageName = metadata.has("package") ? metadata.get("package").asText() :
metadata.get("npmPackage").asText();
config.put("command", "npx");
config.put("args", List.of("-y", packageName));
}
localTool.setConfigJson(objectMapper.writeValueAsString(config));
}
localTool.setStatus(McpToolStatus.ENABLED.getValue());
mcpToolMapper.insert(localTool);
// 更新市场工具状态
marketTool.setIsLoaded(true);
marketTool.setLocalToolId(localTool.getId());
mcpMarketToolMapper.updateById(marketTool);
log.info("Successfully loaded tool {} to local", marketTool.getToolName());
} catch (Exception e) {
log.error("Failed to load tool to local: {}", e.getMessage());
throw new ServiceException("加载工具到本地失败: " + e.getMessage());
}
}
@Override
@Transactional
public int batchLoadTools(List<Long> toolIds) {
int successCount = 0;
for (Long toolId : toolIds) {
try {
loadToolToLocal(toolId);
successCount++;
} catch (Exception e) {
log.warn("Failed to load tool {}: {}", toolId, e.getMessage());
}
}
return successCount;
}
private LambdaQueryWrapper<McpMarket> buildQueryWrapper(McpMarketBo bo) {
Map<String, Object> params = bo.getParams();
LambdaQueryWrapper<McpMarket> wrapper = Wrappers.lambdaQuery();
wrapper.eq(StringUtils.hasText(bo.getStatus()), McpMarket::getStatus, bo.getStatus())
.like(StringUtils.hasText(bo.getName()), McpMarket::getName, bo.getName())
.like(StringUtils.hasText(bo.getDescription()), McpMarket::getDescription, bo.getDescription());
return wrapper;
}
}

View File

@@ -0,0 +1,226 @@
package org.ruoyi.mcp.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.ruoyi.common.core.exception.ServiceException;
import org.ruoyi.common.core.utils.MapstructUtils;
import org.ruoyi.common.mybatis.core.page.PageQuery;
import org.ruoyi.common.mybatis.core.page.TableDataInfo;
import org.ruoyi.mcp.domain.bo.McpToolBo;
import org.ruoyi.mcp.domain.dto.McpToolListResult;
import org.ruoyi.mcp.domain.dto.McpToolTestResult;
import org.ruoyi.mcp.domain.entity.McpTool;
import org.ruoyi.mcp.domain.vo.McpToolVo;
import org.ruoyi.mcp.enums.McpToolStatus;
import org.ruoyi.mcp.mapper.McpToolMapper;
import org.ruoyi.mcp.service.IMcpToolService;
import org.ruoyi.mcp.service.core.BuiltinToolRegistry;
import org.ruoyi.mcp.service.core.LangChain4jMcpToolProviderService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Map;
/**
* MCP 工具服务实现
*
* @author ruoyi team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class McpToolServiceImpl implements IMcpToolService {
private final McpToolMapper baseMapper;
private final LangChain4jMcpToolProviderService langChain4jMcpToolProviderService;
private final BuiltinToolRegistry builtinToolRegistry;
@Override
public TableDataInfo<McpToolVo> selectPageList(McpToolBo bo, PageQuery pageQuery) {
LambdaQueryWrapper<McpTool> wrapper = buildQueryWrapper(bo);
Page<McpToolVo> page = baseMapper.selectVoPage(pageQuery.build(), wrapper);
return TableDataInfo.build(page);
}
@Override
public McpToolListResult listTools(String keyword, String type, String status) {
LambdaQueryWrapper<McpTool> wrapper = new LambdaQueryWrapper<>();
if (StringUtils.hasText(keyword)) {
wrapper.and(w -> w.like(McpTool::getName, keyword)
.or()
.like(McpTool::getDescription, keyword));
}
if (StringUtils.hasText(type)) {
wrapper.eq(McpTool::getType, type);
}
if (StringUtils.hasText(status)) {
wrapper.eq(McpTool::getStatus, status);
}
wrapper.orderByDesc(McpTool::getUpdateTime);
List<McpTool> list = baseMapper.selectList(wrapper);
return McpToolListResult.of(list);
}
@Override
public List<McpToolVo> queryList(McpToolBo bo) {
LambdaQueryWrapper<McpTool> wrapper = buildQueryWrapper(bo);
return baseMapper.selectVoList(wrapper);
}
@Override
public McpToolVo selectById(Long id) {
return baseMapper.selectVoById(id);
}
@Override
@Transactional
public String insert(McpToolBo bo) {
McpTool tool = MapstructUtils.convert(bo, McpTool.class);
if (tool.getStatus() == null) {
tool.setStatus(McpToolStatus.ENABLED.getValue());
}
if (tool.getType() == null) {
tool.setType("LOCAL");
}
baseMapper.insert(tool);
return String.valueOf(tool.getId());
}
@Override
@Transactional
public String update(McpToolBo bo) {
McpTool existingTool = baseMapper.selectById(bo.getId());
if (existingTool != null && BuiltinToolRegistry.TYPE_BUILTIN.equals(existingTool.getType())) {
throw new ServiceException("内置工具不允许编辑");
}
McpTool tool = MapstructUtils.convert(bo, McpTool.class);
baseMapper.updateById(tool);
// 如果工具正在使用中,需要刷新连接
langChain4jMcpToolProviderService.refreshClient(bo.getId());
return String.valueOf(tool.getId());
}
@Override
@Transactional
public void deleteByIds(List<Long> ids) {
// 过滤掉内置工具
List<Long> deletableIds = ids.stream()
.filter(id -> {
McpTool tool = baseMapper.selectById(id);
return tool == null || !BuiltinToolRegistry.TYPE_BUILTIN.equals(tool.getType());
})
.toList();
if (deletableIds.isEmpty()) {
throw new ServiceException("所选工具均为内置工具,不允许删除");
}
// 刷新连接LangChain4j会自动处理
deletableIds.forEach(id -> langChain4jMcpToolProviderService.refreshClient(id));
baseMapper.deleteBatchIds(deletableIds);
}
@Override
@Transactional
public void updateStatus(Long id, String status) {
McpTool tool = new McpTool();
tool.setId(id);
tool.setStatus(status);
baseMapper.updateById(tool);
// 刷新连接
langChain4jMcpToolProviderService.refreshClient(id);
}
@Override
public McpToolTestResult testTool(Long id) {
McpTool tool = baseMapper.selectById(id);
if (tool == null) {
return McpToolTestResult.fail("工具不存在");
}
// 根据工具类型选择不同的测试逻辑
if (BuiltinToolRegistry.TYPE_BUILTIN.equals(tool.getType())) {
// 内置工具 - 直接验证是否在注册表中
return testBuiltinTool(tool);
} else {
// MCP 工具 (LOCAL/REMOTE) - 测试连接
return testMcpTool(tool);
}
}
/**
* 测试内置工具
* 内置工具不需要网络连接,只需验证是否在注册表中
*
* @param tool 工具信息
* @return 测试结果
*/
private McpToolTestResult testBuiltinTool(McpTool tool) {
try {
boolean isRegistered = builtinToolRegistry.hasTool(tool.getName());
if (isRegistered) {
return McpToolTestResult.success(
String.format("内置工具 [%s] 已注册,可正常使用", tool.getName()),
1,
List.of(tool.getName())
);
} else {
return McpToolTestResult.fail(
String.format("内置工具 [%s] 未在注册表中找到,请检查工具名称是否正确", tool.getName())
);
}
} catch (Exception e) {
log.error("测试内置工具失败: {} - {}", tool.getName(), e.getMessage());
return McpToolTestResult.fail("测试失败: " + e.getMessage());
}
}
/**
* 测试MCP工具连接
*
* @param tool 工具信息
* @return 测试结果
*/
private McpToolTestResult testMcpTool(McpTool tool) {
try {
boolean isHealthy = langChain4jMcpToolProviderService.checkToolHealth(tool.getId());
if (isHealthy) {
return McpToolTestResult.success(
String.format("MCP工具 [%s] 连接测试成功", tool.getName()),
1,
List.of(tool.getName())
);
} else {
return McpToolTestResult.fail(
String.format("MCP工具 [%s] 连接测试失败", tool.getName())
);
}
} catch (Exception e) {
log.error("测试MCP工具失败: {} - {}", tool.getName(), e.getMessage());
return McpToolTestResult.fail("测试失败: " + e.getMessage());
}
}
private LambdaQueryWrapper<McpTool> buildQueryWrapper(McpToolBo bo) {
Map<String, Object> params = bo.getParams();
LambdaQueryWrapper<McpTool> wrapper = Wrappers.lambdaQuery();
wrapper.eq(StringUtils.hasText(bo.getType()), McpTool::getType, bo.getType())
.eq(StringUtils.hasText(bo.getStatus()), McpTool::getStatus, bo.getStatus())
.like(StringUtils.hasText(bo.getName()), McpTool::getName, bo.getName())
.like(StringUtils.hasText(bo.getDescription()), McpTool::getDescription, bo.getDescription());
return wrapper;
}
}

View File

@@ -0,0 +1,151 @@
package org.ruoyi.mcp.tools;
import dev.langchain4j.agent.tool.Tool;
import org.ruoyi.mcp.service.core.BuiltinToolProvider;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.Arrays;
import java.util.List;
/**
* 编辑文件工具
* 支持基于diff的文件编辑
*/
@Component
public class EditFileTool implements BuiltinToolProvider {
public static final String DESCRIPTION = "Edits a file by applying a diff. " +
"Use this tool when you need to make specific changes to a file. " +
"The tool will show the diff before applying changes. " +
"Use absolute paths within the workspace directory.";
private final String rootDirectory;
private final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(getClass());
public EditFileTool() {
this.rootDirectory = Paths.get(System.getProperty("user.dir"), "workspace").toString();
}
/**
* 编辑文件
*
* @param filePath 文件绝对路径
* @param diff 要应用的diff内容
* @return 操作结果
*/
@Tool(DESCRIPTION)
public String editFile(String filePath, String diff) {
try {
// 验证参数
if (filePath == null || filePath.trim().isEmpty()) {
return "Error: File path cannot be empty";
}
if (diff == null || diff.trim().isEmpty()) {
return "Error: Diff cannot be empty";
}
Path path = Paths.get(filePath);
// 验证是否为绝对路径
if (!path.isAbsolute()) {
return "Error: File path must be absolute: " + filePath;
}
// 验证是否在工作目录内
if (!isWithinWorkspace(path)) {
return "Error: File path must be within the workspace directory (" + rootDirectory + "): " + filePath;
}
// 检查文件是否存在
if (!Files.exists(path)) {
return "Error: File not found: " + filePath;
}
// 检查是否为目录
if (Files.isDirectory(path)) {
return "Error: Path is a directory, not a file: " + filePath;
}
// 读取原始内容
String originalContent = Files.readString(path, StandardCharsets.UTF_8);
List<String> originalLines = Arrays.asList(originalContent.split("\n"));
// 应用diff
try {
// 这里简化处理,直接用新内容替换
// 在实际应用中可能需要更复杂的diff解析
String newContent = applyDiff(originalContent, diff);
// 写入文件
Files.writeString(path, newContent, StandardCharsets.UTF_8,
StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING);
String relativePath = getRelativePath(path);
return String.format("Successfully edited file: %s", relativePath);
} catch (Exception e) {
return "Error: Failed to apply diff: " + e.getMessage();
}
} catch (IOException e) {
logger.error("Error editing file: {}", filePath, e);
return "Error: " + e.getMessage();
} catch (Exception e) {
logger.error("Unexpected error editing file: {}", filePath, e);
return "Error: Unexpected error: " + e.getMessage();
}
}
/**
* 简化的diff应用逻辑
* 实际应用中可能需要使用更复杂的diff解析器
*/
private String applyDiff(String originalContent, String diff) {
// 这里简化处理实际应用中需要解析diff格式
// 目前将diff作为新内容直接替换
// 可以考虑使用jgit等库来解析 unified diff 格式
return diff;
}
private boolean isWithinWorkspace(Path filePath) {
try {
Path workspaceRoot = Paths.get(rootDirectory).toRealPath();
Path normalizedPath = filePath.normalize();
return normalizedPath.startsWith(workspaceRoot.normalize());
} catch (IOException e) {
logger.warn("Could not resolve workspace path", e);
return false;
}
}
private String getRelativePath(Path filePath) {
try {
Path workspaceRoot = Paths.get(rootDirectory);
return workspaceRoot.relativize(filePath).toString();
} catch (Exception e) {
return filePath.toString();
}
}
@Override
public String getToolName() {
return "edit_file";
}
@Override
public String getDisplayName() {
return "编辑文件";
}
@Override
public String getDescription() {
return DESCRIPTION;
}
}

View File

@@ -0,0 +1,285 @@
package org.ruoyi.mcp.tools;
import dev.langchain4j.agent.tool.Tool;
import org.ruoyi.mcp.service.core.BuiltinToolProvider;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Stream;
/**
* 目录列表工具
* 列出指定目录的文件和子目录,支持递归列表
*/
@Component
public class ListDirectoryTool implements BuiltinToolProvider {
public static final String DESCRIPTION = "Lists files and directories in the specified path. " +
"Supports recursive listing and filtering. " +
"Shows file sizes, modification times, and types. " +
"Use absolute paths within the workspace directory.";
private final String rootDirectory;
private final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(getClass());
public ListDirectoryTool() {
this.rootDirectory = Paths.get(System.getProperty("user.dir"), "workspace").toString();
}
/**
* 列出目录内容
*
* @param filePath 目录绝对路径
* @param recursive 是否递归列出子目录(可选,默认 false
* @param maxDepth 最大递归深度(可选,默认 3范围 1-10
* @return 目录列表结果
*/
@Tool(DESCRIPTION)
public String listDirectory(String filePath, Boolean recursive, Integer maxDepth) {
// 创建参数对象
ListDirectoryParams params = new ListDirectoryParams();
params.filePath = filePath;
params.recursive = recursive != null ? recursive : false;
params.maxDepth = maxDepth != null ? maxDepth : 3;
return execute(params);
}
public String execute(ListDirectoryParams params) {
try {
// 验证参数
String validationError = validateParams(params);
if (validationError != null) {
return "Error: " + validationError;
}
Path dirPath = Paths.get(params.filePath);
// 检查目录是否存在
if (!Files.exists(dirPath)) {
return "Error: Directory not found: " + params.filePath;
}
// 检查是否为目录
if (!Files.isDirectory(dirPath)) {
return "Error: Path is not a directory: " + params.filePath;
}
// 列出文件和目录
List<FileInfo> fileInfos = listFiles(dirPath, params);
// 生成输出
return formatFileList(fileInfos, params);
} catch (IOException e) {
logger.error("Error listing directory: {}", params.filePath, e);
return "Error: " + e.getMessage();
} catch (Exception e) {
logger.error("Unexpected error listing directory: {}", params.filePath, e);
return "Error: Unexpected error: " + e.getMessage();
}
}
private String validateParams(ListDirectoryParams params) {
// 验证路径
if (params.filePath == null || params.filePath.trim().isEmpty()) {
return "Directory path cannot be empty";
}
Path dirPath = Paths.get(params.filePath);
// 验证是否为绝对路径
if (!dirPath.isAbsolute()) {
return "Directory path must be absolute: " + params.filePath;
}
// 验证是否在工作目录内
if (!isWithinWorkspace(dirPath)) {
return "Directory path must be within the workspace directory (" + rootDirectory + "): " + params.filePath;
}
// 验证最大深度
if (params.maxDepth != null && (params.maxDepth < 1 || params.maxDepth > 10)) {
return "Max depth must be between 1 and 10";
}
return null;
}
private List<FileInfo> listFiles(Path dirPath, ListDirectoryParams params) throws IOException {
List<FileInfo> fileInfos = new ArrayList<>();
if (params.recursive != null && params.recursive) {
int maxDepth = params.maxDepth != null ? params.maxDepth : 3;
listFilesRecursive(dirPath, fileInfos, 0, maxDepth, params);
} else {
listFilesInDirectory(dirPath, fileInfos, params);
}
// 排序:目录在前,然后按名称排序
fileInfos.sort(Comparator
.comparing((FileInfo f) -> !f.isDirectory())
.thenComparing(FileInfo::name));
return fileInfos;
}
private void listFilesInDirectory(Path dirPath, List<FileInfo> fileInfos, ListDirectoryParams params) throws IOException {
try (Stream<Path> stream = Files.list(dirPath)) {
stream.forEach(path -> {
try {
FileInfo fileInfo = createFileInfo(path, dirPath);
fileInfos.add(fileInfo);
} catch (IOException e) {
logger.warn("Could not get info for file: " + path, e);
}
});
}
}
private void listFilesRecursive(Path dirPath, List<FileInfo> fileInfos, int currentDepth, int maxDepth, ListDirectoryParams params) throws IOException {
if (currentDepth >= maxDepth) {
return;
}
try (Stream<Path> stream = Files.list(dirPath)) {
List<Path> paths = stream.toList();
for (Path path : paths) {
try {
FileInfo fileInfo = createFileInfo(path, Paths.get(params.filePath));
fileInfos.add(fileInfo);
// 如果是目录,递归列出
if (Files.isDirectory(path)) {
listFilesRecursive(path, fileInfos, currentDepth + 1, maxDepth, params);
}
} catch (IOException e) {
logger.warn("Could not get info for file: " + path, e);
}
}
}
}
private FileInfo createFileInfo(Path path, Path basePath) throws IOException {
String name = path.getFileName().toString();
boolean isDirectory = Files.isDirectory(path);
long size = isDirectory ? 0 : Files.size(path);
LocalDateTime lastModified = LocalDateTime.ofInstant(
Files.getLastModifiedTime(path).toInstant(),
ZoneId.systemDefault()
);
String relativePath = basePath.relativize(path).toString();
return new FileInfo(name, relativePath, isDirectory, size, lastModified);
}
private String formatFileList(List<FileInfo> fileInfos, ListDirectoryParams params) {
if (fileInfos.isEmpty()) {
return "Directory is empty.";
}
StringBuilder sb = new StringBuilder();
sb.append(String.format("Directory listing for: %s\n", getRelativePath(Paths.get(params.filePath))));
sb.append(String.format("Total items: %d\n\n", fileInfos.size()));
// 表头
sb.append(String.format("%-4s %-40s %-12s %-20s %s\n",
"Type", "Name", "Size", "Modified", "Path"));
sb.append("-".repeat(80)).append("\n");
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
for (FileInfo fileInfo : fileInfos) {
String type = fileInfo.isDirectory() ? "DIR" : "FILE";
String sizeStr = fileInfo.isDirectory() ? "-" : formatFileSize(fileInfo.size());
String modifiedStr = fileInfo.lastModified().format(formatter);
sb.append(String.format("%-4s %-40s %-12s %-20s %s\n",
type,
truncate(fileInfo.name()),
sizeStr,
modifiedStr,
fileInfo.relativePath()
));
}
return sb.toString();
}
private String formatFileSize(long bytes) {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return String.format("%.1f KB", bytes / 1024.0);
if (bytes < 1024 * 1024 * 1024) return String.format("%.1f MB", bytes / (1024.0 * 1024));
return String.format("%.1f GB", bytes / (1024.0 * 1024 * 1024));
}
private String truncate(String str) {
if (str.length() <= 40) {
return str;
}
return str.substring(0, 40 - 3) + "...";
}
private boolean isWithinWorkspace(Path dirPath) {
try {
Path workspaceRoot = Paths.get(rootDirectory).toRealPath();
Path normalizedPath = dirPath.normalize();
return normalizedPath.startsWith(workspaceRoot.normalize());
} catch (IOException e) {
logger.warn("Could not resolve workspace path", e);
return false;
}
}
private String getRelativePath(Path dirPath) {
try {
Path workspaceRoot = Paths.get(rootDirectory);
return workspaceRoot.relativize(dirPath).toString();
} catch (Exception e) {
return dirPath.toString();
}
}
@Override
public String getToolName() {
return "list_directory";
}
@Override
public String getDisplayName() {
return "列出目录";
}
@Override
public String getDescription() {
return DESCRIPTION;
}
/**
* 文件信息
*/
public record FileInfo(String name, String relativePath, boolean isDirectory, long size,
LocalDateTime lastModified) {
}
/**
* 列表目录参数
*/
public static class ListDirectoryParams {
public String filePath;
public Boolean recursive;
public Integer maxDepth;
}
}

View File

@@ -0,0 +1,121 @@
package org.ruoyi.mcp.tools;
import dev.langchain4j.agent.tool.Tool;
import org.ruoyi.mcp.service.core.BuiltinToolProvider;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* 读取文件工具
* 读取指定路径的文件内容
*/
@Component
public class ReadFileTool implements BuiltinToolProvider {
public static final String DESCRIPTION = "Reads the contents of a file. " +
"Use absolute paths within the workspace directory. " +
"Returns the complete file content as a string.";
private final String rootDirectory;
private final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(getClass());
public ReadFileTool() {
this.rootDirectory = Paths.get(System.getProperty("user.dir"), "workspace").toString();
}
/**
* 读取文件内容
*
* @param filePath 文件绝对路径
* @return 文件内容
*/
@Tool(DESCRIPTION)
public String readFile(String filePath) {
try {
// 验证参数
if (filePath == null || filePath.trim().isEmpty()) {
return "Error: File path cannot be empty";
}
Path path = Paths.get(filePath);
// 验证是否为绝对路径
if (!path.isAbsolute()) {
return "Error: File path must be absolute: " + filePath;
}
// 验证是否在工作目录内
if (!isWithinWorkspace(path)) {
return "Error: File path must be within the workspace directory (" + rootDirectory + "): " + filePath;
}
// 检查文件是否存在
if (!Files.exists(path)) {
return "Error: File not found: " + filePath;
}
// 检查是否为目录
if (Files.isDirectory(path)) {
return "Error: Path is a directory, not a file: " + filePath;
}
// 读取文件内容
String content = Files.readString(path, StandardCharsets.UTF_8);
// 获取相对路径
String relativePath = getRelativePath(path);
long sizeBytes = content.getBytes(StandardCharsets.UTF_8).length;
long lineCount = content.lines().count();
return String.format("File: %s (%d lines, %d bytes)\n\n%s",
relativePath, lineCount, sizeBytes, content);
} catch (IOException e) {
logger.error("Error reading file: {}", filePath, e);
return "Error: " + e.getMessage();
} catch (Exception e) {
logger.error("Unexpected error reading file: {}", filePath, e);
return "Error: Unexpected error: " + e.getMessage();
}
}
private boolean isWithinWorkspace(Path filePath) {
try {
Path workspaceRoot = Paths.get(rootDirectory).toRealPath();
Path normalizedPath = filePath.normalize();
return normalizedPath.startsWith(workspaceRoot.normalize());
} catch (IOException e) {
logger.warn("Could not resolve workspace path", e);
return false;
}
}
private String getRelativePath(Path filePath) {
try {
Path workspaceRoot = Paths.get(rootDirectory);
return workspaceRoot.relativize(filePath).toString();
} catch (Exception e) {
return filePath.toString();
}
}
@Override
public String getToolName() {
return "read_file";
}
@Override
public String getDisplayName() {
return "读取文件";
}
@Override
public String getDescription() {
return DESCRIPTION;
}
}