From 593a0d0049d8e4eaf3ce140ba37aa8df5f0c4eff Mon Sep 17 00:00:00 2001 From: evo <446796145@qq.com> Date: Mon, 23 Feb 2026 16:07:13 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0mcp=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/script/sql/ruoyi-ai-v3_mysql8.sql | 159 +++++++ pom.xml | 16 + ruoyi-admin/pom.xml | 6 + ruoyi-common/ruoyi-common-excel/pom.xml | 6 + .../common/sse/utils/SseMessageUtils.java | 3 - ruoyi-modules/pom.xml | 1 + .../workflow/workflow/WorkflowStarter.java | 1 - .../workflow/node/AbstractWfNode.java | 1 - .../KeywordExtractorNode.java | 1 - ruoyi-modules/ruoyi-chat/pom.xml | 5 + .../ruoyi/agent/tool/QueryAllTablesTool.java | 3 - .../knowledge/KnowledgeGraphInstanceVo.java | 2 - .../vo/knowledge/KnowledgeGraphSegmentVo.java | 2 - .../impl/provider/QianWenChatServiceImpl.java | 76 ++- .../ruoyi/generator/util/VelocityUtils.java | 2 - ruoyi-modules/ruoyi-mcp/pom.xml | 77 +++ .../org/ruoyi/mcp/config/McpProperties.java | 40 ++ .../mcp/config/SystemToolInitializer.java | 93 ++++ .../mcp/controller/McpMarketController.java | 171 +++++++ .../mcp/controller/McpToolController.java | 136 ++++++ .../org/ruoyi/mcp/domain/bo/McpMarketBo.java | 55 +++ .../org/ruoyi/mcp/domain/bo/McpToolBo.java | 59 +++ .../mcp/domain/dto/McpMarketListResult.java | 44 ++ .../domain/dto/McpMarketRefreshResult.java | 38 ++ .../domain/dto/McpMarketToolListResult.java | 63 +++ .../mcp/domain/dto/McpToolListResult.java | 44 ++ .../mcp/domain/dto/McpToolTestResult.java | 56 +++ .../ruoyi/mcp/domain/entity/McpMarket.java | 51 ++ .../mcp/domain/entity/McpMarketTool.java | 61 +++ .../org/ruoyi/mcp/domain/entity/McpTool.java | 55 +++ .../org/ruoyi/mcp/domain/vo/McpMarketVo.java | 74 +++ .../org/ruoyi/mcp/domain/vo/McpToolVo.java | 74 +++ .../org/ruoyi/mcp/enums/McpToolStatus.java | 47 ++ .../org/ruoyi/mcp/mapper/McpMarketMapper.java | 15 + .../ruoyi/mcp/mapper/McpMarketToolMapper.java | 14 + .../org/ruoyi/mcp/mapper/McpToolMapper.java | 15 + .../ruoyi/mcp/service/IMcpMarketService.java | 117 +++++ .../ruoyi/mcp/service/IMcpToolService.java | 92 ++++ .../service/core/BuiltinToolDefinition.java | 13 + .../mcp/service/core/BuiltinToolProvider.java | 55 +++ .../mcp/service/core/BuiltinToolRegistry.java | 129 +++++ .../LangChain4jMcpToolProviderService.java | 445 ++++++++++++++++++ .../mcp/service/core/ToolProviderFactory.java | 171 +++++++ .../service/impl/McpMarketServiceImpl.java | 328 +++++++++++++ .../mcp/service/impl/McpToolServiceImpl.java | 226 +++++++++ .../org/ruoyi/mcp/tools/EditFileTool.java | 151 ++++++ .../ruoyi/mcp/tools/ListDirectoryTool.java | 285 +++++++++++ .../org/ruoyi/mcp/tools/ReadFileTool.java | 121 +++++ 48 files changed, 3643 insertions(+), 56 deletions(-) create mode 100644 ruoyi-modules/ruoyi-mcp/pom.xml create mode 100644 ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/config/McpProperties.java create mode 100644 ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/config/SystemToolInitializer.java create mode 100644 ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/controller/McpMarketController.java create mode 100644 ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/controller/McpToolController.java create mode 100644 ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/bo/McpMarketBo.java create mode 100644 ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/bo/McpToolBo.java create mode 100644 ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/dto/McpMarketListResult.java create mode 100644 ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/dto/McpMarketRefreshResult.java create mode 100644 ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/dto/McpMarketToolListResult.java create mode 100644 ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/dto/McpToolListResult.java create mode 100644 ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/dto/McpToolTestResult.java create mode 100644 ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/entity/McpMarket.java create mode 100644 ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/entity/McpMarketTool.java create mode 100644 ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/entity/McpTool.java create mode 100644 ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/vo/McpMarketVo.java create mode 100644 ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/vo/McpToolVo.java create mode 100644 ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/enums/McpToolStatus.java create mode 100644 ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/mapper/McpMarketMapper.java create mode 100644 ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/mapper/McpMarketToolMapper.java create mode 100644 ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/mapper/McpToolMapper.java create mode 100644 ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/IMcpMarketService.java create mode 100644 ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/IMcpToolService.java create mode 100644 ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/core/BuiltinToolDefinition.java create mode 100644 ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/core/BuiltinToolProvider.java create mode 100644 ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/core/BuiltinToolRegistry.java create mode 100644 ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/core/LangChain4jMcpToolProviderService.java create mode 100644 ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/core/ToolProviderFactory.java create mode 100644 ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/impl/McpMarketServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/impl/McpToolServiceImpl.java create mode 100644 ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/tools/EditFileTool.java create mode 100644 ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/tools/ListDirectoryTool.java create mode 100644 ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/tools/ReadFileTool.java diff --git a/docs/script/sql/ruoyi-ai-v3_mysql8.sql b/docs/script/sql/ruoyi-ai-v3_mysql8.sql index e7aec981..b1528ce2 100644 --- a/docs/script/sql/ruoyi-ai-v3_mysql8.sql +++ b/docs/script/sql/ruoyi-ai-v3_mysql8.sql @@ -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配置管理菜单'); + + diff --git a/pom.xml b/pom.xml index 2e5a27ab..e10bf329 100644 --- a/pom.xml +++ b/pom.xml @@ -59,6 +59,8 @@ 1.5.3 1.19.6 1.0.7 + + 1.27.1 1.1.0 @@ -402,6 +404,13 @@ ${revision} + + + org.ruoyi + ruoyi-mcp + ${revision} + + com.github.binarywang @@ -416,6 +425,13 @@ ${jackson-dataformat-xml.version} + + + org.apache.commons + commons-compress + ${commons-compress.version} + + diff --git a/ruoyi-admin/pom.xml b/ruoyi-admin/pom.xml index 28ce987a..1ee16ddb 100644 --- a/ruoyi-admin/pom.xml +++ b/ruoyi-admin/pom.xml @@ -116,6 +116,12 @@ ruoyi-aiflow + + + org.ruoyi + ruoyi-mcp + + de.codecentric spring-boot-admin-starter-client diff --git a/ruoyi-common/ruoyi-common-excel/pom.xml b/ruoyi-common/ruoyi-common-excel/pom.xml index 51a34a08..e48189f9 100644 --- a/ruoyi-common/ruoyi-common-excel/pom.xml +++ b/ruoyi-common/ruoyi-common-excel/pom.xml @@ -25,6 +25,12 @@ cn.idev.excel fastexcel + + + + org.apache.commons + commons-compress + diff --git a/ruoyi-common/ruoyi-common-sse/src/main/java/org/ruoyi/common/sse/utils/SseMessageUtils.java b/ruoyi-common/ruoyi-common-sse/src/main/java/org/ruoyi/common/sse/utils/SseMessageUtils.java index ec4e95ca..b4c224d5 100644 --- a/ruoyi-common/ruoyi-common-sse/src/main/java/org/ruoyi/common/sse/utils/SseMessageUtils.java +++ b/ruoyi-common/ruoyi-common-sse/src/main/java/org/ruoyi/common/sse/utils/SseMessageUtils.java @@ -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工具类 diff --git a/ruoyi-modules/pom.xml b/ruoyi-modules/pom.xml index 17b9a97e..67d9ecfd 100644 --- a/ruoyi-modules/pom.xml +++ b/ruoyi-modules/pom.xml @@ -16,6 +16,7 @@ ruoyi-demo ruoyi-generator ruoyi-job + ruoyi-mcp ruoyi-system ruoyi-wechat ruoyi-workflow diff --git a/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/WorkflowStarter.java b/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/WorkflowStarter.java index 7865af51..4cb377a1 100644 --- a/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/WorkflowStarter.java +++ b/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/WorkflowStarter.java @@ -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 diff --git a/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/node/AbstractWfNode.java b/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/node/AbstractWfNode.java index d3262971..e0cef0fd 100644 --- a/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/node/AbstractWfNode.java +++ b/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/node/AbstractWfNode.java @@ -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; diff --git a/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/node/keywordExtractor/KeywordExtractorNode.java b/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/node/keywordExtractor/KeywordExtractorNode.java index 5ad7147a..b3f85796 100644 --- a/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/node/keywordExtractor/KeywordExtractorNode.java +++ b/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/workflow/node/keywordExtractor/KeywordExtractorNode.java @@ -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; diff --git a/ruoyi-modules/ruoyi-chat/pom.xml b/ruoyi-modules/ruoyi-chat/pom.xml index ae85abd9..26c66ef5 100644 --- a/ruoyi-modules/ruoyi-chat/pom.xml +++ b/ruoyi-modules/ruoyi-chat/pom.xml @@ -19,6 +19,11 @@ ruoyi-common-chat + + org.ruoyi + ruoyi-mcp + + org.ruoyi ruoyi-common-sensitive diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/tool/QueryAllTablesTool.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/tool/QueryAllTablesTool.java index 9e490dc3..81623dca 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/tool/QueryAllTablesTool.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/agent/tool/QueryAllTablesTool.java @@ -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; diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/knowledge/KnowledgeGraphInstanceVo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/knowledge/KnowledgeGraphInstanceVo.java index 25045920..f358b6f5 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/knowledge/KnowledgeGraphInstanceVo.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/knowledge/KnowledgeGraphInstanceVo.java @@ -10,8 +10,6 @@ import org.ruoyi.domain.entity.knowledge.KnowledgeGraphInstance; import java.io.Serial; import java.io.Serializable; -import java.util.Date; - /** diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/knowledge/KnowledgeGraphSegmentVo.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/knowledge/KnowledgeGraphSegmentVo.java index 78973e84..1d534e38 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/knowledge/KnowledgeGraphSegmentVo.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/knowledge/KnowledgeGraphSegmentVo.java @@ -10,8 +10,6 @@ import org.ruoyi.domain.entity.knowledge.KnowledgeGraphSegment; import java.io.Serial; import java.io.Serializable; -import java.util.Date; - /** diff --git a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/QianWenChatServiceImpl.java b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/QianWenChatServiceImpl.java index ab583520..26c1858c 100644 --- a/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/QianWenChatServiceImpl.java +++ b/ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/provider/QianWenChatServiceImpl.java @@ -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 builtinTools = toolProviderFactory.getAllBuiltinToolObjects(); - // 步骤2:开启客户端连接 - McpClient mcpClient = new DefaultMcpClient.Builder() - .transport(httpMcpTransport) - .build(); + // 步骤3: 获取 MCP 工具提供者 + ToolProvider mcpToolProvider = toolProviderFactory.getAllEnabledMcpToolsProvider(); - // 获取所有mcp工具 - List 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); } diff --git a/ruoyi-modules/ruoyi-generator/src/main/java/org/ruoyi/generator/util/VelocityUtils.java b/ruoyi-modules/ruoyi-generator/src/main/java/org/ruoyi/generator/util/VelocityUtils.java index 9b591bfc..cb78b33d 100644 --- a/ruoyi-modules/ruoyi-generator/src/main/java/org/ruoyi/generator/util/VelocityUtils.java +++ b/ruoyi-modules/ruoyi-generator/src/main/java/org/ruoyi/generator/util/VelocityUtils.java @@ -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.*; diff --git a/ruoyi-modules/ruoyi-mcp/pom.xml b/ruoyi-modules/ruoyi-mcp/pom.xml new file mode 100644 index 00000000..e5dfa27a --- /dev/null +++ b/ruoyi-modules/ruoyi-mcp/pom.xml @@ -0,0 +1,77 @@ + + + 4.0.0 + + + org.ruoyi + ruoyi-modules + ${revision} + + + ruoyi-mcp + + + MCP模块 - 管理MCP工具连接、市场集成和内置工具 + + + + + + org.ruoyi + ruoyi-common-core + + + + org.ruoyi + ruoyi-common-web + + + + org.ruoyi + ruoyi-common-mybatis + + + + org.ruoyi + ruoyi-common-log + + + + org.ruoyi + ruoyi-common-tenant + + + + org.ruoyi + ruoyi-common-security + + + + org.ruoyi + ruoyi-common-excel + + + + org.ruoyi + ruoyi-common-idempotent + + + + + dev.langchain4j + langchain4j-mcp + ${langchain4j.community.version} + + + + + com.fasterxml.jackson.core + jackson-databind + + + + + diff --git a/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/config/McpProperties.java b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/config/McpProperties.java new file mode 100644 index 00000000..e686c9a2 --- /dev/null +++ b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/config/McpProperties.java @@ -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; + } +} diff --git a/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/config/SystemToolInitializer.java b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/config/SystemToolInitializer.java new file mode 100644 index 00000000..30647c65 --- /dev/null +++ b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/config/SystemToolInitializer.java @@ -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 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; + } +} diff --git a/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/controller/McpMarketController.java b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/controller/McpMarketController.java new file mode 100644 index 00000000..52a0eacc --- /dev/null +++ b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/controller/McpMarketController.java @@ -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 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 list = mcpMarketService.queryList(bo); + ExcelUtil.exportExcel(list, "MCP市场", McpMarketVo.class, response); + } + + /** + * 根据市场ID获取详细信息 + * + * @param id 市场ID + */ + @SaCheckPermission("mcp:market:query") + @GetMapping("/{id}") + public R getInfo(@PathVariable Long id) { + return R.ok(mcpMarketService.selectById(id)); + } + + /** + * 新增市场 + */ + @SaCheckPermission("mcp:market:add") + @Log(title = "MCP市场管理", businessType = BusinessType.INSERT) + @RepeatSubmit() + @PostMapping + public R 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 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 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 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 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 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> batchLoadTools(@RequestBody List toolIds) { + int successCount = mcpMarketService.batchLoadTools(toolIds); + return R.ok(Map.of("successCount", successCount)); + } +} diff --git a/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/controller/McpToolController.java b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/controller/McpToolController.java new file mode 100644 index 00000000..eae82f72 --- /dev/null +++ b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/controller/McpToolController.java @@ -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 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 list = mcpToolService.queryList(bo); + ExcelUtil.exportExcel(list, "MCP工具", McpToolVo.class, response); + } + + /** + * 根据工具ID获取详细信息 + * + * @param id 工具ID + */ + @SaCheckPermission("mcp:tool:query") + @GetMapping("/{id}") + public R 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 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 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 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 updateStatus(@PathVariable Long id, @RequestParam String status) { + mcpToolService.updateStatus(id, status); + return R.ok(); + } + + /** + * 测试工具连接 + */ + @SaCheckPermission("mcp:tool:query") + @PostMapping("/{id}/test") + public R testTool(@PathVariable Long id) { + return R.ok(mcpToolService.testTool(id)); + } +} diff --git a/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/bo/McpMarketBo.java b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/bo/McpMarketBo.java new file mode 100644 index 00000000..00493b8d --- /dev/null +++ b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/bo/McpMarketBo.java @@ -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; + +} diff --git a/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/bo/McpToolBo.java b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/bo/McpToolBo.java new file mode 100644 index 00000000..7bb2005a --- /dev/null +++ b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/bo/McpToolBo.java @@ -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; + +} diff --git a/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/dto/McpMarketListResult.java b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/dto/McpMarketListResult.java new file mode 100644 index 00000000..69f0a5d9 --- /dev/null +++ b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/dto/McpMarketListResult.java @@ -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 data; + + /** + * 总数 + */ + private int total; + + public static McpMarketListResult of(List data) { + return McpMarketListResult.builder() + .success(true) + .data(data) + .total(data != null ? data.size() : 0) + .build(); + } +} diff --git a/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/dto/McpMarketRefreshResult.java b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/dto/McpMarketRefreshResult.java new file mode 100644 index 00000000..d52e91c7 --- /dev/null +++ b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/dto/McpMarketRefreshResult.java @@ -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; +} diff --git a/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/dto/McpMarketToolListResult.java b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/dto/McpMarketToolListResult.java new file mode 100644 index 00000000..8f2e6a81 --- /dev/null +++ b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/dto/McpMarketToolListResult.java @@ -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 data; + + /** + * 总数 + */ + private long total; + + /** + * 当前页 + */ + private int page; + + /** + * 每页大小 + */ + private int size; + + /** + * 总页数 + */ + private long pages; + + public static McpMarketToolListResult of(List 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(); + } +} diff --git a/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/dto/McpToolListResult.java b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/dto/McpToolListResult.java new file mode 100644 index 00000000..e330abc2 --- /dev/null +++ b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/dto/McpToolListResult.java @@ -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 data; + + /** + * 总数 + */ + private int total; + + public static McpToolListResult of(List data) { + return McpToolListResult.builder() + .success(true) + .data(data) + .total(data != null ? data.size() : 0) + .build(); + } +} diff --git a/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/dto/McpToolTestResult.java b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/dto/McpToolTestResult.java new file mode 100644 index 00000000..eeb6f3dd --- /dev/null +++ b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/dto/McpToolTestResult.java @@ -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 tools; + + public static McpToolTestResult success(String message, int toolCount, List 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(); + } +} diff --git a/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/entity/McpMarket.java b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/entity/McpMarket.java new file mode 100644 index 00000000..8d2cf21e --- /dev/null +++ b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/entity/McpMarket.java @@ -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; + +} diff --git a/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/entity/McpMarketTool.java b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/entity/McpMarketTool.java new file mode 100644 index 00000000..69b942ab --- /dev/null +++ b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/entity/McpMarketTool.java @@ -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; + +} diff --git a/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/entity/McpTool.java b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/entity/McpTool.java new file mode 100644 index 00000000..0a5b3088 --- /dev/null +++ b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/entity/McpTool.java @@ -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; + +} diff --git a/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/vo/McpMarketVo.java b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/vo/McpMarketVo.java new file mode 100644 index 00000000..243604cf --- /dev/null +++ b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/vo/McpMarketVo.java @@ -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; + +} diff --git a/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/vo/McpToolVo.java b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/vo/McpToolVo.java new file mode 100644 index 00000000..88cd2494 --- /dev/null +++ b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/domain/vo/McpToolVo.java @@ -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; + +} diff --git a/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/enums/McpToolStatus.java b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/enums/McpToolStatus.java new file mode 100644 index 00000000..caf8c49b --- /dev/null +++ b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/enums/McpToolStatus.java @@ -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); + } +} diff --git a/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/mapper/McpMarketMapper.java b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/mapper/McpMarketMapper.java new file mode 100644 index 00000000..7d9ed31d --- /dev/null +++ b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/mapper/McpMarketMapper.java @@ -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 { +} diff --git a/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/mapper/McpMarketToolMapper.java b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/mapper/McpMarketToolMapper.java new file mode 100644 index 00000000..2211906d --- /dev/null +++ b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/mapper/McpMarketToolMapper.java @@ -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 { +} diff --git a/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/mapper/McpToolMapper.java b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/mapper/McpToolMapper.java new file mode 100644 index 00000000..5e46f399 --- /dev/null +++ b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/mapper/McpToolMapper.java @@ -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 { +} diff --git a/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/IMcpMarketService.java b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/IMcpMarketService.java new file mode 100644 index 00000000..b7a68b1f --- /dev/null +++ b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/IMcpMarketService.java @@ -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 selectPageList(McpMarketBo bo, PageQuery pageQuery); + + /** + * 查询市场列表(不分页) + * + * @param keyword 关键词 + * @param status 状态 + * @return 市场列表结果 + */ + McpMarketListResult listMarkets(String keyword, String status); + + /** + * 查询市场列表(用于导出) + * + * @param bo 查询条件 + * @return 市场列表 + */ + List 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 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 toolIds); +} diff --git a/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/IMcpToolService.java b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/IMcpToolService.java new file mode 100644 index 00000000..d7cba323 --- /dev/null +++ b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/IMcpToolService.java @@ -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 selectPageList(McpToolBo bo, PageQuery pageQuery); + + /** + * 查询工具列表(不分页) + * + * @param keyword 关键词 + * @param type 类型 + * @param status 状态 + * @return 工具列表结果 + */ + McpToolListResult listTools(String keyword, String type, String status); + + /** + * 查询工具列表(用于导出) + * + * @param bo 查询条件 + * @return 工具列表 + */ + List 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 ids); + + /** + * 更新工具状态 + * + * @param id 工具 ID + * @param status 状态 + */ + void updateStatus(Long id, String status); + + /** + * 测试工具连接 + * + * @param id 工具 ID + * @return 测试结果 + */ + McpToolTestResult testTool(Long id); +} diff --git a/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/core/BuiltinToolDefinition.java b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/core/BuiltinToolDefinition.java new file mode 100644 index 00000000..96890034 --- /dev/null +++ b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/core/BuiltinToolDefinition.java @@ -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) { +} diff --git a/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/core/BuiltinToolProvider.java b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/core/BuiltinToolProvider.java new file mode 100644 index 00000000..d72922b0 --- /dev/null +++ b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/core/BuiltinToolProvider.java @@ -0,0 +1,55 @@ +package org.ruoyi.mcp.service.core; + +/** + * 内置工具提供者接口 + * 所有系统内置工具都应实现此接口,以便自动注册到 BuiltinToolRegistry + * + * @author ruoyi team + * + *

使用方式: + *

+ * {@code
+ * @Component
+ * public class MyTool implements BuiltinToolProvider {
+ *     @Override
+ *     public String getToolName() {
+ *         return "my_tool";
+ *     }
+ *
+ *     @Override
+ *     public String getDisplayName() {
+ *         return "我的工具";
+ *     }
+ *
+ *     @Override
+ *     public String getDescription() {
+ *         return "工具描述...";
+ *     }
+ * }
+ * }
+ * 
+ */ +public interface BuiltinToolProvider { + + /** + * 获取工具名称(唯一标识,用于数据库存储) + * 建议使用 snake_case 格式,如:list_directory, edit_file + * + * @return 工具名称 + */ + String getToolName(); + + /** + * 获取工具显示名称(用于 UI 展示) + * + * @return 显示名称 + */ + String getDisplayName(); + + /** + * 获取工具描述(用于 AI 理解工具用途) + * + * @return 工具描述 + */ + String getDescription(); +} diff --git a/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/core/BuiltinToolRegistry.java b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/core/BuiltinToolRegistry.java new file mode 100644 index 00000000..b3953b7f --- /dev/null +++ b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/core/BuiltinToolRegistry.java @@ -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} 接口的工具 + * + *

工具注册流程: + *

    + *
  1. Spring 自动注入所有 {@link BuiltinToolProvider} 实现
  2. + *
  3. {@link #init()} 方法在 Bean 初始化后自动调用
  4. + *
  5. 将所有工具注册到内部 Map
  6. + *
+ * + *

添加新工具只需: + *

    + *
  1. 创建一个类实现 {@link BuiltinToolProvider} 接口
  2. + *
  3. 添加 {@code @Component} 注解
  4. + *
  5. 工具会自动被发现和注册
  6. + *
+ * + * @author ruoyi team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class BuiltinToolRegistry { + + /** + * 工具类型常量 + */ + public static final String TYPE_BUILTIN = "BUILTIN"; + + /** + * Spring 自动注入所有实现 BuiltinToolProvider 接口的 Bean + */ + private final List toolProviders; + + /** + * 内置工具定义映射表 (工具名称 -> 工具提供者) + */ + private final Map 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 getAllBuiltinTools() { + return registeredTools.values().stream() + .map(provider -> new BuiltinToolDefinition( + provider.getToolName(), + provider.getDisplayName(), + provider.getDescription() + )) + .toList(); + } + + /** + * 获取所有内置工具对象 + * 这些对象包含 @Tool 注解的方法,可直接用于 AgenticServices + * + * @return 内置工具对象列表 + */ + public List getAllBuiltinToolObjects() { + return List.copyOf(registeredTools.values()); + } + + /** + * 根据工具名称获取工具对象 + * + * @param toolName 工具名称 + * @return 工具对象,如果不存在则返回 null + */ + public Object getBuiltinToolObject(String toolName) { + return registeredTools.get(toolName); + } +} diff --git a/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/core/LangChain4jMcpToolProviderService.java b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/core/LangChain4jMcpToolProviderService.java new file mode 100644 index 00000000..7abc9fd9 --- /dev/null +++ b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/core/LangChain4jMcpToolProviderService.java @@ -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 activeClients = new ConcurrentHashMap<>(); + /** + * 工具健康状态缓存(工具ID -> 是否健康) + */ + private final Map toolHealthStatus = new ConcurrentHashMap<>(); + /** + * 工具失败次数(工具ID -> 失败次数) + */ + private final Map toolFailureCount = new ConcurrentHashMap<>(); + /** + * 工具禁用时间(工具ID -> 禁用截止时间戳) + */ + private final Map toolDisabledUntil = new ConcurrentHashMap<>(); + + /** + * 根据工具 ID 列表获取 ToolProvider + * + * @param toolIds 工具 ID 列表 + * @return ToolProvider 实例 + */ + public ToolProvider getToolProvider(List toolIds) { + if (toolIds == null || toolIds.isEmpty()) { + return McpToolProvider.builder().build(); + } + + List 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 enabledTools = mcpToolMapper.selectList( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(McpTool::getStatus, McpToolStatus.ENABLED.getValue()) + ); + + if (enabledTools.isEmpty()) { + return McpToolProvider.builder().build(); + } + + List toolIds = enabledTools.stream() + .map(McpTool::getId) + .toList(); + + return getToolProvider(toolIds); + } + + /** + * 获取指定名称的 MCP 工具的 ToolProvider + * + * @param toolNames 工具名称列表 + * @return ToolProvider 实例 + */ + public ToolProvider getToolProviderByNames(List toolNames) { + if (toolNames == null || toolNames.isEmpty()) { + return McpToolProvider.builder().build(); + } + + List tools = mcpToolMapper.selectList( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .in(McpTool::getName, toolNames) + .eq(McpTool::getStatus, McpToolStatus.ENABLED.getValue()) + ); + + if (tools.isEmpty()) { + return McpToolProvider.builder().build(); + } + + List 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 getAllToolsHealthStatus() { + List allTools = mcpToolMapper.selectList( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(McpTool::getStatus, McpToolStatus.ENABLED.getValue()) + ); + + Map 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 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 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(); + } +} diff --git a/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/core/ToolProviderFactory.java b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/core/ToolProviderFactory.java new file mode 100644 index 00000000..edf21bb3 --- /dev/null +++ b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/core/ToolProviderFactory.java @@ -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服务提供统一的工具获取入口 + * + *

支持的工具类型: + *

    + *
  • BUILTIN - 内置工具(如文件操作工具)
  • + *
  • LOCAL - 本地STDIO工具(通过命令行启动的MCP服务器)
  • + *
  • REMOTE - 远程HTTP/SSE工具(通过网络连接的MCP服务器)
  • + *
+ * + * @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 toolIds) { + if (toolIds == null || toolIds.isEmpty()) { + return McpToolProvider.builder().build(); + } + + // 只获取非内置工具(LangChain4j的MCP工具) + List 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 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() + .eq(McpTool::getName, toolName) + .last("LIMIT 1") + ); + return tool != null ? tool.getId() : null; + } + + /** + * 根据工具名称列表获取工具ID列表 + * + * @param toolNames 工具名称列表 + * @return 工具ID列表 + */ + public List getToolIdsByNames(List toolNames) { + if (toolNames == null || toolNames.isEmpty()) { + return List.of(); + } + + List tools = mcpToolMapper.selectList( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .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 getToolsHealthStatus() { + return langChain4jMcpToolProviderService.getAllToolsHealthStatus(); + } + + /** + * 获取所有 BUILTIN 工具对象 + * 这些对象包含 @Tool 注解的方法,可直接用于 AgenticServices + * + * @return BUILTIN 工具对象列表 + */ + public List getAllBuiltinToolObjects() { + return builtinToolRegistry.getAllBuiltinToolObjects(); + } +} diff --git a/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/impl/McpMarketServiceImpl.java b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/impl/McpMarketServiceImpl.java new file mode 100644 index 00000000..b3bed3ab --- /dev/null +++ b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/impl/McpMarketServiceImpl.java @@ -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 selectPageList(McpMarketBo bo, PageQuery pageQuery) { + LambdaQueryWrapper wrapper = buildQueryWrapper(bo); + Page page = baseMapper.selectVoPage(pageQuery.build(), wrapper); + return TableDataInfo.build(page); + } + + @Override + public McpMarketListResult listMarkets(String keyword, String status) { + LambdaQueryWrapper 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 list = baseMapper.selectList(wrapper); + + return McpMarketListResult.of(list); + } + + @Override + public List queryList(McpMarketBo bo) { + LambdaQueryWrapper 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 ids) { + for (Long id : ids) { + // 先删除关联的市场工具 + LambdaQueryWrapper 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 wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(McpMarketTool::getMarketId, marketId); + wrapper.orderByDesc(McpMarketTool::getCreateTime); + + Page 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 existingWrapper = new LambdaQueryWrapper<>(); + existingWrapper.eq(McpMarketTool::getMarketId, marketId); + List existingTools = mcpMarketToolMapper.selectList(existingWrapper); + + // 创建现有工具的名称到ID映射 + Map 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 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 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 buildQueryWrapper(McpMarketBo bo) { + Map params = bo.getParams(); + LambdaQueryWrapper 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; + } +} diff --git a/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/impl/McpToolServiceImpl.java b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/impl/McpToolServiceImpl.java new file mode 100644 index 00000000..26c2d247 --- /dev/null +++ b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/service/impl/McpToolServiceImpl.java @@ -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 selectPageList(McpToolBo bo, PageQuery pageQuery) { + LambdaQueryWrapper wrapper = buildQueryWrapper(bo); + Page page = baseMapper.selectVoPage(pageQuery.build(), wrapper); + return TableDataInfo.build(page); + } + + @Override + public McpToolListResult listTools(String keyword, String type, String status) { + LambdaQueryWrapper 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 list = baseMapper.selectList(wrapper); + + return McpToolListResult.of(list); + } + + @Override + public List queryList(McpToolBo bo) { + LambdaQueryWrapper 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 ids) { + // 过滤掉内置工具 + List 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 buildQueryWrapper(McpToolBo bo) { + Map params = bo.getParams(); + LambdaQueryWrapper 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; + } +} diff --git a/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/tools/EditFileTool.java b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/tools/EditFileTool.java new file mode 100644 index 00000000..26473da2 --- /dev/null +++ b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/tools/EditFileTool.java @@ -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 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; + } +} diff --git a/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/tools/ListDirectoryTool.java b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/tools/ListDirectoryTool.java new file mode 100644 index 00000000..8f6c0cdd --- /dev/null +++ b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/tools/ListDirectoryTool.java @@ -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 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 listFiles(Path dirPath, ListDirectoryParams params) throws IOException { + List 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 fileInfos, ListDirectoryParams params) throws IOException { + try (Stream 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 fileInfos, int currentDepth, int maxDepth, ListDirectoryParams params) throws IOException { + if (currentDepth >= maxDepth) { + return; + } + + try (Stream stream = Files.list(dirPath)) { + List 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 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; + } +} diff --git a/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/tools/ReadFileTool.java b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/tools/ReadFileTool.java new file mode 100644 index 00000000..7b4886e7 --- /dev/null +++ b/ruoyi-modules/ruoyi-mcp/src/main/java/org/ruoyi/mcp/tools/ReadFileTool.java @@ -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; + } +}