diff --git a/README.md b/README.md
index 85fd9fd2..3d39e8f3 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,181 @@
## 🤖AI能力
+
+
{
+ @ExcelProperty("唯一ID")
+ @Schema(description = "唯一ID")
+ @TableId(type = IdType.ASSIGN_UUID)
+ @Accessors(chain = true)
+ private String id;
+ @ExcelProperty("MCP服务器名称")
+ @Schema(description = "MCP服务器名称")
+ private String serverName;
+ @ExcelProperty("传输类型: stdio/sse/streamable")
+ @Schema(description = "传输类型: stdio/sse/streamable")
+ private String transportType;
+ @ExcelProperty("stdio模式启动命令")
+ @Schema(description = "stdio模式启动命令(如node/python)")
+ private String command;
+ @ExcelProperty("stdio模式参数(JSON数组)")
+ @Schema(description = "stdio模式参数(JSON数组,如[\"server.js\",\"--port\",\"3000\"])")
+ private String args;
+ @ExcelProperty("stdio模式环境变量(JSON对象)")
+ @Schema(description = "stdio模式环境变量(JSON对象)")
+ private String env;
+ @ExcelProperty("远程服务基础URL(sse/streamable通用)")
+ @Schema(description = "远程服务基础URL(sse/streamable通用,如https://mcp.example.com)")
+ private String sseUrl;
+ @ExcelProperty("远程服务端点路径(sse/streamable通用)")
+ @Schema(description = "远程服务端点路径(sse/streamable通用,如/sse或/mcp)")
+ private String sseEndpoint;
+ @ExcelProperty("认证Token")
+ @Schema(description = "认证Token(Bearer)")
+ private String authToken;
+ @ExcelProperty("状态 0正常 1停用")
+ @Schema(description = "状态 0正常 1停用")
+ private Short status;
+ @ExcelProperty("备注")
+ @Schema(description = "备注")
+ private String remark;
+}
diff --git a/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/agent/AgentRuntime.java b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/agent/AgentRuntime.java
index e2c4dc3a..3fae09da 100644
--- a/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/agent/AgentRuntime.java
+++ b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/agent/AgentRuntime.java
@@ -9,9 +9,11 @@ import cn.com.mfish.common.ai.entity.AiRequest;
import cn.com.mfish.common.ai.entity.ChatResponseVo;
import cn.com.mfish.common.ai.entity.EventType;
import cn.com.mfish.common.ai.entity.PlanStep;
+import cn.com.mfish.common.ai.memory.ConversationMemory;
+import cn.com.mfish.common.ai.memory.ConversationMemoryStore;
+import cn.com.mfish.common.ai.memory.DocumentChunk;
import cn.com.mfish.common.core.utils.AuthInfoUtils;
import cn.com.mfish.common.core.utils.ServletUtils;
-import cn.com.mfish.common.core.utils.StringUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestAttributes;
@@ -51,30 +53,34 @@ public class AgentRuntime {
private final Executor executor;
private final LlmModelRouter llmModelRouter;
private final FileParseService fileParseService;
+ private final ConversationMemoryStore memoryStore;
public AgentRuntime(Planner planner, Executor executor, LlmModelRouter llmModelRouter,
- FileParseService fileParseService) {
+ FileParseService fileParseService, ConversationMemoryStore memoryStore) {
this.planner = planner;
this.executor = executor;
this.llmModelRouter = llmModelRouter;
this.fileParseService = fileParseService;
+ this.memoryStore = memoryStore;
}
/**
* 运行智能体编排
*
- * 在请求线程捕获租户上下文快照,后台启动 Plan → Execute 流程,同时返回事件流供前端订阅。
- * 编排流程在 boundedElastic 调度器上执行,不阻塞当前线程。
+ * 重构后流程(接入 Memory 模块):
+ *
+ * - 获取(或创建)sessionId 对应的 {@link ConversationMemory}
+ * - 在请求线程捕获 TenantContext 并绑定到 Memory 的 Vars Context
+ * - 若请求携带 fileIds:在请求线程解析文件为 DocumentChunk 列表,
+ * 注入到 Memory 的 Document Context
+ * - 从 Memory 读取 Document Context 拼接文本,注入到用户 prompt 前
+ * - 后台启动 Plan → Execute 流程
+ *
*
*
- * 关键点:必须在请求线程捕获 {@link TenantContext}(含 tenantId/userId/token/RequestAttributes/Exchange),
- * 因为 AuthInfoUtils 和 RequestContextHolder 不支持异步线程访问,编排切到 boundedElastic 后
- * 需要用快照构建 ToolContext 和 ChatClient。
- *
- *
- * 文件解析:若请求携带 fileIds,在请求线程同步加载文件内容(Feign 调用 mf-storage),
- * 拼接到用户提示词前。必须在请求线程执行,因为 Feign 的 BearerTokenInterceptor 依赖
- * RequestContextHolder 中继令牌,异步线程拿不到。
+ * 关键点:必须在请求线程完成 Memory 写入(TenantContext 绑定 + 文件解析),
+ * 因为 AuthInfoUtils 和 Feign BearerTokenInterceptor 都依赖 RequestContextHolder,
+ * 切到 boundedElastic 异步线程后拿不到请求上下文。
*
*
* 所有返回的 {@link ChatResponseVo} 的 id 字段填充为 {@link AiRequest#getId()},
@@ -88,14 +94,25 @@ public class AgentRuntime {
String requestId = aiRequest.getId();
String sessionId = aiRequest.getSessionId();
String prompt = aiRequest.getMessage() != null ? aiRequest.getMessage().getContent() : null;
- // 用请求 id 作为事件 id,与普通聊天返回结构一致
EventBus eventBus = new EventBus(requestId);
- // 在请求线程捕获租户上下文快照,供异步编排使用
- TenantContext tenantContext = captureTenantContext();
+ // 获取(或创建)会话 Memory
+ ConversationMemory memory = memoryStore.getOrCreate(sessionId);
- // 文件解析必须在请求线程执行:Feign BearerTokenInterceptor 依赖 RequestContextHolder
- prompt = resolveFileContents(prompt, aiRequest.getFileIds());
+ // 在请求线程捕获租户上下文并绑定到 Memory 的 Vars Context
+ TenantContext tenantContext = captureTenantContext();
+ memory.bindTenantContext(tenantContext);
+
+ // 文件解析 + 注入 Memory 的 Document Context(必须在请求线程执行)
+ List chunks = fileParseService.loadAsChunks(aiRequest.getFileIds());
+ if (!chunks.isEmpty()) {
+ memory.addDocumentChunks(chunks);
+ }
+
+ // Document Context 的拼接交由 Planner 统一处理:
+ // Planner.plan() 会从 Memory 读取 getSystemContext()(含 Vars + Document)拼接到 prompt 前。
+ // AgentRuntime 只负责 Memory 写入(文件解析 + 租户绑定),不再拼接 DocumentContext 到 prompt,
+ // 避免与 Planner 重复拼接。
// 后台启动编排流程
runOrchestration(sessionId, prompt, eventBus, tenantContext);
@@ -104,31 +121,6 @@ public class AgentRuntime {
return eventBus.asFlux();
}
- /**
- * 加载文件内容并拼接到提示词前
- *
- * 若 fileIds 为空或全部加载失败,返回原始 prompt。
- * 文件内容加载失败时记录日志但不阻断流程,降级为纯文本对话。
- *
- *
- * @param prompt 原始用户提示词
- * @param fileIds 文件fileKey列表
- * @return 拼接后的提示词
- */
- private String resolveFileContents(String prompt, List fileIds) {
- if (fileIds == null || fileIds.isEmpty()) {
- return prompt;
- }
- String fileContents = fileParseService.loadFileContents(fileIds);
- if (StringUtils.isEmpty(fileContents)) {
- log.warn("[AgentRuntime] 文件内容加载为空 fileIds={}", fileIds);
- return prompt;
- }
- return "以下是用户上传的文件内容,请基于文件内容进行分析:\n\n"
- + fileContents
- + "\n用户需求:" + prompt;
- }
-
/**
* 在请求线程捕获租户上下文快照
*
diff --git a/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/agent/BaseAssistant.java b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/agent/BaseAssistant.java
index 198a1001..d9f75be2 100644
--- a/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/agent/BaseAssistant.java
+++ b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/agent/BaseAssistant.java
@@ -7,6 +7,8 @@ import cn.com.mfish.common.ai.engine.ApiToolEngine;
import cn.com.mfish.common.ai.entity.AiRequest;
import cn.com.mfish.common.ai.entity.ChatResponseVo;
import cn.com.mfish.common.ai.agent.ToolCapable;
+import cn.com.mfish.common.ai.memory.ConversationMemory;
+import cn.com.mfish.common.ai.memory.ConversationMemoryStore;
import cn.com.mfish.common.core.constants.RPCConstants;
import cn.com.mfish.common.core.utils.AuthInfoUtils;
import cn.com.mfish.common.core.utils.ServletUtils;
@@ -61,6 +63,19 @@ public abstract class BaseAssistant implements IClientAssistant, ToolCapable {
@Autowired
protected FileParseService fileParseService;
+ /**
+ * 会话记忆存储:三驾马车 Memory 模块的入口
+ *
+ * 用于按 sessionId 获取 {@link ConversationMemory},将文件解析结果、租户上下文、
+ * 业务变量等统一收纳到 Memory,供后续 Planner 拼接上下文。
+ *
+ *
+ * 字段注入避免修改所有子类构造函数。
+ *
+ */
+ @Autowired
+ protected ConversationMemoryStore memoryStore;
+
public BaseAssistant(ChatMemory chatMemory, LlmModelRouter llmModelRouter, ApiToolEngine apiToolEngine) {
this.llmModelRouter = llmModelRouter;
this.chatMemory = chatMemory;
@@ -279,25 +294,47 @@ public abstract class BaseAssistant implements IClientAssistant, ToolCapable {
/**
* 聊天返回id
*
- * 若请求携带fileIds,会先通过FileParseService从文件服务获取文件内容,
- * 将其拼接到用户提示词前,再交由子类chat(sessionId, prompt)处理。
+ * 重构后流程(接入 Memory 模块):
+ *
+ * - 获取(或创建)sessionId 对应的 {@link ConversationMemory}
+ * - 若请求携带 fileIds:通过 FileParseService 解析为 DocumentChunk 列表,
+ * 注入到 Memory 的 Document Context(替代旧的字符串拼接)
+ * - 从 Memory 读取 Document Context 拼接文本,注入到用户 prompt 前
+ * - 交由子类 chat(sessionId, prompt) 处理(保持向后兼容)
+ *
+ *
+ * 关键约束:文件解析必须在请求线程执行(Feign BearerTokenInterceptor 依赖
+ * RequestContextHolder),Memory 写入也在请求线程完成(保证一致性)。
*
* @return 聊天信息
*/
@Override
public Flux chat(AiRequest aiRequest) {
+ String sessionId = aiRequest.getSessionId();
String prompt = aiRequest.getMessage().getContent();
+ ConversationMemory memory = memoryStore.getOrCreate(sessionId);
+
+ // 文件解析 + 注入 Memory 的 Document Context
List fileIds = aiRequest.getFileIds();
if (fileIds != null && !fileIds.isEmpty()) {
- String fileContents = fileParseService.loadFileContents(fileIds);
- if (StringUtils.isNotEmpty(fileContents)) {
- prompt = "以下是用户上传的文件内容,请基于文件内容进行分析:\n\n"
- + fileContents
- + "\n用户问题:" + prompt;
+ List chunks = fileParseService.loadAsChunks(fileIds);
+ if (!chunks.isEmpty()) {
+ memory.addDocumentChunks(chunks);
}
}
+
+ // 从 Memory 读取文档上下文,拼接到 prompt 前
+ // 当前使用 getDocumentContext() 全量注入;当文档较多、token 预算紧张时,
+ // 未来可切换为 memory.searchDocumentContext(prompt, 5) 走 RAG 检索(向量库版 Memory 覆写此方法)。
+ String documentContext = memory.getDocumentContext();
+ if (StringUtils.isNotEmpty(documentContext)) {
+ prompt = "以下是用户上传的文件内容,请基于文件内容进行分析:\n\n"
+ + documentContext
+ + "\n用户问题:" + prompt;
+ }
+
final String finalPrompt = prompt;
- return chat(aiRequest.getSessionId(), finalPrompt)
+ return chat(sessionId, finalPrompt)
.filter(resp -> "STOP".equals(Objects.requireNonNull(resp.getResult()).getMetadata().getFinishReason())
|| StringUtils.isNotEmpty(resp.getResult().getOutput().getText()))
.map(resp -> new ChatResponseVo().setId(aiRequest.getId())
diff --git a/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/agent/Planner.java b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/agent/Planner.java
index 77ca9049..db3f6af1 100644
--- a/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/agent/Planner.java
+++ b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/agent/Planner.java
@@ -2,9 +2,13 @@ package cn.com.mfish.ai.agent;
import cn.com.mfish.ai.service.LlmModelRouter;
import cn.com.mfish.common.ai.agent.TenantContext;
+import cn.com.mfish.common.ai.capability.ActionDefinition;
+import cn.com.mfish.common.ai.capability.CapabilityEngine;
import cn.com.mfish.common.ai.entity.AgentPlan;
import cn.com.mfish.common.ai.entity.PlanStep;
-import cn.com.mfish.common.core.constants.ServiceConstants;
+import cn.com.mfish.common.ai.memory.ConversationMemory;
+import cn.com.mfish.common.ai.memory.ConversationMemoryStore;
+import cn.com.mfish.common.core.utils.StringUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
@@ -14,24 +18,37 @@ import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
-import java.util.Arrays;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
import java.util.Objects;
+import java.util.Set;
import java.util.stream.Collectors;
import static org.springframework.ai.chat.memory.ChatMemory.CONVERSATION_ID;
/**
- * 任务规划器
+ * 任务规划器(已接入 CapabilityEngine + Memory)
*
- * 调用 LLM 将用户原始需求拆解为结构化的 {@link AgentPlan}:
+ * 重构后职责(第二阶段存量平移):
*
- * - 分析用户意图
- * - 拆解为多个可执行的步骤
- * - 每步指定需要调用的微服务集合
+ * - 从 {@link ConversationMemoryStore} 读取会话 Memory,获取系统上下文(Vars + Document)
+ * - 从 {@link CapabilityEngine} 获取所有可用 {@link ActionDefinition},按服务分组展示给 LLM
+ * - 将上下文 + 动作列表 + 用户需求打包发给 LLM,生成结构化 {@link AgentPlan}
*
*
*
+ * 与旧版差异:
+ *
+ * - 服务列表来源:旧版从 {@code ServiceConstants.MfService.values()} 硬编码枚举获取;
+ * 新版从 {@link CapabilityEngine#getAvailableActions()} 动态获取,自动反映已注册的子引擎
+ * - 上下文注入:旧版仅传入原始 prompt;新版从 Memory 读取 {@code getSystemContext()}
+ * 拼接到 prompt 前,包含租户信息、业务变量和文档内容
+ * - 动作摘要:新版在规划提示词中展示每个服务的动作数量和示例动作名,
+ * 帮助 LLM 更精准地选择 serviceIds
+ *
+ *
+ *
* 采用结构化输出(responseEntity),LLM 直接返回 JSON 反序列化为 AgentPlan。
* 必须在请求线程解析租户并构建 ChatClient(AuthInfoUtils 不支持异步)。
*
@@ -45,22 +62,36 @@ public class Planner {
private final ChatMemory chatMemory;
private final LlmModelRouter llmModelRouter;
+ private final CapabilityEngine capabilityEngine;
+ private final ConversationMemoryStore memoryStore;
- public Planner(ChatMemory chatMemory, LlmModelRouter llmModelRouter) {
+ public Planner(ChatMemory chatMemory, LlmModelRouter llmModelRouter,
+ CapabilityEngine capabilityEngine, ConversationMemoryStore memoryStore) {
this.chatMemory = chatMemory;
this.llmModelRouter = llmModelRouter;
+ this.capabilityEngine = capabilityEngine;
+ this.memoryStore = memoryStore;
}
/**
* 规划:将用户需求拆解为步骤列表
*
- * 注意:本方法会被异步调度器(boundedElastic)调用,因此 ChatClient 必须在请求线程
- * 预构建并通过闭包传入。{@link TenantContext} 中包含请求线程捕获的 tenantId,
+ * 重构后流程:
+ *
+ * - 从 Memory 读取系统上下文(Vars + Document),拼接到 prompt 前
+ * - 从 CapabilityEngine 获取所有可用 ActionDefinition,构建规划提示词
+ * - 调用 LLM 生成结构化 AgentPlan
+ * - 失败时降级为单步执行,聚合所有已注册服务
+ *
+ *
+ *
+ * 注意:本方法会被异步调度器(boundedElastic)调用,因此 ChatClient 和系统提示词
+ * 必须在请求线程预构建并通过闭包传入。{@link TenantContext} 中包含请求线程捕获的 tenantId,
* 用于路由到该租户的 ChatModel。
*
*
* @param sessionId 会话ID
- * @param prompt 用户原始需求
+ * @param prompt 用户原始需求(已由 AgentRuntime 拼接 Document Context)
* @param tenantContext 请求线程捕获的租户上下文
* @return 执行计划
*/
@@ -70,16 +101,26 @@ public class Planner {
? tenantContext.getTenantId()
: llmModelRouter.currentTenantId();
ChatClient chatClient = getChatClient(tenantId);
+
+ // 从 Memory 读取系统上下文(Vars + Document),拼接到 prompt 前
+ String systemContext = resolveSystemContext(sessionId);
+
+ // 从 CapabilityEngine 获取动作列表,构建规划提示词
String systemPrompt = buildPlannerPrompt();
+ // 拼接最终 prompt:系统上下文 + 用户需求
+ String finalPrompt = StringUtils.isNotEmpty(systemContext)
+ ? systemContext + "\n用户需求:" + prompt
+ : prompt;
+
return Mono.fromCallable(() -> {
var responseEntity = chatClient.prompt()
.system(systemPrompt)
- .user(prompt)
+ .user(finalPrompt)
.advisors(a -> a.param(CONVERSATION_ID, sessionId))
.call()
.responseEntity(AgentPlan.class);
- AgentPlan plan = Objects.requireNonNullElseGet(responseEntity.entity(), () -> fallbackPlan(prompt));
+ AgentPlan plan = Objects.requireNonNullElseGet(responseEntity.entity(), () -> fallbackPlan(finalPrompt));
plan.setOriginalPrompt(prompt);
log.info("[Planner] 规划完成, 步骤数={}, summary={}",
plan.getSteps() != null ? plan.getSteps().size() : 0, plan.getSummary());
@@ -88,17 +129,63 @@ public class Planner {
.subscribeOn(Schedulers.boundedElastic())
.onErrorResume(ex -> {
log.error("[Planner] 规划失败,降级为单步执行", ex);
- return Mono.just(fallbackPlan(prompt));
+ return Mono.just(fallbackPlan(finalPrompt));
});
}
/**
- * 构建规划师系统提示词,包含可用服务列表
+ * 从 Memory 读取系统上下文(Vars Context + Document Context)
+ *
+ * AgentRuntime 已在请求线程将 DocumentContext 拼接到 prompt 中,
+ * 此处进一步补充 Vars Context(租户信息、业务变量),
+ * 使 Planner 能感知用户身份和业务上下文。
+ *
+ */
+ private String resolveSystemContext(String sessionId) {
+ try {
+ ConversationMemory memory = memoryStore.getOrCreate(sessionId);
+ return memory.getSystemContext();
+ } catch (Exception e) {
+ log.warn("[Planner] 读取 Memory 系统上下文失败 sessionId={}", sessionId, e);
+ return "";
+ }
+ }
+
+ /**
+ * 构建规划师系统提示词,包含可用服务列表和动作摘要
+ *
+ * 重构后从 {@link CapabilityEngine#getAvailableActions()} 动态获取动作列表,
+ * 按服务分组展示。相比旧版从 {@code ServiceConstants.MfService.values()} 硬编码枚举获取,
+ * 新版自动反映已注册的子引擎状态(如某服务未启动则不出现在列表中)。
+ *
*/
private String buildPlannerPrompt() {
- String serviceList = Arrays.stream(ServiceConstants.MfService.values())
- .map(s -> String.format(" - %s: %s", s.getValue(), s.getGatewayPrefix()))
- .collect(Collectors.joining("\n"));
+ // 从 CapabilityEngine 获取所有动作,按 serviceId 分组
+ List actions = capabilityEngine.getAvailableActions();
+ Map> actionsByService = groupActionsByService(actions);
+
+ // 构建服务列表(仅包含有动作的服务)
+ StringBuilder serviceList = new StringBuilder();
+ for (Map.Entry> entry : actionsByService.entrySet()) {
+ String serviceId = entry.getKey();
+ List serviceActions = entry.getValue();
+ serviceList.append(String.format(" - %s: %d 个可用动作", serviceId, serviceActions.size()));
+
+ // 展示前 3 个动作名作为示例,帮助 LLM 理解服务能力
+ List sampleNames = serviceActions.stream()
+ .limit(3)
+ .map(ActionDefinition::getName)
+ .toList();
+ if (!sampleNames.isEmpty()) {
+ serviceList.append("(示例: ").append(String.join(", ", sampleNames)).append(")");
+ }
+ serviceList.append("\n");
+ }
+
+ // 如果没有动作,使用兜底提示
+ if (serviceList.isEmpty()) {
+ serviceList.append(" (当前无可用服务,请直接回答用户问题)\n");
+ }
return """
你是"摸鱼低代码"平台的任务规划师。
@@ -154,17 +241,39 @@ public class Planner {
}
/**
- * 规划失败时的兜底:单步执行原始需求,聚合所有服务工具
+ * 将动作列表按 serviceId 分组(保持注册顺序)
+ */
+ private Map> groupActionsByService(List actions) {
+ Map> grouped = new LinkedHashMap<>();
+ if (actions == null || actions.isEmpty()) {
+ return grouped;
+ }
+ for (ActionDefinition action : actions) {
+ String serviceId = action.getServiceId() != null ? action.getServiceId() : "unknown";
+ grouped.computeIfAbsent(serviceId, k -> new java.util.ArrayList<>()).add(action);
+ }
+ return grouped;
+ }
+
+ /**
+ * 规划失败时的兜底:单步执行原始需求,聚合所有已注册服务
+ *
+ * 重构后从 {@link CapabilityEngine#getAvailableActions()} 提取所有 serviceId,
+ * 相比旧版从 {@code ServiceConstants.MfService.values()} 硬编码枚举获取,
+ * 新版仅聚合实际有动作的服务,避免向未启动的服务发送请求。
+ *
*/
private AgentPlan fallbackPlan(String prompt) {
- List allServices = Arrays.stream(ServiceConstants.MfService.values())
- .map(ServiceConstants.MfService::getValue)
- .collect(Collectors.toList());
+ Set allServiceIds = capabilityEngine.getAvailableActions().stream()
+ .map(ActionDefinition::getServiceId)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toSet());
+ List serviceIdList = List.copyOf(allServiceIds);
return new AgentPlan()
.setOriginalPrompt(prompt)
.setSummary("规划降级:直接执行")
.setSteps(List.of(
- new PlanStep(prompt, allServices)
+ new PlanStep(prompt, serviceIdList)
));
}
diff --git a/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/controller/McpServerConfigController.java b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/controller/McpServerConfigController.java
new file mode 100644
index 00000000..9a152bde
--- /dev/null
+++ b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/controller/McpServerConfigController.java
@@ -0,0 +1,69 @@
+package cn.com.mfish.ai.controller;
+
+import cn.com.mfish.ai.api.entity.McpServerConfig;
+import cn.com.mfish.ai.service.McpServerConfigService;
+import cn.com.mfish.common.core.web.PageResult;
+import cn.com.mfish.common.core.web.ReqPage;
+import cn.com.mfish.common.core.web.Result;
+import cn.com.mfish.common.core.enums.OperateType;
+import cn.com.mfish.common.log.annotation.Log;
+import cn.com.mfish.common.oauth.annotation.RequiresPermissions;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * @description: MCP服务器配置信息
+ * @author: mfish
+ * @date: 2026-07-21
+ * @version: V2.4.1
+ */
+@Tag(name = "MCP服务器配置")
+@RestController
+@RequestMapping("/mcp/config")
+@Slf4j
+public class McpServerConfigController {
+
+ @Resource
+ McpServerConfigService mcpServerConfigService;
+
+ @Operation(summary = "MCP服务器配置-分页列表查询", description = "MCP服务器配置-分页列表查询")
+ @GetMapping
+ @RequiresPermissions("ai:mcp:query")
+ public Result> queryPageList(McpServerConfig req, ReqPage reqPage) {
+ return mcpServerConfigService.queryPageList(req, reqPage);
+ }
+
+ @Log(title = "MCP服务器配置-新增", operateType = OperateType.INSERT)
+ @Operation(summary = "MCP服务器配置-新增", description = "MCP服务器配置-新增")
+ @PostMapping
+ @RequiresPermissions("ai:mcp:insert")
+ public Result add(@RequestBody McpServerConfig entity) {
+ return mcpServerConfigService.insert(entity);
+ }
+
+ @Log(title = "MCP服务器配置-修改", operateType = OperateType.UPDATE)
+ @Operation(summary = "MCP服务器配置-修改", description = "MCP服务器配置-修改")
+ @PutMapping
+ @RequiresPermissions("ai:mcp:update")
+ public Result edit(@RequestBody McpServerConfig entity) {
+ return mcpServerConfigService.update(entity);
+ }
+
+ @Log(title = "MCP服务器配置-通过id删除", operateType = OperateType.DELETE)
+ @Operation(summary = "MCP服务器配置-通过id删除", description = "MCP服务器配置-通过id删除")
+ @DeleteMapping("/{id}")
+ @RequiresPermissions("ai:mcp:delete")
+ public Result delete(@PathVariable String id) {
+ return mcpServerConfigService.delete(id);
+ }
+
+ @Operation(summary = "MCP服务器配置-通过id查询", description = "MCP服务器配置-通过id查询")
+ @GetMapping("/{id}")
+ @RequiresPermissions("ai:mcp:query")
+ public Result getById(@PathVariable String id) {
+ return Result.ok(mcpServerConfigService.getById(id), "查询成功");
+ }
+}
diff --git a/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/mapper/McpServerConfigMapper.java b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/mapper/McpServerConfigMapper.java
new file mode 100644
index 00000000..90bc9dd8
--- /dev/null
+++ b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/mapper/McpServerConfigMapper.java
@@ -0,0 +1,14 @@
+package cn.com.mfish.ai.mapper;
+
+import cn.com.mfish.ai.api.entity.McpServerConfig;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+
+/**
+ * @description: MCP服务器配置信息
+ * @author: mfish
+ * @date: 2026-07-21
+ * @version: V2.4.1
+ */
+public interface McpServerConfigMapper extends BaseMapper {
+
+}
diff --git a/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/service/FileParseService.java b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/service/FileParseService.java
index bb0d7fec..0e73344c 100644
--- a/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/service/FileParseService.java
+++ b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/service/FileParseService.java
@@ -1,5 +1,6 @@
package cn.com.mfish.ai.service;
+import cn.com.mfish.common.ai.memory.DocumentChunk;
import cn.com.mfish.common.core.constants.RPCConstants;
import cn.com.mfish.common.core.utils.StringUtils;
import cn.com.mfish.common.storage.api.entity.StorageInfo;
@@ -14,9 +15,11 @@ import org.springframework.stereotype.Service;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
+import java.util.UUID;
/**
* 文件解析服务
@@ -111,6 +114,9 @@ public class FileParseService {
*
* 遍历fileKey列表,逐个获取文件元数据与内容,将文本/Office文档内容拼接到StringBuilder。
* 单个文件读取失败不影响其他文件,异常会被记录日志并跳过。
+ *
+ * 旧版接口,保留向后兼容。新调用方建议使用 {@link #loadAsChunks} 获取结构化的
+ * {@link DocumentChunk} 列表,再注入到 Memory 模块。
*
* @param fileIds 文件fileKey列表
* @return 拼接好的文件内容提示词片段;列表为空或全部失败时返回空字符串
@@ -141,6 +147,45 @@ public class FileParseService {
return sb.toString();
}
+ /**
+ * 加载文件并解析为 {@link DocumentChunk} 列表
+ *
+ * 与 {@link #loadFileContents} 的区别:返回结构化的文档块列表,便于 Memory 模块
+ * 按 chunk 维度管理文档上下文,支持后续按相关性检索(RAG)、按 token 上限分片注入等扩展。
+ *
+ *
+ * 当前实现:每个文件产出 1 个 Chunk(小文件场景),未来可扩展为按段落/页码/字符数分片。
+ * Chunk 内容已截断到 {@link #MAX_CONTENT_CHARS},可直接注入 LLM 上下文。
+ *
+ *
+ * 必须在请求线程调用:内部通过 Feign 调用 mf-storage,BearerTokenInterceptor 依赖
+ * RequestContextHolder 中继令牌。
+ *
+ *
+ * @param fileIds 文件fileKey列表
+ * @return 文档块列表;列表为空或全部失败时返回空列表(不会返回 null)
+ */
+ public List loadAsChunks(List fileIds) {
+ if (fileIds == null || fileIds.isEmpty()) {
+ return List.of();
+ }
+ List chunks = new ArrayList<>(fileIds.size());
+ for (String fileKey : fileIds) {
+ if (StringUtils.isEmpty(fileKey)) {
+ continue;
+ }
+ try {
+ DocumentChunk chunk = loadOneFileAsChunk(fileKey);
+ if (chunk != null && StringUtils.isNotEmpty(chunk.getContent())) {
+ chunks.add(chunk);
+ }
+ } catch (Exception e) {
+ log.warn("加载文件块失败 fileKey={} reason={}", fileKey, e.getMessage());
+ }
+ }
+ return chunks;
+ }
+
/**
* 加载单个文件并构建内容片段
*
@@ -176,6 +221,47 @@ public class FileParseService {
return buildBinaryFileHint(fileName, fileType, storageInfo.getFileSize());
}
+ /**
+ * 加载单个文件并构建为 {@link DocumentChunk}
+ *
+ * 与 {@link #loadOneFile} 共享文件元数据查询和解析路径,但返回结构化 Chunk 而非拼接文本。
+ * 二进制文件(无法解析内容的)返回 null 而非提示片段,避免无效 Chunk 进入 Memory。
+ *
+ *
+ * @param fileKey 文件key
+ * @return 文档块;文件不存在或无法解析返回 null
+ */
+ private DocumentChunk loadOneFileAsChunk(String fileKey) {
+ Result infoResult = remoteStorageService.queryByKey(RPCConstants.INNER, fileKey);
+ if (infoResult == null || !infoResult.isSuccess() || infoResult.getData() == null) {
+ log.warn("文件信息查询失败 fileKey={} msg={}", fileKey,
+ infoResult == null ? "result is null" : infoResult.getMsg());
+ return null;
+ }
+ StorageInfo storageInfo = infoResult.getData();
+ String fileName = StringUtils.isEmpty(storageInfo.getFileName()) ? fileKey : storageInfo.getFileName();
+ String fileType = storageInfo.getFileType();
+
+ String content = null;
+ if (isTextFile(fileType, fileName)) {
+ content = readFileText(fileKey);
+ } else if (isOfficeDocument(fileType, fileName)) {
+ content = extractOfficeText(fileKey, fileName);
+ }
+ if (content == null || content.isEmpty()) {
+ return null;
+ }
+ String truncated = truncate(content);
+ return new DocumentChunk()
+ .setChunkId(UUID.randomUUID().toString())
+ .setFileKey(fileKey)
+ .setFileName(fileName)
+ .setFileType(fileType)
+ .setContent(truncated)
+ .setChunkIndex(0)
+ .setLength(truncated.length());
+ }
+
/**
* 通过Feign获取文件资源并读取为UTF-8文本
*
diff --git a/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/service/McpServerConfigService.java b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/service/McpServerConfigService.java
new file mode 100644
index 00000000..015dfea8
--- /dev/null
+++ b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/service/McpServerConfigService.java
@@ -0,0 +1,40 @@
+package cn.com.mfish.ai.service;
+
+import cn.com.mfish.ai.api.entity.McpServerConfig;
+import cn.com.mfish.common.ai.capability.McpServerInfo;
+import cn.com.mfish.common.ai.capability.McpServerConfigProvider;
+import cn.com.mfish.common.core.web.PageResult;
+import cn.com.mfish.common.core.web.ReqPage;
+import cn.com.mfish.common.core.web.Result;
+import com.baomidou.mybatisplus.extension.service.IService;
+
+import java.util.List;
+
+/**
+ * @description: MCP服务器配置信息
+ * @author: mfish
+ * @date: 2026-07-21
+ * @version: V2.4.1
+ */
+public interface McpServerConfigService extends IService, McpServerConfigProvider {
+
+ /**
+ * 分页查询
+ */
+ Result> queryPageList(McpServerConfig req, ReqPage reqPage);
+
+ /**
+ * 新增
+ */
+ Result insert(McpServerConfig entity);
+
+ /**
+ * 修改
+ */
+ Result update(McpServerConfig entity);
+
+ /**
+ * 删除
+ */
+ Result delete(String id);
+}
diff --git a/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/service/impl/McpServerConfigServiceImpl.java b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/service/impl/McpServerConfigServiceImpl.java
new file mode 100644
index 00000000..9cf66d79
--- /dev/null
+++ b/mf-business/mf-ai/src/main/java/cn/com/mfish/ai/service/impl/McpServerConfigServiceImpl.java
@@ -0,0 +1,97 @@
+package cn.com.mfish.ai.service.impl;
+
+import cn.com.mfish.ai.api.entity.McpServerConfig;
+import cn.com.mfish.ai.mapper.McpServerConfigMapper;
+import cn.com.mfish.ai.service.McpServerConfigService;
+import cn.com.mfish.common.ai.capability.McpServerInfo;
+import cn.com.mfish.common.core.utils.StringUtils;
+import cn.com.mfish.common.core.utils.Utils;
+import cn.com.mfish.common.core.web.PageResult;
+import cn.com.mfish.common.core.web.ReqPage;
+import cn.com.mfish.common.core.web.Result;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.github.pagehelper.PageHelper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * @description: MCP服务器配置信息实现
+ * @author: mfish
+ * @date: 2026-07-21
+ * @version: V2.4.1
+ */
+@Slf4j
+@Service
+public class McpServerConfigServiceImpl extends ServiceImpl
+ implements McpServerConfigService {
+
+ @Override
+ public Result> queryPageList(McpServerConfig req, ReqPage reqPage) {
+ PageHelper.startPage(reqPage.getPageNum(), reqPage.getPageSize());
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
+ if (req != null) {
+ wrapper.like(StringUtils.isNotEmpty(req.getServerName()), McpServerConfig::getServerName, req.getServerName());
+ wrapper.eq(StringUtils.isNotEmpty(req.getTransportType()), McpServerConfig::getTransportType, req.getTransportType());
+ wrapper.eq(req.getStatus() != null, McpServerConfig::getStatus, req.getStatus());
+ }
+ wrapper.orderByDesc(McpServerConfig::getCreateTime);
+ List list = baseMapper.selectList(wrapper);
+ return Result.ok(new PageResult<>(list), "查询成功");
+ }
+
+ @Override
+ @Transactional
+ public Result insert(McpServerConfig entity) {
+ if (StringUtils.isEmpty(entity.getId())) {
+ entity.setId(Utils.uuid32());
+ }
+ entity.setCreateTime(new Date());
+ baseMapper.insert(entity);
+ return Result.ok(entity, "新增成功");
+ }
+
+ @Override
+ @Transactional
+ public Result update(McpServerConfig entity) {
+ entity.setUpdateTime(new Date());
+ baseMapper.updateById(entity);
+ return Result.ok(entity, "修改成功");
+ }
+
+ @Override
+ @Transactional
+ public Result delete(String id) {
+ baseMapper.deleteById(id);
+ return Result.ok(true, "删除成功");
+ }
+
+ /**
+ * 实现 {@link McpServerConfigProvider}:查询状态为"正常"的 MCP 服务器配置,
+ * 转换为 {@link McpServerInfo} 列表供 {@code McpCapabilityEngine} 使用
+ */
+ @Override
+ public List getActiveServerConfigs() {
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
+ wrapper.eq(McpServerConfig::getStatus, (short) 0);
+ List configs = baseMapper.selectList(wrapper);
+ List result = new ArrayList<>();
+ for (McpServerConfig config : configs) {
+ result.add(new McpServerInfo()
+ .setServerName(config.getServerName())
+ .setTransportType(config.getTransportType())
+ .setCommand(config.getCommand())
+ .setArgs(config.getArgs())
+ .setEnv(config.getEnv())
+ .setSseUrl(config.getSseUrl())
+ .setSseEndpoint(config.getSseEndpoint())
+ .setAuthToken(config.getAuthToken()));
+ }
+ return result;
+ }
+}
diff --git a/mf-common/mf-common-ai/pom.xml b/mf-common/mf-common-ai/pom.xml
index 12269fa9..eff964bf 100644
--- a/mf-common/mf-common-ai/pom.xml
+++ b/mf-common/mf-common-ai/pom.xml
@@ -67,5 +67,16 @@
com.baomidou
mybatis-plus-spring
+
+
+ org.springframework.ai
+ spring-ai-starter-mcp-client-webflux
+
\ No newline at end of file
diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/ActionDefinition.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/ActionDefinition.java
new file mode 100644
index 00000000..b6479f2e
--- /dev/null
+++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/ActionDefinition.java
@@ -0,0 +1,66 @@
+package cn.com.mfish.common.ai.capability;
+
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+/**
+ * 动作定义(Action Definition)
+ *
+ * 能力引擎向 Planner 暴露的单个可执行动作的元数据,遵循 OpenAI Tool 格式。
+ * 一个 {@link CapabilitySubEngine} 可暴露多个 Action,{@link CapabilityEngine}
+ * 将所有子引擎的 Action 合并为统一列表供 Planner 选择。
+ *
+ *
+ * 与 Spring AI ToolCallback 的关系:
+ *
+ * - 对于 TOOL 引擎:ActionDefinition 由 {@code ToolCallback.getToolDefinition()} 转换而来
+ * - 对于 SKILL/MCP/WORKFLOW 引擎:ActionDefinition 由各引擎自行构建
+ * - Planner 可将 ActionDefinition 转换回 ToolCallback 供 ChatClient 使用,
+ * 也可通过 {@code CapabilityEngine.executeAction()} 直接执行
+ *
+ *
+ *
+ * @author: mfish
+ * @date: 2026/07/21
+ */
+@Data
+@Accessors(chain = true)
+public class ActionDefinition {
+
+ /**
+ * 动作名称(全局唯一,建议加引擎前缀避免冲突,如 {@code tool.getUserList}、{@code skill.parseFile})
+ */
+ private String name;
+
+ /**
+ * 动作描述(供 LLM 理解动作用途,决定是否选择此动作)
+ */
+ private String description;
+
+ /**
+ * 输入参数 JSON Schema(OpenAI Tool 格式,描述参数类型和结构)
+ *
+ * 示例:
+ *
{@code
+ * {
+ * "type": "object",
+ * "properties": {
+ * "userId": { "type": "string", "description": "用户ID" }
+ * },
+ * "required": ["userId"]
+ * }
+ * }
+ *
+ */
+ private String inputSchema;
+
+ /**
+ * 来源引擎类型
+ */
+ private EngineType engineType;
+
+ /**
+ * 来源服务ID(仅 TOOL 引擎有值,标识来自哪个微服务;其他引擎为 null)
+ */
+ private String serviceId;
+}
diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/ActionResult.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/ActionResult.java
new file mode 100644
index 00000000..999d851f
--- /dev/null
+++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/ActionResult.java
@@ -0,0 +1,69 @@
+package cn.com.mfish.common.ai.capability;
+
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+/**
+ * 动作执行结果
+ *
+ * 由 {@link CapabilitySubEngine#execute} 返回,封装执行状态、输出内容和错误信息。
+ * 设计为不可变值对象:执行完成后构造,调用方只读。
+ *
+ *
+ * @author: mfish
+ * @date: 2026/07/21
+ */
+@Data
+@Accessors(chain = true)
+public class ActionResult {
+
+ /**
+ * 是否执行成功
+ */
+ private boolean success;
+
+ /**
+ * 执行输出(LLM 可读的文本,通常为 JSON 字符串或纯文本)
+ *
+ * 成功时填充结果内容,失败时可为 null。
+ *
+ */
+ private String output;
+
+ /**
+ * 错误信息(失败时填充,成功时为 null)
+ */
+ private String error;
+
+ /**
+ * 来源引擎类型(便于 Planner 日志追踪和结果分流处理)
+ */
+ private EngineType engineType;
+
+ /**
+ * 执行耗时(毫秒)
+ */
+ private long durationMs;
+
+ /**
+ * 构建成功结果
+ */
+ public static ActionResult success(EngineType engineType, String output, long durationMs) {
+ return new ActionResult()
+ .setSuccess(true)
+ .setOutput(output)
+ .setEngineType(engineType)
+ .setDurationMs(durationMs);
+ }
+
+ /**
+ * 构建失败结果
+ */
+ public static ActionResult failure(EngineType engineType, String error, long durationMs) {
+ return new ActionResult()
+ .setSuccess(false)
+ .setError(error)
+ .setEngineType(engineType)
+ .setDurationMs(durationMs);
+ }
+}
diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/CapabilityAutoConfiguration.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/CapabilityAutoConfiguration.java
new file mode 100644
index 00000000..11a5dae0
--- /dev/null
+++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/CapabilityAutoConfiguration.java
@@ -0,0 +1,123 @@
+package cn.com.mfish.common.ai.capability;
+
+import cn.com.mfish.common.ai.engine.ApiToolEngine;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.SmartInitializingSingleton;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.context.annotation.Bean;
+
+import java.util.List;
+
+/**
+ * 能力引擎自动配置
+ *
+ * 注册 {@link CapabilityEngine} 作为能力引擎门面,自动发现所有 {@link CapabilitySubEngine} 实现,
+ * 在所有单例 Bean 初始化完成后将子引擎注册到 CapabilityEngine。
+ *
+ *
+ * 默认注册两个子引擎:
+ *
+ * - {@link ToolCapabilityEngine} — 适配 ApiToolEngine(Feign/OpenAPI 工具)
+ * - {@link McpCapabilityEngine} — MCP 协议工具(条件注册,需 MCP SDK + McpServerConfigProvider)
+ *
+ * 其他子引擎(SkillCapabilityEngine / WorkflowCapabilityEngine)
+ * 通过实现 CapabilitySubEngine 接口并声明为 Bean 即可自动接入。
+ *
+ *
+ * 与 ApiToolAutoConfiguration 的关系:
+ *
+ * - ApiToolAutoConfiguration 负责 ApiToolEngine 的初始化(工具发现与聚合)
+ * - CapabilityAutoConfiguration 负责 CapabilityEngine 的初始化(子引擎注册与动作索引构建)
+ * - 两者通过 ApiToolEngine Bean 关联:ToolCapabilityEngine 依赖 ApiToolEngine
+ * - 初始化顺序由 Spring 容器保证:ApiToolEngine 的 SmartInitializingSingleton 先执行,
+ * CapabilityEngine 的 SmartInitializingSingleton 后执行
+ *
+ *
+ *
+ * 通过 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 注册。
+ *
+ *
+ * @author: mfish
+ * @date: 2026/07/21
+ */
+@Slf4j
+@AutoConfiguration
+public class CapabilityAutoConfiguration {
+
+ /**
+ * 能力引擎门面
+ */
+ @Bean
+ @ConditionalOnMissingBean
+ public CapabilityEngine capabilityEngine() {
+ return new CapabilityEngine();
+ }
+
+ /**
+ * 工具能力引擎适配器(包装 ApiToolEngine 为 CapabilitySubEngine)
+ *
+ * 依赖 ApiToolEngine Bean,由 ApiToolAutoConfiguration 注册。
+ *
+ */
+ @Bean
+ public ToolCapabilityEngine toolCapabilityEngine(ApiToolEngine apiToolEngine) {
+ return new ToolCapabilityEngine(apiToolEngine);
+ }
+
+ /**
+ * MCP 能力引擎(条件注册)
+ *
+ * 仅当 classpath 中存在 MCP SDK 类 且 容器中有 {@link McpServerConfigProvider} 实现时才注册。
+ *
+ *
+ * 条件说明:
+ *
+ * - {@code @ConditionalOnClass} — 确保 MCP SDK 在 classpath 中(mf-common-ai 已声明依赖)
+ * - {@code @ConditionalOnBean(McpServerConfigProvider.class)} — 确保业务层提供了 MCP 配置数据源
+ * (由 mf-ai 模块的 McpServerConfigServiceImpl 实现)
+ *
+ * 若条件不满足(如 mf-common-ai 独立测试或 mf-ai 模块未启动),MCP 引擎不注册,不影响其他子引擎。
+ *
+ */
+ @Bean
+ @ConditionalOnClass(name = "io.modelcontextprotocol.client.McpSyncClient")
+ @ConditionalOnBean(McpServerConfigProvider.class)
+ public McpCapabilityEngine mcpCapabilityEngine(McpServerConfigProvider configProvider) {
+ return new McpCapabilityEngine(configProvider);
+ }
+
+ /**
+ * 能力引擎初始化触发器:在所有单例 Bean(含所有 CapabilitySubEngine)就绪后,
+ * 将子引擎注册到 CapabilityEngine 并构建动作索引
+ *
+ * 放在此处而非 CapabilityEngine 内,避免 CapabilityEngine 依赖 Spring 容器回调接口,保持可测试性。
+ * 与 ApiToolAutoConfiguration 的 apiToolEngineInitializer 模式一致。
+ *
+ *
+ * 对于 McpCapabilityEngine,在注册前先调用 {@code refresh()} 连接所有 MCP 服务器并发现工具,
+ * 确保 {@code getActions()} 在注册到 CapabilityEngine 时返回完整的动作列表。
+ *
+ */
+ @Bean
+ public SmartInitializingSingleton capabilityEngineInitializer(CapabilityEngine capabilityEngine,
+ List subEngines) {
+ return () -> {
+ log.info("[CapabilityAutoConfiguration] 发现 {} 个 CapabilitySubEngine: {}",
+ subEngines.size(),
+ subEngines.stream().map(e -> e.getEngineType().name()).toList());
+
+ // MCP 引擎需在注册前初始化(连接 MCP 服务器、发现工具)
+ for (CapabilitySubEngine engine : subEngines) {
+ if (engine instanceof McpCapabilityEngine mcpEngine) {
+ log.info("[CapabilityAutoConfiguration] 触发 MCP 引擎初始化");
+ mcpEngine.refresh();
+ }
+ }
+
+ capabilityEngine.registerSubEngines(subEngines);
+ };
+ }
+}
diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/CapabilityEngine.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/CapabilityEngine.java
new file mode 100644
index 00000000..5225c42e
--- /dev/null
+++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/CapabilityEngine.java
@@ -0,0 +1,249 @@
+package cn.com.mfish.common.ai.capability;
+
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 能力引擎门面(Capability Engine)
+ *
+ * 组合(Composite)所有 {@link CapabilitySubEngine} 实例,向上为 Planner 提供统一的能力发现与执行入口。
+ * 屏蔽异构执行细节:Planner 无需关心动作来自 TOOL / SKILL / MCP / WORKFLOW 哪个引擎。
+ *
+ *
+ * 架构定位:
+ *
+ * CapabilityEngine(门面)
+ * │
+ * ┌───────────────┼───────────────┬───────────────┬───────────────┐
+ * │ │ │ │
+ * ToolCapability SkillCapability McpCapability WorkflowCapability
+ * (Engine) (Engine) (Engine) (Engine)
+ * │ │ │ │
+ * ApiToolEngine Java Skill MCP Client Flowable/BPMN
+ * (Feign+HTTP)
+ *
+ *
+ *
+ * 与现有 ApiToolEngine 的关系:
+ *
+ * - ApiToolEngine 保持不变,仍由 {@code BaseAssistant.chatWithTools()} 直接调用,
+ * 支撑 LLM 驱动模式(把工具交给 ChatClient)
+ * - CapabilityEngine 是上层抽象,ToolCapabilityEngine 适配 ApiToolEngine 暴露其动作元数据
+ * - 两条链路并存:LLM 驱动走 ChatClient.tools(),显式调用走 CapabilityEngine.executeAction()
+ *
+ *
+ *
+ * 线程安全:子引擎注册在初始化期完成,运行期只读;动作查找通过 ConcurrentHashMap 支持并发。
+ *
+ *
+ * @author: mfish
+ * @date: 2026/07/21
+ */
+@Slf4j
+public class CapabilityEngine {
+
+ /**
+ * 已注册的子引擎列表(按注册顺序)
+ */
+ private final List subEngines = Collections.synchronizedList(new ArrayList<>());
+
+ /**
+ * 动作名 → 子引擎 的索引(供 executeAction 快速路由)
+ *
+ * 初始化时构建,子引擎动态新增动作时需调用 {@link #refreshActionIndex()} 重建。
+ * 同名动作按子引擎注册顺序,先注册的覆盖(与 ApiToolEngine 去重策略一致)。
+ *
+ */
+ private volatile Map actionIndex = new ConcurrentHashMap<>();
+
+ /**
+ * 注册子引擎
+ *
+ * 在 {@link CapabilityAutoConfiguration} 初始化期调用,运行期不应动态增减。
+ * 注册后自动重建动作索引。
+ *
+ *
+ * @param subEngine 子引擎实例
+ */
+ public synchronized void registerSubEngine(CapabilitySubEngine subEngine) {
+ if (subEngine == null) {
+ return;
+ }
+ subEngines.add(subEngine);
+ log.info("[CapabilityEngine] 注册子引擎 type={} actions={}",
+ subEngine.getEngineType(),
+ subEngine.getActions() != null ? subEngine.getActions().size() : 0);
+ rebuildActionIndex();
+ }
+
+ /**
+ * 批量注册子引擎
+ *
+ * @param engines 子引擎列表
+ */
+ public synchronized void registerSubEngines(List engines) {
+ if (engines == null || engines.isEmpty()) {
+ return;
+ }
+ for (CapabilitySubEngine engine : engines) {
+ if (engine != null) {
+ subEngines.add(engine);
+ }
+ }
+ log.info("[CapabilityEngine] 批量注册 {} 个子引擎", subEngines.size());
+ rebuildActionIndex();
+ }
+
+ /**
+ * 获取所有子引擎的可用动作(并集)
+ *
+ * Planner 调用此方法获取全局动作列表,可选择:
+ *
+ * - 将动作列表注入系统提示词,供 LLM 决策
+ * - 将动作列表转换为 ToolCallback 传给 ChatClient(LLM 驱动模式)
+ * - 根据动作元数据自行决策后调用 {@link #executeAction}(显式调用模式)
+ *
+ *
+ *
+ * @return 所有子引擎动作的并集,无子引擎时返回空列表
+ */
+ public List getAvailableActions() {
+ List all = new ArrayList<>();
+ for (CapabilitySubEngine engine : subEngines) {
+ try {
+ List actions = engine.getActions();
+ if (actions != null) {
+ all.addAll(actions);
+ }
+ } catch (Exception e) {
+ log.error("[CapabilityEngine] 子引擎 type={} 获取动作列表失败",
+ engine.getEngineType(), e);
+ }
+ }
+ return all;
+ }
+
+ /**
+ * 按引擎类型过滤可用动作
+ *
+ * @param engineType 引擎类型
+ * @return 该类型引擎的动作列表
+ */
+ public List getAvailableActions(EngineType engineType) {
+ List filtered = new ArrayList<>();
+ for (CapabilitySubEngine engine : subEngines) {
+ if (engine.getEngineType() == engineType) {
+ try {
+ List actions = engine.getActions();
+ if (actions != null) {
+ filtered.addAll(actions);
+ }
+ } catch (Exception e) {
+ log.error("[CapabilityEngine] 子引擎 type={} 获取动作列表失败", engineType, e);
+ }
+ }
+ }
+ return filtered;
+ }
+
+ /**
+ * 执行动作(统一入口)
+ *
+ * 按 actionName 路由到对应子引擎执行。Planner 显式调用模式走此方法。
+ *
+ *
+ * @param actionName 动作名称(需与 ActionDefinition.name 一致)
+ * @param params 动作参数
+ * @param ctx 执行上下文
+ * @return 执行结果;动作不存在时返回 failure
+ */
+ public ActionResult executeAction(String actionName, Map params, ExecutionContext ctx) {
+ if (actionName == null || actionName.isEmpty()) {
+ return ActionResult.failure(EngineType.TOOL, "actionName 不能为空", 0);
+ }
+ CapabilitySubEngine engine = actionIndex.get(actionName);
+ if (engine == null) {
+ return ActionResult.failure(EngineType.TOOL,
+ "未找到动作: " + actionName + ",可用动作: " + actionIndex.keySet(), 0);
+ }
+ long start = System.currentTimeMillis();
+ try {
+ ActionResult result = engine.execute(actionName, params, ctx);
+ long elapsed = System.currentTimeMillis() - start;
+ // 子引擎可能未填充耗时,此处兜底
+ if (result.getDurationMs() <= 0) {
+ result.setDurationMs(elapsed);
+ }
+ log.info("[CapabilityEngine] 执行动作 name={} engine={} success={} cost={}ms",
+ actionName, engine.getEngineType(), result.isSuccess(), elapsed);
+ return result;
+ } catch (Exception e) {
+ long elapsed = System.currentTimeMillis() - start;
+ log.error("[CapabilityEngine] 执行动作异常 name={} engine={}",
+ actionName, engine.getEngineType(), e);
+ return ActionResult.failure(engine.getEngineType(),
+ "执行异常: " + e.getMessage(), elapsed);
+ }
+ }
+
+ /**
+ * 重建动作索引(子引擎动态新增动作后调用)
+ */
+ public synchronized void refreshActionIndex() {
+ rebuildActionIndex();
+ }
+
+ /**
+ * 获取已注册的子引擎类型列表(供调试和监控)
+ */
+ public List getRegisteredEngineTypes() {
+ List types = new ArrayList<>();
+ for (CapabilitySubEngine engine : subEngines) {
+ types.add(engine.getEngineType());
+ }
+ return types;
+ }
+
+ /**
+ * 获取已注册的动作总数(供调试和监控)
+ */
+ public int getActionCount() {
+ return actionIndex.size();
+ }
+
+ /**
+ * 重建动作索引:遍历所有子引擎,收集动作名 → 子引擎映射
+ *
+ * 同名动作按子引擎注册顺序,先注册的保留(与 ApiToolEngine 去重策略一致)。
+ *
+ */
+ private void rebuildActionIndex() {
+ Map newIndex = new LinkedHashMap<>();
+ for (CapabilitySubEngine engine : subEngines) {
+ try {
+ List actions = engine.getActions();
+ if (actions == null) {
+ continue;
+ }
+ for (ActionDefinition action : actions) {
+ if (action == null || action.getName() == null) {
+ continue;
+ }
+ // 先注册的保留,后注册的跳过(避免同名冲突)
+ newIndex.putIfAbsent(action.getName(), engine);
+ }
+ } catch (Exception e) {
+ log.error("[CapabilityEngine] 重建索引时子引擎 type={} 获取动作失败",
+ engine.getEngineType(), e);
+ }
+ }
+ this.actionIndex = new ConcurrentHashMap<>(newIndex);
+ log.info("[CapabilityEngine] 动作索引重建完成,共 {} 个动作", actionIndex.size());
+ }
+}
diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/CapabilitySubEngine.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/CapabilitySubEngine.java
new file mode 100644
index 00000000..68f361be
--- /dev/null
+++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/CapabilitySubEngine.java
@@ -0,0 +1,64 @@
+package cn.com.mfish.common.ai.capability;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 能力子引擎接口
+ *
+ * 屏蔽异构执行细节的统一抽象。每种能力来源(Tool / Skill / MCP / Workflow)
+ * 实现此接口,由 {@link CapabilityEngine} 门面组合所有子引擎实例。
+ *
+ *
+ * 三种执行模式的关系:
+ *
+ * - LLM 驱动模式:Planner 将 {@link #getActions()} 的结果作为工具列表传给 ChatClient,
+ * 由 LLM 决定调用哪个动作、传什么参数。TOOL 引擎目前走此模式。
+ * - 显式调用模式:Planner 直接通过 {@link #execute} 指定 actionName 和参数执行。
+ * SKILL/MCP/WORKFLOW 引擎适合此模式。
+ * - 混合模式:Planner 先用 getActions() 让 LLM 决策,
+ * 再用 execute() 执行 LLM 选择的动作。两种模式可共存。
+ *
+ *
+ *
+ * 线程安全:实现类需保证 getActions() 和 execute() 线程安全,
+ * 因为可能被多个请求线程并发调用。
+ *
+ *
+ * @author: mfish
+ * @date: 2026/07/21
+ */
+public interface CapabilitySubEngine {
+
+ /**
+ * 引擎类型识别
+ *
+ * @return 引擎类型枚举(TOOL / SKILL / MCP / WORKFLOW)
+ */
+ EngineType getEngineType();
+
+ /**
+ * 向 Planner 暴露的可用动作元数据
+ *
+ * 遵循 OpenAI Tool 格式,{@link CapabilityEngine} 将所有子引擎的返回值合并为统一列表。
+ * 动作名需全局唯一(建议加引擎前缀,如 {@code tool.xxx}、{@code skill.xxx})。
+ *
+ *
+ * @return 动作定义列表,无可用动作时返回空列表
+ */
+ List getActions();
+
+ /**
+ * 执行动作
+ *
+ * 显式调用入口:按 actionName 查找并执行对应动作。
+ * 实现类需处理 actionName 不存在的情况(返回 failure 结果,不抛异常)。
+ *
+ *
+ * @param actionName 动作名称(需与 {@link ActionDefinition#getName()} 一致)
+ * @param params 动作参数(key 为参数名,value 为参数值)
+ * @param ctx 执行上下文(租户信息、会话ID、自定义属性)
+ * @return 执行结果
+ */
+ ActionResult execute(String actionName, Map params, ExecutionContext ctx);
+}
diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/EngineType.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/EngineType.java
new file mode 100644
index 00000000..ed66681d
--- /dev/null
+++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/EngineType.java
@@ -0,0 +1,50 @@
+package cn.com.mfish.common.ai.capability;
+
+/**
+ * 能力子引擎类型
+ *
+ * 标识 {@link CapabilitySubEngine} 的具体来源,供 Planner 决策时区分能力来源,
+ * 也用于日志和调试。新增引擎类型时在此枚举扩展。
+ *
+ *
+ * @author: mfish
+ * @date: 2026/07/21
+ */
+public enum EngineType {
+
+ /**
+ * 工具引擎:基于 Feign/OpenAPI 的微服务接口工具
+ *
+ * 适配现有 {@code ApiToolEngine} + {@code ToolProvider} 体系,
+ * 将 Spring AI {@code ToolCallback} 转换为 {@link ActionDefinition}。
+ *
+ */
+ TOOL,
+
+ /**
+ * 技能引擎:Java 编写的预置技能(如文件解析、数据格式化、SQL 生成等)
+ *
+ * 与 TOOL 的区别:TOOL 是远程接口的包装,SKILL 是本地 Java 逻辑的包装。
+ * SKILL 不依赖网络调用,执行延迟低、结果确定性强。
+ *
+ */
+ SKILL,
+
+ /**
+ * MCP 引擎:Model Context Protocol 客户端工具
+ *
+ * 对接外部 MCP Server,将 MCP 工具转换为统一的 ActionDefinition。
+ * 当前为预留类型,待 Spring AI MCP 集成后实现。
+ *
+ */
+ MCP,
+
+ /**
+ * 工作流引擎:基于 Flowable/BPMN 的流程编排能力
+ *
+ * 将工作流的"发起流程/审批/查询任务"等操作封装为 Action,
+ * 供 Planner 在需要人工审批或复杂流程编排时调用。
+ *
+ */
+ WORKFLOW;
+}
diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/ExecutionContext.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/ExecutionContext.java
new file mode 100644
index 00000000..bd3b881d
--- /dev/null
+++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/ExecutionContext.java
@@ -0,0 +1,88 @@
+package cn.com.mfish.common.ai.capability;
+
+import cn.com.mfish.common.ai.agent.TenantContext;
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 能力执行上下文
+ *
+ * 封装动作执行时所需的环境信息,由调用方(Planner / Executor / BaseAssistant)构建,
+ * 传递给 {@link CapabilitySubEngine#execute}。
+ *
+ *
+ * 与 Spring AI ToolContext 的关系:
+ *
+ * - ToolContext({@code Map})是 Spring AI 的工具执行上下文,
+ * 由 ChatClient 框架在调用 ToolCallback 时注入
+ * - ExecutionContext 是能力引擎层的抽象,更结构化地封装租户信息和自定义属性
+ * - 对于 TOOL 引擎:{@link ToolCapabilityEngine} 会从 ExecutionContext 提取信息,
+ * 转换为 Spring AI ToolContext 传给 ToolCallback
+ * - 对于 SKILL/MCP/WORKFLOW 引擎:直接使用 ExecutionContext 的字段
+ *
+ *
+ *
+ * @author: mfish
+ * @date: 2026/07/21
+ */
+@Data
+@Accessors(chain = true)
+public class ExecutionContext {
+
+ /**
+ * 租户上下文(包含 tenantId/userId/accessToken/请求上下文快照)
+ *
+ * 由请求线程捕获,供异步执行线程恢复认证上下文。
+ * 可为 null(如离线测试场景),各子引擎需做空值防御。
+ *
+ */
+ private TenantContext tenantContext;
+
+ /**
+ * 会话ID(用于日志追踪和会话级状态关联)
+ */
+ private String sessionId;
+
+ /**
+ * 自定义属性(扩展通道,子引擎可按约定存取特定 key)
+ *
+ * 常用 key(可选,非强制约定):
+ *
+ * - {@code requestAttributes} —— Servlet RequestAttributes
+ * - {@code serverWebExchange} —— WebFlux ServerWebExchange
+ * - {@code actionTraceId} —— 动作执行追踪ID
+ *
+ *
+ */
+ private Map attributes = new HashMap<>();
+
+ /**
+ * 添加自定义属性
+ */
+ public ExecutionContext addAttribute(String key, Object value) {
+ if (this.attributes == null) {
+ this.attributes = new HashMap<>();
+ }
+ this.attributes.put(key, value);
+ return this;
+ }
+
+ /**
+ * 获取自定义属性
+ */
+ public Object getAttribute(String key) {
+ return this.attributes != null ? this.attributes.get(key) : null;
+ }
+
+ /**
+ * 从 TenantContext 快速构建执行上下文
+ */
+ public static ExecutionContext of(TenantContext tenantContext, String sessionId) {
+ return new ExecutionContext()
+ .setTenantContext(tenantContext)
+ .setSessionId(sessionId);
+ }
+}
diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/McpCapabilityEngine.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/McpCapabilityEngine.java
new file mode 100644
index 00000000..43d8c08c
--- /dev/null
+++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/McpCapabilityEngine.java
@@ -0,0 +1,437 @@
+package cn.com.mfish.common.ai.capability;
+
+import cn.com.mfish.common.core.utils.StringUtils;
+import com.alibaba.fastjson2.JSON;
+import io.modelcontextprotocol.client.McpClient;
+import io.modelcontextprotocol.client.McpSyncClient;
+import io.modelcontextprotocol.client.transport.ServerParameters;
+import io.modelcontextprotocol.client.transport.StdioClientTransport;
+import io.modelcontextprotocol.json.McpJsonDefaults;
+import io.modelcontextprotocol.json.McpJsonMapper;
+import io.modelcontextprotocol.spec.McpClientTransport;
+import io.modelcontextprotocol.spec.McpSchema;
+import org.springframework.ai.mcp.client.webflux.transport.WebClientStreamableHttpTransport;
+import org.springframework.ai.mcp.client.webflux.transport.WebFluxSseClientTransport;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.reactive.function.client.WebClient;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * MCP 能力引擎
+ *
+ * 实现 {@link CapabilitySubEngine} 接口,对接外部 MCP (Model Context Protocol) Server,
+ * 将远程工具转换为统一的 {@link ActionDefinition}。
+ *
+ *
+ * 架构定位:
+ *
+ * CapabilityEngine(门面)
+ * │
+ * ├── ToolCapabilityEngine (Feign/OpenAPI)
+ * ├── McpCapabilityEngine (本类,MCP 协议)
+ * ├── [SkillCapabilityEngine] (未来扩展)
+ * └── [WorkflowCapabilityEngine] (未来扩展)
+ *
+ *
+ *
+ * 初始化流程:
+ *
+ * - 从 {@link McpServerConfigProvider} 获取所有活跃的 MCP 服务器配置
+ * - 为每个配置创建 {@link McpSyncClient}:
+ *
+ * - stdio Transport:通过 {@link StdioClientTransport} 拉起本地进程(Node.js/Python)
+ * - SSE Transport:通过 {@link WebFluxSseClientTransport} 连接远程 MCP 服务(已 deprecated)
+ * - Streamable HTTP Transport:通过 {@link WebClientStreamableHttpTransport} 连接远程 MCP 服务(MCP 2025-03-26 规范,推荐)
+ *
+ *
+ * - 调用 {@code client.initialize()} + {@code client.listTools()} 获取工具列表
+ * - 将工具映射为 {@link ActionDefinition},动作名加 {@code mcp.{serverName}.} 前缀避免冲突
+ *
+ *
+ *
+ * 动作名约定:{@code mcp.{serverName}.{toolName}}
+ *
示例:{@code mcp.filesystem.readFile}、{@code mcp.github.searchRepos}
+ *
+ *
+ * 线程安全:clients 和 actions 映射使用 ConcurrentHashMap,支持并发读取。
+ * 初始化在 {@link cn.com.mfish.common.ai.capability.CapabilityAutoConfiguration} 的
+ * SmartInitializingSingleton 中触发。
+ *
+ *
+ * @author: mfish
+ * @date: 2026/07/21
+ */
+@Slf4j
+@SuppressWarnings("deprecation")
+public class McpCapabilityEngine implements CapabilitySubEngine {
+
+ /**
+ * stdio 传输类型标识
+ */
+ private static final String TRANSPORT_STDIO = "stdio";
+
+ /**
+ * SSE 传输类型标识
+ */
+ private static final String TRANSPORT_SSE = "sse";
+
+ /**
+ * Streamable HTTP 传输类型标识(MCP 2025-03-26 规范,推荐用于新服务器)
+ */
+ private static final String TRANSPORT_STREAMABLE = "streamable";
+
+ /**
+ * MCP 动作名前缀
+ */
+ private static final String MCP_ACTION_PREFIX = "mcp.";
+
+ /**
+ * MCP 服务器配置提供者(由业务层注入,查询数据库)
+ */
+ private final McpServerConfigProvider configProvider;
+
+ /**
+ * MCP 客户端映射:serverName → McpSyncClient
+ */
+ private final Map clients = new ConcurrentHashMap<>();
+
+ /**
+ * 动作映射:actionName → McpActionEntry(含 client 引用和原始 toolName)
+ */
+ private final Map actionRegistry = new ConcurrentHashMap<>();
+
+ /**
+ * 已映射的 ActionDefinition 列表(不可变快照,getActions() 直接返回)
+ */
+ private volatile List cachedActions = Collections.emptyList();
+
+ public McpCapabilityEngine(McpServerConfigProvider configProvider) {
+ this.configProvider = configProvider;
+ }
+
+ @Override
+ public EngineType getEngineType() {
+ return EngineType.MCP;
+ }
+
+ /**
+ * 获取所有 MCP 服务器的工具列表(并集)
+ *
+ * 返回的是初始化时缓存的快照,不会实时调用 MCP 服务器。
+ * 如需刷新,调用 {@link #refresh()}。
+ *
+ */
+ @Override
+ public List getActions() {
+ return cachedActions;
+ }
+
+ /**
+ * 执行 MCP 工具动作
+ *
+ * 按 actionName 从 actionRegistry 查找对应的 McpSyncClient 和原始 toolName,
+ * 调用 {@code client.callTool(CallToolRequest)} 执行工具。
+ *
+ */
+ @Override
+ public ActionResult execute(String actionName, Map params, ExecutionContext ctx) {
+ long start = System.currentTimeMillis();
+ McpActionEntry entry = actionRegistry.get(actionName);
+ if (entry == null) {
+ return ActionResult.failure(EngineType.MCP,
+ "未找到 MCP 动作: " + actionName, System.currentTimeMillis() - start);
+ }
+
+ try {
+ McpSchema.CallToolRequest request = new McpSchema.CallToolRequest(
+ entry.toolName, params != null ? params : Collections.emptyMap());
+ McpSchema.CallToolResult result = entry.client.callTool(request);
+ String output = extractTextContent(result);
+ return ActionResult.success(EngineType.MCP, output, System.currentTimeMillis() - start);
+ } catch (Exception e) {
+ log.error("[McpCapabilityEngine] 执行 MCP 工具失败 action={} tool={}",
+ actionName, entry.toolName, e);
+ return ActionResult.failure(EngineType.MCP,
+ "MCP 工具执行异常: " + e.getMessage(), System.currentTimeMillis() - start);
+ }
+ }
+
+ /**
+ * 初始化:连接所有 MCP 服务器,发现工具,构建动作注册表
+ *
+ * 由 {@link CapabilityAutoConfiguration} 的 SmartInitializingSingleton 触发。
+ * 某个服务器连接失败不影响其他服务器。
+ *
+ */
+ public synchronized void refresh() {
+ if (configProvider == null) {
+ log.warn("[McpCapabilityEngine] 无 McpServerConfigProvider,跳过初始化");
+ return;
+ }
+
+ List configs;
+ try {
+ configs = configProvider.getActiveServerConfigs();
+ } catch (Exception e) {
+ log.error("[McpCapabilityEngine] 获取 MCP 配置失败", e);
+ return;
+ }
+
+ if (configs == null || configs.isEmpty()) {
+ log.info("[McpCapabilityEngine] 无活跃的 MCP 服务器配置");
+ clearAll();
+ return;
+ }
+
+ // 清理旧的连接和动作
+ clearAll();
+
+ List actions = new ArrayList<>();
+ for (McpServerInfo config : configs) {
+ try {
+ McpSyncClient client = connectServer(config);
+ if (client == null) {
+ continue;
+ }
+ clients.put(config.getServerName(), client);
+
+ // 列出工具并映射为 ActionDefinition
+ McpSchema.ListToolsResult toolsResult = client.listTools();
+ if (toolsResult == null || toolsResult.tools() == null) {
+ continue;
+ }
+ for (McpSchema.Tool tool : toolsResult.tools()) {
+ String actionName = buildActionName(config.getServerName(), tool.name());
+ ActionDefinition action = new ActionDefinition()
+ .setName(actionName)
+ .setDescription(tool.description())
+ .setInputSchema(serializeSchema(tool.inputSchema()))
+ .setEngineType(EngineType.MCP)
+ .setServiceId(config.getServerName());
+ actions.add(action);
+ actionRegistry.put(actionName, new McpActionEntry(client, tool.name()));
+ log.info("[McpCapabilityEngine] 注册 MCP 工具 server={} tool={} action={}",
+ config.getServerName(), tool.name(), actionName);
+ }
+ log.info("[McpCapabilityEngine] MCP 服务器 {} 连接成功,注册 {} 个工具",
+ config.getServerName(), toolsResult.tools().size());
+ } catch (Exception e) {
+ log.error("[McpCapabilityEngine] MCP 服务器 {} 连接失败", config.getServerName(), e);
+ }
+ }
+
+ this.cachedActions = Collections.unmodifiableList(actions);
+ log.info("[McpCapabilityEngine] 初始化完成,共 {} 个 MCP 服务器,{} 个工具",
+ clients.size(), actions.size());
+ }
+
+ /**
+ * 连接单个 MCP 服务器(支持 stdio / SSE / Streamable HTTP 三种 Transport)
+ */
+ private McpSyncClient connectServer(McpServerInfo config) {
+ String transportType = config.getTransportType();
+ if (TRANSPORT_STDIO.equalsIgnoreCase(transportType)) {
+ return connectStdio(config);
+ } else if (TRANSPORT_SSE.equalsIgnoreCase(transportType)) {
+ return connectSse(config);
+ } else if (TRANSPORT_STREAMABLE.equalsIgnoreCase(transportType)) {
+ return connectStreamable(config);
+ } else {
+ log.warn("[McpCapabilityEngine] 不支持的传输类型: {} server={}", transportType, config.getServerName());
+ return null;
+ }
+ }
+
+ /**
+ * stdio Transport:拉起本地进程
+ */
+ private McpSyncClient connectStdio(McpServerInfo config) {
+ if (StringUtils.isEmpty(config.getCommand())) {
+ log.warn("[McpCapabilityEngine] stdio 模式缺少 command 参数 server={}", config.getServerName());
+ return null;
+ }
+
+ ServerParameters.Builder builder = ServerParameters.builder(config.getCommand());
+
+ // 解析 args(JSON 数组字符串 → List)
+ if (StringUtils.isNotEmpty(config.getArgs())) {
+ try {
+ List argsList = JSON.parseArray(config.getArgs(), String.class);
+ builder.args(argsList);
+ } catch (Exception e) {
+ log.warn("[McpCapabilityEngine] 解析 args 失败 server={} args={}",
+ config.getServerName(), config.getArgs(), e);
+ }
+ }
+
+ // 解析 env(JSON 对象字符串 → Map)
+ if (StringUtils.isNotEmpty(config.getEnv())) {
+ try {
+ Map envMap = JSON.parseObject(config.getEnv(), Map.class);
+ builder.env(envMap);
+ } catch (Exception e) {
+ log.warn("[McpCapabilityEngine] 解析 env 失败 server={} env={}",
+ config.getServerName(), config.getEnv(), e);
+ }
+ }
+
+ // MCP SDK 2.0.0:StdioClientTransport 构造函数需要 McpJsonMapper 参数
+ McpJsonMapper jsonMapper = McpJsonDefaults.getMapper();
+ StdioClientTransport transport = new StdioClientTransport(builder.build(), jsonMapper);
+ return createAndInitialize(transport, config.getServerName());
+ }
+
+ /**
+ * SSE Transport:连接远程 MCP 服务
+ */
+ private McpSyncClient connectSse(McpServerInfo config) {
+ if (StringUtils.isEmpty(config.getSseUrl())) {
+ log.warn("[McpCapabilityEngine] SSE 模式缺少 sseUrl 参数 server={}", config.getServerName());
+ return null;
+ }
+
+ WebClient.Builder webClientBuilder = WebClient.builder()
+ .baseUrl(config.getSseUrl());
+
+ // 注入认证 Token
+ if (StringUtils.isNotEmpty(config.getAuthToken())) {
+ webClientBuilder.defaultHeader("Authorization", "Bearer " + config.getAuthToken());
+ }
+
+ WebFluxSseClientTransport.Builder transportBuilder = WebFluxSseClientTransport.builder(webClientBuilder);
+ if (StringUtils.isNotEmpty(config.getSseEndpoint())) {
+ transportBuilder.sseEndpoint(config.getSseEndpoint());
+ }
+
+ WebFluxSseClientTransport transport = transportBuilder.build();
+ return createAndInitialize(transport, config.getServerName());
+ }
+
+ /**
+ * Streamable HTTP Transport:连接远程 MCP 服务(MCP 2025-03-26 规范,推荐用于新服务器)
+ *
+ * 与 SSE 的区别:
+ *
+ * - 单一 /mcp 端点,请求可流式响应也可普通响应
+ * - 支持会话恢复(resumableStreams)
+ * - 支持协议版本协商(supportedProtocolVersions)
+ * - 是 MCP 2025-03-26 规范的标准 transport,SSE 已标记 deprecated
+ *
+ *
+ *
+ * 字段复用:sseUrl 作为基础 URL,sseEndpoint 作为端点路径(通常为 /mcp)。
+ *
+ */
+ private McpSyncClient connectStreamable(McpServerInfo config) {
+ if (StringUtils.isEmpty(config.getSseUrl())) {
+ log.warn("[McpCapabilityEngine] streamable 模式缺少 sseUrl 参数 server={}", config.getServerName());
+ return null;
+ }
+
+ WebClient.Builder webClientBuilder = WebClient.builder()
+ .baseUrl(config.getSseUrl());
+
+ // 注入认证 Token
+ if (StringUtils.isNotEmpty(config.getAuthToken())) {
+ webClientBuilder.defaultHeader("Authorization", "Bearer " + config.getAuthToken());
+ }
+
+ WebClientStreamableHttpTransport.Builder transportBuilder = WebClientStreamableHttpTransport.builder(webClientBuilder);
+ if (StringUtils.isNotEmpty(config.getSseEndpoint())) {
+ transportBuilder.endpoint(config.getSseEndpoint());
+ }
+
+ WebClientStreamableHttpTransport transport = transportBuilder.build();
+ return createAndInitialize(transport, config.getServerName());
+ }
+
+ /**
+ * 创建 McpSyncClient 并初始化连接
+ */
+ private McpSyncClient createAndInitialize(McpClientTransport transport, String serverName) {
+ McpSyncClient client = McpClient.sync(transport)
+ .requestTimeout(Duration.ofSeconds(60))
+ .build();
+ client.initialize();
+ log.info("[McpCapabilityEngine] MCP 客户端已初始化 server={}", serverName);
+ return client;
+ }
+
+ /**
+ * 构建 MCP 动作名:mcp.{serverName}.{toolName}
+ */
+ private String buildActionName(String serverName, String toolName) {
+ return MCP_ACTION_PREFIX + serverName + "." + toolName;
+ }
+
+ /**
+ * 从 CallToolResult 提取文本内容
+ */
+ private String extractTextContent(McpSchema.CallToolResult result) {
+ if (result == null || result.content() == null || result.content().isEmpty()) {
+ return "";
+ }
+ StringBuilder sb = new StringBuilder();
+ for (McpSchema.Content content : result.content()) {
+ if (content instanceof McpSchema.TextContent tc) {
+ sb.append(tc.text());
+ } else {
+ // 非 TextContent 类型,序列化为 JSON
+ sb.append(JSON.toJSONString(content));
+ }
+ sb.append("\n");
+ }
+ return sb.toString().trim();
+ }
+
+ /**
+ * 将 MCP Tool 的 inputSchema 序列化为 JSON 字符串
+ */
+ private String serializeSchema(Object inputSchema) {
+ if (inputSchema == null) {
+ return "{}";
+ }
+ try {
+ return JSON.toJSONString(inputSchema);
+ } catch (Exception e) {
+ log.warn("[McpCapabilityEngine] 序列化 inputSchema 失败", e);
+ return "{}";
+ }
+ }
+
+ /**
+ * 清理所有连接和动作注册表
+ */
+ private void clearAll() {
+ for (Map.Entry entry : clients.entrySet()) {
+ try {
+ entry.getValue().close();
+ } catch (Exception e) {
+ log.warn("[McpCapabilityEngine] 关闭 MCP 客户端失败 server={}", entry.getKey(), e);
+ }
+ }
+ clients.clear();
+ actionRegistry.clear();
+ cachedActions = Collections.emptyList();
+ }
+
+ /**
+ * MCP 动作注册表条目:持有 McpSyncClient 引用和原始 toolName
+ */
+ private static class McpActionEntry {
+ final McpSyncClient client;
+ final String toolName;
+
+ McpActionEntry(McpSyncClient client, String toolName) {
+ this.client = client;
+ this.toolName = toolName;
+ }
+ }
+}
diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/McpServerConfigProvider.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/McpServerConfigProvider.java
new file mode 100644
index 00000000..292d46eb
--- /dev/null
+++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/McpServerConfigProvider.java
@@ -0,0 +1,27 @@
+package cn.com.mfish.common.ai.capability;
+
+import java.util.List;
+
+/**
+ * MCP 服务器配置提供者接口
+ *
+ * 跨模块解耦:mf-common-ai 模块的 {@link McpCapabilityEngine} 通过此接口获取数据库中的 MCP 配置,
+ * 由 mf-ai 业务模块实现(查询 ai_mcp_server_config 表)。
+ *
+ *
+ * 若 Spring 容器中无此接口的实现 Bean(如 mf-common-ai 独立测试场景),
+ * {@link McpCapabilityEngine} 不报错,返回空动作列表。
+ *
+ *
+ * @author: mfish
+ * @date: 2026/07/21
+ */
+public interface McpServerConfigProvider {
+
+ /**
+ * 获取所有状态为"正常"的 MCP 服务器配置
+ *
+ * @return MCP 服务器配置列表,无配置时返回空列表
+ */
+ List getActiveServerConfigs();
+}
diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/McpServerInfo.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/McpServerInfo.java
new file mode 100644
index 00000000..72764c53
--- /dev/null
+++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/McpServerInfo.java
@@ -0,0 +1,64 @@
+package cn.com.mfish.common.ai.capability;
+
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+/**
+ * MCP 服务器配置信息(mf-common-ai 模块内的 DTO)
+ *
+ * 与 {@code McpServerConfig} 实体类解耦:mf-common-ai 不依赖 mf-ai-api 模块,
+ * 通过 {@link McpServerConfigProvider} 接口由业务层注入配置数据。
+ *
+ *
+ * @author: mfish
+ * @date: 2026/07/21
+ */
+@Data
+@Accessors(chain = true)
+public class McpServerInfo {
+
+ /**
+ * MCP 服务器名称(唯一标识)
+ */
+ private String serverName;
+
+ /**
+ * 传输类型:stdio / sse / streamable
+ *
+ * - stdio — 拉起本地进程(Node.js/Python)
+ * - sse — SSE Transport(MCP 2024-11-05 规范,已 deprecated)
+ * - streamable — Streamable HTTP Transport(MCP 2025-03-26 规范,推荐)
+ *
+ */
+ private String transportType;
+
+ /**
+ * stdio 模式启动命令(如 node / python)
+ */
+ private String command;
+
+ /**
+ * stdio 模式参数(JSON 数组字符串,如 ["server.js","--port","3000"])
+ */
+ private String args;
+
+ /**
+ * stdio 模式环境变量(JSON 对象字符串)
+ */
+ private String env;
+
+ /**
+ * 远程 MCP 服务基础 URL(sse / streamable 模式通用,如 https://mcp.example.com)
+ */
+ private String sseUrl;
+
+ /**
+ * 远程 MCP 服务端点路径(sse / streamable 模式通用,如 /sse 或 /mcp)
+ */
+ private String sseEndpoint;
+
+ /**
+ * 认证 Token(Bearer)
+ */
+ private String authToken;
+}
diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/ToolCapabilityEngine.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/ToolCapabilityEngine.java
new file mode 100644
index 00000000..676a19df
--- /dev/null
+++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/capability/ToolCapabilityEngine.java
@@ -0,0 +1,175 @@
+package cn.com.mfish.common.ai.capability;
+
+import cn.com.mfish.common.ai.agent.TenantContext;
+import cn.com.mfish.common.ai.engine.ApiToolEngine;
+import cn.com.mfish.common.core.constants.RPCConstants;
+import cn.com.mfish.common.core.utils.AuthInfoUtils;
+import cn.com.mfish.common.core.utils.StringUtils;
+import com.alibaba.fastjson2.JSON;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.chat.model.ToolContext;
+import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.definition.ToolDefinition;
+import org.springframework.web.context.request.RequestAttributes;
+import org.springframework.web.server.ServerWebExchange;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 工具能力引擎适配器
+ *
+ * 将现有 {@link ApiToolEngine} 包装为 {@link CapabilitySubEngine},
+ * 使 Feign/OpenAPI 工具能纳入 CapabilityEngine 的统一能力发现与执行体系。
+ *
+ *
+ * 适配逻辑:
+ *
+ * - {@link #getActions()}:遍历 ApiToolEngine 所有 ToolCallback,
+ * 将 {@code ToolDefinition}(name/description/inputSchema)转换为 {@link ActionDefinition}
+ * - {@link #execute}:按 actionName 查找 ToolCallback,
+ * 将 params Map 序列化为 JSON 字符串,构建 Spring AI ToolContext,
+ * 调用 {@code ToolCallback.call(toolInput, toolContext)}
+ *
+ *
+ *
+ * 与 LLM 驱动模式的关系:
+ *
+ * - LLM 驱动模式:BaseAssistant 直接调用 {@code apiToolEngine.getToolCallbackProvider(serviceIds)}
+ * 传给 ChatClient,由 LLM 决定调用哪个工具。此模式不经过 ToolCapabilityEngine。
+ * - 显式调用模式:Planner 通过 {@code capabilityEngine.executeAction(actionName, params, ctx)}
+ * 直接执行指定动作。此模式经过 ToolCapabilityEngine。
+ * - 两种模式共享同一套 ToolCallback 实例,执行结果一致。
+ *
+ *
+ *
+ * @author: mfish
+ * @date: 2026/07/21
+ */
+@Slf4j
+public class ToolCapabilityEngine implements CapabilitySubEngine {
+
+ private final ApiToolEngine apiToolEngine;
+
+ public ToolCapabilityEngine(ApiToolEngine apiToolEngine) {
+ this.apiToolEngine = apiToolEngine;
+ }
+
+ @Override
+ public EngineType getEngineType() {
+ return EngineType.TOOL;
+ }
+
+ /**
+ * 将 ApiToolEngine 中所有 ToolCallback 转换为 ActionDefinition
+ *
+ * 动作名直接使用 ToolCallback 的原始 name(与 LLM 驱动模式保持一致),
+ * 跨服务同名动作由 ApiToolEngine 去重策略保证唯一。
+ * serviceId 字段标识动作来源服务,便于 Planner 按服务过滤。
+ *
+ */
+ @Override
+ public List getActions() {
+ List actions = new ArrayList<>();
+ for (String serviceId : apiToolEngine.getAllServiceIds()) {
+ org.springframework.ai.tool.ToolCallbackProvider provider =
+ apiToolEngine.getToolCallbackProvider(serviceId);
+ if (provider == null) {
+ continue;
+ }
+ org.springframework.ai.tool.ToolCallback[] callbacks = provider.getToolCallbacks();
+ if (callbacks == null) {
+ continue;
+ }
+ for (org.springframework.ai.tool.ToolCallback tc : callbacks) {
+ try {
+ ToolDefinition td = tc.getToolDefinition();
+ actions.add(new ActionDefinition()
+ .setName(td.name())
+ .setDescription(td.description())
+ .setInputSchema(td.inputSchema())
+ .setEngineType(EngineType.TOOL)
+ .setServiceId(serviceId));
+ } catch (Exception e) {
+ log.warn("[ToolCapabilityEngine] 转换动作失败 serviceId={}", serviceId, e);
+ }
+ }
+ }
+ return actions;
+ }
+
+ /**
+ * 执行工具动作
+ *
+ * 通过 ApiToolEngine 查找 actionName 对应的 ToolCallback,
+ * 将 params 序列化为 JSON 字符串作为 toolInput,
+ * 从 ExecutionContext 提取租户信息构建 ToolContext,
+ * 调用 {@code ToolCallback.call(toolInput, toolContext)}。
+ *
+ */
+ @Override
+ public ActionResult execute(String actionName, Map params, ExecutionContext ctx) {
+ long start = System.currentTimeMillis();
+ ToolCallback callback = apiToolEngine.findToolCallback(actionName);
+ if (callback == null) {
+ return ActionResult.failure(EngineType.TOOL,
+ "未找到工具动作: " + actionName, System.currentTimeMillis() - start);
+ }
+
+ // 构建 Spring AI ToolContext(复用 BaseAssistant 的 context key 约定)
+ ToolContext toolContext = buildToolContext(ctx);
+
+ // params 序列化为 JSON 字符串
+ String toolInput = (params == null || params.isEmpty())
+ ? "{}"
+ : JSON.toJSONString(params);
+
+ try {
+ String result = callback.call(toolInput, toolContext);
+ return ActionResult.success(EngineType.TOOL, result, System.currentTimeMillis() - start);
+ } catch (Exception e) {
+ log.error("[ToolCapabilityEngine] 执行工具动作失败 name={} input={}",
+ actionName, toolInput, e);
+ return ActionResult.failure(EngineType.TOOL,
+ "工具执行异常: " + e.getMessage(), System.currentTimeMillis() - start);
+ }
+ }
+
+ /**
+ * 从 ExecutionContext 构建 Spring AI ToolContext
+ *
+ * 复用 {@code BaseAssistant.buildToolContextFromSnapshot} 的 key 约定
+ * (RPCConstants.REQ_* 常量),保证 FeignToolCallback/HttpToolCallback
+ * 的内部参数自动填充逻辑一致。
+ *
+ */
+ private ToolContext buildToolContext(ExecutionContext ctx) {
+ Map contextMap = new HashMap<>();
+
+ if (ctx != null && ctx.getTenantContext() != null) {
+ TenantContext tc = ctx.getTenantContext();
+ contextMap.put(RPCConstants.REQ_USER_ID, tc.getUserId() != null ? tc.getUserId() : "");
+ contextMap.put(RPCConstants.REQ_TENANT_ID,
+ tc.getTenantId() != null ? tc.getTenantId() : AuthInfoUtils.SUPER_TENANT_ID);
+ contextMap.put(RPCConstants.REQ_ORIGIN, RPCConstants.AI);
+ if (StringUtils.isNotEmpty(tc.getAccessToken())) {
+ contextMap.put(RPCConstants.REQ_TOKEN, tc.getAccessToken());
+ }
+ if (tc.getRequestAttributes() instanceof RequestAttributes ra) {
+ contextMap.put(RPCConstants.REQ_REQUEST_ATTRIBUTES, ra);
+ }
+ if (tc.getServerWebExchange() instanceof ServerWebExchange swe) {
+ contextMap.put(RPCConstants.REQ_SERVER_WEB_EXCHANGE, swe);
+ }
+ } else {
+ // 无 TenantContext 时填充默认值,避免内部参数解析失败
+ contextMap.put(RPCConstants.REQ_USER_ID, "");
+ contextMap.put(RPCConstants.REQ_TENANT_ID, AuthInfoUtils.SUPER_TENANT_ID);
+ contextMap.put(RPCConstants.REQ_ORIGIN, RPCConstants.AI);
+ }
+
+ return new ToolContext(contextMap);
+ }
+}
diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/engine/ApiToolEngine.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/engine/ApiToolEngine.java
index 493d0267..3a26ec54 100644
--- a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/engine/ApiToolEngine.java
+++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/engine/ApiToolEngine.java
@@ -186,4 +186,53 @@ public class ApiToolEngine {
public ToolCallbackProvider getToolCallbackProvider(Set serviceIds) {
return ToolCallbackProvider.from(getToolCallbacks(serviceIds));
}
+
+ /**
+ * 获取所有已注册的微服务ID
+ *
+ * 供 {@code ToolCapabilityEngine} 枚举所有服务的工具,将其暴露为 ActionDefinition。
+ *
+ *
+ * @return 已注册的 serviceId 集合(不可变快照)
+ */
+ public Set getAllServiceIds() {
+ return Set.copyOf(toolsByService.keySet());
+ }
+
+ /**
+ * 获取所有服务的全部工具(跨服务,同名工具按 serviceId 顺序去重)
+ *
+ * 供 {@code ToolCapabilityEngine} 构建全局动作列表。
+ * 去重策略与 {@link #getToolCallbacks(Collection)} 一致:先注册的保留。
+ *
+ *
+ * @return 全部工具列表(去重后的快照)
+ */
+ public List getAllToolCallbacks() {
+ return getToolCallbacks(toolsByService.keySet());
+ }
+
+ /**
+ * 按动作名查找对应的 ToolCallback
+ *
+ * 供 {@code ToolCapabilityEngine.execute()} 通过 actionName 路由到具体工具。
+ * 遍历所有服务,返回首个名称匹配的 ToolCallback。
+ *
+ *
+ * @param actionName 动作名(即 ToolCallback.getToolDefinition().name())
+ * @return 匹配的 ToolCallback,未找到时返回 null
+ */
+ public ToolCallback findToolCallback(String actionName) {
+ if (actionName == null || actionName.isEmpty()) {
+ return null;
+ }
+ for (List callbacks : toolsByService.values()) {
+ for (ToolCallback tc : callbacks) {
+ if (actionName.equals(tc.getToolDefinition().name())) {
+ return tc;
+ }
+ }
+ }
+ return null;
+ }
}
diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/ConversationMemory.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/ConversationMemory.java
new file mode 100644
index 00000000..0c15ff4a
--- /dev/null
+++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/ConversationMemory.java
@@ -0,0 +1,268 @@
+package cn.com.mfish.common.ai.memory;
+
+import cn.com.mfish.common.ai.agent.TenantContext;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 会话级记忆容器(Conversation Memory)
+ *
+ * 三驾马车驱动的 AI 架构 —— Memory 模块的核心抽象。
+ * 每个 sessionId 对应一个 ConversationMemory 实例,聚合会话内 Spring AI 管不到的两类上下文:
+ *
+ *
+ * - Vars Context(变量上下文):低代码平台运行实例中的局部变量、用户凭证(Token)、
+ * 租户上下文等。统一收纳原本散落在 TenantContext / ToolContext / 业务变量中的数据,
+ * 通过 {@link #updateVariable} / {@link #getVariable} 读写,{@link #getVarsContext}
+ * 供 Planner 一次性快照。
+ * - Document Context(文档上下文):由 FileParseService 解析后的临时文件块(Chunk)。
+ * 通过 {@link #addDocumentChunks} 批量注入,{@link #getDocumentChunks} 读取,
+ * {@link #getDocumentContext} 直接产出 LLM 可读的拼接文本。
+ *
+ *
+ * 为什么不管理 Short-term Memory(Chat 历史)?
+ * Spring AI 的 {@code ChatMemory} + {@code MessageChatMemoryAdvisor} 已经完整覆盖
+ * "对话历史存储 + 自动注入 LLM 上下文" 两个职责,本模块不再重复实现,避免双写不一致。
+ * Planner / Assistant 若需要读取对话历史,直接注入 Spring AI 的 {@code ChatMemory} 即可。
+ *
+ *
+ * 接口设计原则:
+ *
+ * - 会话隔离:所有方法以当前实例绑定的 sessionId 为作用域,不跨会话泄漏
+ * - 两段独立:Vars 和 Documents 互不耦合,可分别清理({@link #clearVars} / {@link #clearDocuments})
+ * - 上下文拼接:{@link #getSystemContext()} 一次性产出 LLM 系统提示词所需的全部上下文片段
+ * - 不耦合存储:本接口只定义读写契约,底层存储由实现类决定(内存/Redis/DB)
+ *
+ *
+ *
+ * @author: mfish
+ * @date: 2026/07/20
+ */
+public interface ConversationMemory {
+
+ /**
+ * 获取当前会话 ID
+ */
+ String getSessionId();
+
+ // ==================== Vars Context ====================
+
+ /**
+ * 更新一个变量到 Vars Context
+ *
+ * 若变量已存在则覆盖。常见用法:
+ *
+ *
+ * - 低代码平台运行时写入局部变量(如 `userId` / `formId` / `businessKey`)
+ * - 认证拦截器写入用户凭证(`accessToken` / `refreshToken`)
+ * - 编排流程写入中间结果(如 Planner 产出的 `currentPlan` / Executor 写入的 `stepResults`)
+ *
+ *
+ * @param key 变量名(建议使用命名空间前缀避免冲突,如 `sys.userId` / `biz.formId`)
+ * @param value 变量值(可为 null,表示移除该变量)
+ */
+ void updateVariable(String key, Object value);
+
+ /**
+ * 读取一个变量
+ *
+ * @param key 变量名
+ * @return 变量值;不存在返回 null
+ */
+ Object getVariable(String key);
+
+ /**
+ * 读取一个变量并按指定类型转换
+ *
+ * @param key 变量名
+ * @param targetType 目标类型
+ * @param 泛型
+ * @return 转换后的值;不存在或类型不匹配返回 null
+ */
+ T getVariable(String key, Class targetType);
+
+ /**
+ * 获取 Vars Context 的快照(只读视图)
+ *
+ * Planner 拼接上下文时调用,避免逐个 getVariable 的开销。
+ * 返回的 Map 不可变,修改需通过 {@link #updateVariable}。
+ *
+ *
+ * @return 变量快照(不可变 Map)
+ */
+ Map getVarsContext();
+
+ /**
+ * 绑定租户上下文到 Vars Context
+ *
+ * 将 TenantContext 的 5 个字段(tenantId / userId / accessToken / requestAttributes /
+ * serverWebExchange)按约定的 key 写入 Vars Context,供后续工具调用恢复认证态。
+ *
+ *
+ * 这是对原 AgentRuntime.captureTenantContext + BaseAssistant.buildToolContext 流程的统一收纳:
+ * 原本散落在两处的"捕获-传递-解包"逻辑,现在统一为 Memory 的一次 bind 调用。
+ *
+ *
+ * @param tenantContext 租户上下文快照(null 表示清除绑定的租户信息)
+ */
+ void bindTenantContext(TenantContext tenantContext);
+
+ /**
+ * 清空 Vars Context
+ */
+ void clearVars();
+
+ // ==================== Document Context ====================
+
+ /**
+ * 批量添加文档块到 Document Context
+ *
+ * FileParseService 解析文件后调用此方法注入。重复添加相同 chunkId 的块会被去重。
+ *
+ *
+ * @param chunks 文档块列表(不能为 null,可为空列表)
+ */
+ void addDocumentChunks(List chunks);
+
+ /**
+ * 获取当前会话的全部文档块
+ *
+ * 返回的是副本或不可变视图,外部修改不影响内部状态。
+ *
+ *
+ * @return 文档块列表(可能为空,不会为 null)
+ */
+ List getDocumentChunks();
+
+ /**
+ * 获取 Document Context 拼接后的文本
+ *
+ * 按 chunkIndex 顺序拼接所有文档块内容,每个块带文件名分隔符。
+ * Planner / Assistant 注入 LLM 上下文时调用此方法,无需自己拼接。
+ *
+ *
+ * 全量注入语义:本方法返回当前会话的全部文档块内容,适合文档量较小的场景。
+ * 文档量较大时(几十份 PDF / 大型代码库),建议改用 {@link #searchDocumentContext} 按相关性检索,
+ * 避免 LLM 上下文窗口爆炸。
+ *
+ *
+ * 示例输出:
+ *
+ *
+ * === 文件: example.docx ===
+ * 文件内容...
+ * === 文件结束: example.docx ===
+ *
+ *
+ * @return 拼接后的文本;无文档时返回空字符串
+ */
+ String getDocumentContext();
+
+ /**
+ * 按相关性检索文档块(向量库扩展点)
+ *
+ * 默认实现(内存版):忽略 query,返回全部文档块(截断到 topK)。
+ * 这等价于"全量注入",保持与 {@link #getDocumentChunks()} 一致的行为。
+ *
+ *
+ * 向量库版实现:将 query 文本向量化,按余弦相似度返回最相关的 topK 个 chunk。
+ * 这是 RAG(检索增强生成)的核心入口,让 LLM 只看到最相关的文档片段而非全部文档。
+ *
+ *
+ * 调用方建议:
+ *
+ * - 文档量小(< 5 个文件或 < 20K 字符):用 {@link #getDocumentChunks()} 全量注入
+ * - 文档量大:用 {@code searchDocuments(prompt, 5)} 检索 topK 个最相关 chunk
+ *
+ *
+ *
+ * @param query 检索查询文本(通常是用户 prompt 或当前任务描述);内存版可忽略此参数
+ * @param topK 返回的最大 chunk 数;<= 0 表示不限制(返回全部)
+ * @return 检索到的文档块列表(按相关性或 chunkIndex 排序);可能为空,不会为 null
+ */
+ default List searchDocuments(String query, int topK) {
+ List all = getDocumentChunks();
+ if (topK <= 0 || all.size() <= topK) {
+ return all;
+ }
+ return new java.util.ArrayList<>(all.subList(0, topK));
+ }
+
+ /**
+ * 按相关性检索并拼接为 LLM 上下文文本(向量库扩展点)
+ *
+ * 默认实现:调用 {@link #searchDocuments(query, topK)} 检索,然后按
+ * {@link #getDocumentContext()} 的格式拼接。
+ *
+ *
+ * 向量库版可重写此方法以使用更高效的批量检索 API,或在拼接时附加相关性分数。
+ *
+ *
+ * @param query 检索查询文本;内存版可忽略
+ * @param topK 返回的最大 chunk 数;<= 0 表示不限制
+ * @return 拼接后的文本;无文档或检索无结果时返回空字符串
+ */
+ default String searchDocumentContext(String query, int topK) {
+ List chunks = searchDocuments(query, topK);
+ if (chunks.isEmpty()) {
+ return "";
+ }
+ StringBuilder sb = new StringBuilder();
+ for (DocumentChunk chunk : chunks) {
+ String fileName = chunk.getFileName() != null ? chunk.getFileName() : chunk.getFileKey();
+ sb.append("=== 文件: ").append(fileName).append(" ===\n");
+ sb.append(chunk.getContent() != null ? chunk.getContent() : "");
+ sb.append("\n=== 文件结束: ").append(fileName).append(" ===\n\n");
+ }
+ return sb.toString();
+ }
+
+ /**
+ * 清空 Document Context
+ */
+ void clearDocuments();
+
+ // ==================== 上下文拼接 ====================
+
+ /**
+ * 一次性产出 LLM 系统提示词所需的全部上下文
+ *
+ * Planner 在构建 prompt 时调用此方法,获取 Vars Context 和 Document Context 的拼接文本,
+ * 注入到 system prompt 中。Short-term Memory(Chat 历史)由 Spring AI
+ * MessageChatMemoryAdvisor 自动注入,不在此方法产出范围内。
+ *
+ *
+ * 输出格式示例:
+ *
+ *
+ * 【用户上下文】
+ * 租户: tenant_001
+ * 用户: user_123
+ *
+ * 【业务变量】
+ * formId: 1001
+ * businessKey: ORDER-2026-001
+ *
+ * 【上传文件】
+ * === 文件: example.docx ===
+ * 文件内容...
+ * === 文件结束: example.docx ===
+ *
+ *
+ * @return 系统上下文文本;无任何上下文时返回空字符串
+ */
+ String getSystemContext();
+
+ /**
+ * 清空所有记忆(Vars + Documents)
+ *
+ * 用于会话彻底销毁场景。会话本身的存储条目由 {@link ConversationMemoryStore} 管理。
+ *
+ *
+ * 注意:此方法不清空 Spring AI 的 ChatMemory(对话历史),那部分由 Spring AI
+ * 自己管理,会话结束时业务方需另行调用 {@code ChatMemory.clear(sessionId)}。
+ *
+ */
+ void clearAll();
+}
diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/ConversationMemoryStore.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/ConversationMemoryStore.java
new file mode 100644
index 00000000..f84a2443
--- /dev/null
+++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/ConversationMemoryStore.java
@@ -0,0 +1,85 @@
+package cn.com.mfish.common.ai.memory;
+
+import java.util.List;
+
+/**
+ * 会话记忆存储(Conversation Memory Store)
+ *
+ * Memory 模块的入口,负责按 sessionId 创建、获取、销毁 {@link ConversationMemory} 实例。
+ * 对外暴露类似 Map 的语义,但底层可以是内存 / Redis / 数据库。
+ *
+ *
+ * 使用方式:
+ *
+ *
+ * // 在请求入口获取(或创建)会话 Memory
+ * ConversationMemory memory = memoryStore.getOrCreate(sessionId);
+ *
+ * // 注入上下文
+ * memory.bindTenantContext(tenantContext);
+ * memory.addDocumentChunks(chunks);
+ * memory.updateVariable("biz.formId", 1001);
+ *
+ * // Planner / Assistant 读取上下文
+ * String systemContext = memory.getSystemContext();
+ *
+ * // 会话结束后清理
+ * memoryStore.remove(sessionId);
+ *
+ *
+ * 设计为 Spring Bean,由 {@code MemoryAutoConfiguration} 注册。
+ * Controller / Assistant / Runtime 通过依赖注入获取。
+ *
+ *
+ * @author: mfish
+ * @date: 2026/07/20
+ */
+public interface ConversationMemoryStore {
+
+ /**
+ * 获取或创建会话 Memory
+ *
+ * 首次访问某 sessionId 时创建新实例并缓存;后续访问返回同一实例。
+ * 实例的生命周期由实现类管理(如基于 TTL 自动过期)。
+ *
+ *
+ * @param sessionId 会话ID(不能为 null 或空)
+ * @return 会话 Memory 实例
+ */
+ ConversationMemory getOrCreate(String sessionId);
+
+ /**
+ * 获取会话 Memory(不创建)
+ *
+ * @param sessionId 会话ID
+ * @return 会话 Memory 实例;不存在返回 null
+ */
+ ConversationMemory get(String sessionId);
+
+ /**
+ * 移除会话 Memory 并清理其内部状态
+ *
+ * 触发 {@link ConversationMemory#clearAll()},并从存储中移除。
+ * 用于会话主动关闭、用户登出等场景。
+ *
+ *
+ * @param sessionId 会话ID
+ * @return 被移除的实例;不存在返回 null
+ */
+ ConversationMemory remove(String sessionId);
+
+ /**
+ * 获取当前活跃的会话 ID 列表
+ *
+ * 主要用于监控、调试、定期清理任务。
+ *
+ *
+ * @return 会话 ID 列表(不可变)
+ */
+ List listSessionIds();
+
+ /**
+ * 当前存储的会话数量
+ */
+ int size();
+}
diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/DocumentChunk.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/DocumentChunk.java
new file mode 100644
index 00000000..5a0ad758
--- /dev/null
+++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/DocumentChunk.java
@@ -0,0 +1,79 @@
+package cn.com.mfish.common.ai.memory;
+
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+/**
+ * 文档块(Document Chunk)
+ *
+ * 由 FileParseService 解析文件后产出的最小内容单元。一个文件可能产生多个 Chunk,
+ * 也可能只有一个 Chunk(小文件)。Memory 模块按 Chunk 维度管理文档上下文,
+ * 便于后续扩展:按相关性检索(RAG)、按 token 上限分片注入等。
+ *
+ *
+ * 设计为不可变值对象:构造后内容不再修改,需要更新时替换整个 Chunk。
+ *
+ *
+ * @author: mfish
+ * @date: 2026/07/20
+ */
+@Data
+@Accessors(chain = true)
+public class DocumentChunk {
+ /**
+ * 块ID(建议使用 UUID 或 fileKey + 序号)
+ */
+ private String chunkId;
+ /**
+ * 来源文件的 fileKey(Storage 服务的唯一标识)
+ */
+ private String fileKey;
+ /**
+ * 来源文件名(用于 LLM 提示词展示)
+ */
+ private String fileName;
+ /**
+ * 文件 MIME 类型(用于 LLM 提示词展示和后续分流处理)
+ */
+ private String fileType;
+ /**
+ * 块文本内容(已提取为纯文本,可直接注入 LLM 上下文)
+ */
+ private String content;
+ /**
+ * 在原文件中的序号(从 0 开始;单文件单块时为 0)
+ */
+ private int chunkIndex;
+ /**
+ * 块字符数(便于 token 估算和上下文裁剪)
+ */
+ private int length;
+ /**
+ * 文本向量(embedding),用于向量库相似度检索
+ *
+ * 维度由 embedding model 决定(如 OpenAI text-embedding-3-small 为 1536 维)。
+ *
+ *
+ * 使用约定:
+ *
+ * - 内存版 Memory:不使用此字段(始终为 null),searchDocuments 走全量返回
+ * - 向量库版 Memory:由 Store 在写入时调用 embedding model 计算并存储,
+ * chunk 对象上的此字段可为 null(向量库内部维护真正的向量索引)
+ * - 调试场景:可手动填充此字段用于离线分析
+ *
+ *
+ *
+ * 类型为 {@code float[]} 而非 {@code List},避免装箱开销;
+ * Lombok {@code @Data} 会基于内容生成 equals/hashCode,正常使用无影响。
+ *
+ */
+ private float[] embedding;
+ /**
+ * 解析时间戳(毫秒)
+ */
+ private long parsedAt;
+
+ public DocumentChunk() {
+ this.parsedAt = System.currentTimeMillis();
+ }
+}
diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/InMemoryConversationMemory.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/InMemoryConversationMemory.java
new file mode 100644
index 00000000..10df1cae
--- /dev/null
+++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/InMemoryConversationMemory.java
@@ -0,0 +1,279 @@
+package cn.com.mfish.common.ai.memory;
+
+import cn.com.mfish.common.ai.agent.TenantContext;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 基于 JVM 内存的 {@link ConversationMemory} 默认实现
+ *
+ * 两段记忆的存储策略:
+ *
+ * - Vars Context:使用 {@link ConcurrentHashMap} 存储变量,支持并发读写。
+ * 值为 Object,业务方自行约定类型契约。
+ * - Document Context:使用 {@link List} + 同步锁保护,因为文档块通常批量写入、
+ * 顺序读出。按 chunkId 去重。
+ *
+ *
+ *
+ * 不管理 Short-term Memory:对话历史由 Spring AI 的 {@code ChatMemory} +
+ * {@code MessageChatMemoryAdvisor} 自动管理,本类不涉及,避免双写不一致。
+ *
+ *
+ * 线程安全:本类所有方法线程安全,可被多个线程(请求线程 + 异步编排线程)并发访问。
+ * 实例由 {@link InMemoryConversationMemoryStore} 按 sessionId 单例化,无需关心实例级并发。
+ *
+ *
+ * @author: mfish
+ * @date: 2026/07/20
+ */
+@Slf4j
+public class InMemoryConversationMemory implements ConversationMemory {
+
+ /**
+ * Vars Context 中租户上下文的标准 key 前缀
+ */
+ private static final String TENANT_KEY_PREFIX = "sys.tenant.";
+ /** tenantId 在 Vars Context 中的 key */
+ public static final String KEY_TENANT_ID = "sys.tenant.tenantId";
+ /** userId 在 Vars Context 中的 key */
+ public static final String KEY_USER_ID = "sys.tenant.userId";
+ /** accessToken 在 Vars Context 中的 key */
+ public static final String KEY_ACCESS_TOKEN = "sys.tenant.accessToken";
+ /** requestAttributes 在 Vars Context 中的 key(Servlet) */
+ public static final String KEY_REQUEST_ATTRIBUTES = "sys.tenant.requestAttributes";
+ /** serverWebExchange 在 Vars Context 中的 key(WebFlux) */
+ public static final String KEY_SERVER_WEB_EXCHANGE = "sys.tenant.serverWebExchange";
+
+ private final String sessionId;
+
+ /** Vars Context:业务变量 + 租户上下文,统一存储 */
+ private final Map varsContext = new ConcurrentHashMap<>();
+
+ /** Document Context:文件解析块列表,按 chunkIndex 顺序维护 */
+ private final List documentChunks = new ArrayList<>();
+
+ /** 文档块去重索引:chunkId → 是否已存在 */
+ private final Map chunkIndex = new ConcurrentHashMap<>();
+
+ public InMemoryConversationMemory(String sessionId) {
+ this.sessionId = Objects.requireNonNull(sessionId, "sessionId 不能为 null");
+ }
+
+ @Override
+ public String getSessionId() {
+ return sessionId;
+ }
+
+ // ==================== Vars Context ====================
+
+ @Override
+ public void updateVariable(String key, Object value) {
+ if (key == null || key.isEmpty()) {
+ return;
+ }
+ if (value == null) {
+ varsContext.remove(key);
+ } else {
+ varsContext.put(key, value);
+ }
+ }
+
+ @Override
+ public Object getVariable(String key) {
+ if (key == null || key.isEmpty()) {
+ return null;
+ }
+ return varsContext.get(key);
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public T getVariable(String key, Class targetType) {
+ Object value = getVariable(key);
+ if (value == null) {
+ return null;
+ }
+ if (targetType.isInstance(value)) {
+ return (T) value;
+ }
+ log.warn("[Memory] 变量类型不匹配 key={} expected={} actual={}",
+ key, targetType.getName(), value.getClass().getName());
+ return null;
+ }
+
+ @Override
+ public Map getVarsContext() {
+ // 返回不可变快照,外部修改需通过 updateVariable
+ return Collections.unmodifiableMap(new LinkedHashMap<>(varsContext));
+ }
+
+ @Override
+ public void bindTenantContext(TenantContext tenantContext) {
+ if (tenantContext == null) {
+ // 清除绑定的租户信息
+ varsContext.remove(KEY_TENANT_ID);
+ varsContext.remove(KEY_USER_ID);
+ varsContext.remove(KEY_ACCESS_TOKEN);
+ varsContext.remove(KEY_REQUEST_ATTRIBUTES);
+ varsContext.remove(KEY_SERVER_WEB_EXCHANGE);
+ return;
+ }
+ varsContext.put(KEY_TENANT_ID, tenantContext.getTenantId());
+ varsContext.put(KEY_USER_ID, tenantContext.getUserId());
+ varsContext.put(KEY_ACCESS_TOKEN, tenantContext.getAccessToken());
+ if (tenantContext.getRequestAttributes() != null) {
+ varsContext.put(KEY_REQUEST_ATTRIBUTES, tenantContext.getRequestAttributes());
+ }
+ if (tenantContext.getServerWebExchange() != null) {
+ varsContext.put(KEY_SERVER_WEB_EXCHANGE, tenantContext.getServerWebExchange());
+ }
+ }
+
+ @Override
+ public void clearVars() {
+ varsContext.clear();
+ }
+
+ // ==================== Document Context ====================
+
+ @Override
+ public void addDocumentChunks(List chunks) {
+ if (chunks == null || chunks.isEmpty()) {
+ return;
+ }
+ synchronized (documentChunks) {
+ for (DocumentChunk chunk : chunks) {
+ if (chunk == null || chunk.getChunkId() == null) {
+ continue;
+ }
+ // chunkId 去重
+ if (chunkIndex.putIfAbsent(chunk.getChunkId(), Boolean.TRUE) != null) {
+ log.debug("[Memory] 文档块已存在,跳过 chunkId={} fileName={}",
+ chunk.getChunkId(), chunk.getFileName());
+ continue;
+ }
+ documentChunks.add(chunk);
+ }
+ // 按 chunkIndex 字段排序,保证顺序稳定
+ documentChunks.sort((a, b) -> Integer.compare(a.getChunkIndex(), b.getChunkIndex()));
+ }
+ }
+
+ @Override
+ public List getDocumentChunks() {
+ synchronized (documentChunks) {
+ return new ArrayList<>(documentChunks);
+ }
+ }
+
+ @Override
+ public String getDocumentContext() {
+ List snapshot;
+ synchronized (documentChunks) {
+ if (documentChunks.isEmpty()) {
+ return "";
+ }
+ snapshot = new ArrayList<>(documentChunks);
+ }
+ StringBuilder sb = new StringBuilder();
+ for (DocumentChunk chunk : snapshot) {
+ String fileName = chunk.getFileName() != null ? chunk.getFileName() : chunk.getFileKey();
+ sb.append("=== 文件: ").append(fileName).append(" ===\n");
+ sb.append(chunk.getContent() != null ? chunk.getContent() : "");
+ sb.append("\n=== 文件结束: ").append(fileName).append(" ===\n\n");
+ }
+ return sb.toString();
+ }
+
+ @Override
+ public void clearDocuments() {
+ synchronized (documentChunks) {
+ documentChunks.clear();
+ chunkIndex.clear();
+ }
+ }
+
+ // ==================== 上下文拼接 ====================
+
+ @Override
+ public String getSystemContext() {
+ StringBuilder sb = new StringBuilder();
+
+ // 拼接 Vars Context(仅展示 sys.tenant.* 和非内部变量)
+ Map vars = getVarsContext();
+ String tenantContext = buildTenantSection(vars);
+ if (!tenantContext.isEmpty()) {
+ sb.append("【用户上下文】\n").append(tenantContext).append("\n");
+ }
+
+ String bizVars = buildBizVarsSection(vars);
+ if (!bizVars.isEmpty()) {
+ sb.append("【业务变量】\n").append(bizVars).append("\n");
+ }
+
+ // 拼接 Document Context
+ String docContext = getDocumentContext();
+ if (!docContext.isEmpty()) {
+ sb.append("【上传文件】\n").append(docContext);
+ }
+
+ return sb.toString();
+ }
+
+ /**
+ * 构建租户上下文段落
+ */
+ private String buildTenantSection(Map vars) {
+ Object tenantId = vars.get(KEY_TENANT_ID);
+ Object userId = vars.get(KEY_USER_ID);
+ if (tenantId == null && userId == null) {
+ return "";
+ }
+ StringBuilder sb = new StringBuilder();
+ if (tenantId != null) {
+ sb.append("租户: ").append(tenantId).append("\n");
+ }
+ if (userId != null) {
+ sb.append("用户: ").append(userId).append("\n");
+ }
+ return sb.toString();
+ }
+
+ /**
+ * 构建业务变量段落(过滤掉 sys.tenant.* 内部变量和 requestAttributes/exchange 等非文本对象)
+ */
+ private String buildBizVarsSection(Map vars) {
+ StringBuilder sb = new StringBuilder();
+ vars.forEach((key, value) -> {
+ // 跳过租户上下文相关 key(已在用户上下文段落展示)
+ if (key.startsWith(TENANT_KEY_PREFIX)) {
+ return;
+ }
+ // 跳过非文本对象(RequestAttributes / ServerWebExchange 等)
+ if (value == null || value.getClass().getName().startsWith("org.springframework.web")) {
+ return;
+ }
+ // 跳过 Class 对象
+ if (value instanceof Class) {
+ return;
+ }
+ sb.append(key).append(": ").append(value).append("\n");
+ });
+ return sb.toString();
+ }
+
+ @Override
+ public void clearAll() {
+ // 仅清空本模块管理的两段记忆;Spring AI 的 ChatMemory 不在此清理
+ clearVars();
+ clearDocuments();
+ }
+}
diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/InMemoryConversationMemoryStore.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/InMemoryConversationMemoryStore.java
new file mode 100644
index 00000000..4480d407
--- /dev/null
+++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/InMemoryConversationMemoryStore.java
@@ -0,0 +1,75 @@
+package cn.com.mfish.common.ai.memory;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.stream.Collectors;
+
+/**
+ * 基于 JVM 内存的 {@link ConversationMemoryStore} 默认实现
+ *
+ * 使用 {@link ConcurrentHashMap} 按 sessionId 缓存 {@link ConversationMemory} 实例。
+ * 实例首次访问时创建并缓存,后续访问返回同一实例(sessionId 级单例)。
+ *
+ *
+ * 限制:进程内存储,重启丢失;不支持分布式部署下的会话漂移。
+ * 后续可替换为 Redis 实现以支持多实例共享。
+ *
+ *
+ * 注意:本 Store 只管理 Vars Context 和 Document Context,不管理 Spring AI 的
+ * ChatMemory(对话历史)。会话结束时如需清空对话历史,业务方需另行调用
+ * {@code ChatMemory.clear(sessionId)}。
+ *
+ *
+ * @author: mfish
+ * @date: 2026/07/20
+ */
+public class InMemoryConversationMemoryStore implements ConversationMemoryStore {
+
+ private final ConcurrentMap store = new ConcurrentHashMap<>();
+
+ public InMemoryConversationMemoryStore() {
+ }
+
+ @Override
+ public ConversationMemory getOrCreate(String sessionId) {
+ Objects.requireNonNull(sessionId, "sessionId 不能为 null");
+ if (sessionId.isEmpty()) {
+ throw new IllegalArgumentException("sessionId 不能为空字符串");
+ }
+ return store.computeIfAbsent(sessionId, InMemoryConversationMemory::new);
+ }
+
+ @Override
+ public ConversationMemory get(String sessionId) {
+ if (sessionId == null || sessionId.isEmpty()) {
+ return null;
+ }
+ return store.get(sessionId);
+ }
+
+ @Override
+ public ConversationMemory remove(String sessionId) {
+ if (sessionId == null || sessionId.isEmpty()) {
+ return null;
+ }
+ ConversationMemory removed = store.remove(sessionId);
+ if (removed != null) {
+ removed.clearAll();
+ }
+ return removed;
+ }
+
+ @Override
+ public List listSessionIds() {
+ return store.keySet().stream()
+ .sorted()
+ .collect(Collectors.toList());
+ }
+
+ @Override
+ public int size() {
+ return store.size();
+ }
+}
diff --git a/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/MemoryAutoConfiguration.java b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/MemoryAutoConfiguration.java
new file mode 100644
index 00000000..06037812
--- /dev/null
+++ b/mf-common/mf-common-ai/src/main/java/cn/com/mfish/common/ai/memory/MemoryAutoConfiguration.java
@@ -0,0 +1,33 @@
+package cn.com.mfish.common.ai.memory;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Memory 模块自动配置
+ *
+ * 注册 {@link ConversationMemoryStore} 的默认实现 {@link InMemoryConversationMemoryStore}。
+ *
+ *
+ * 本模块只管理 Spring AI 管不到的两类上下文:Vars Context(变量/凭证)和
+ * Document Context(文件解析块)。对话历史(Short-term Memory)由 Spring AI 的
+ * {@code ChatMemory} + {@code MessageChatMemoryAdvisor} 自动管理,本模块不涉及。
+ *
+ *
+ * 业务方可通过自定义 {@link ConversationMemoryStore} Bean 覆盖默认实现,
+ * 例如替换为 Redis 版本以支持多实例部署。
+ *
+ *
+ * @author: mfish
+ * @date: 2026/07/20
+ */
+@Configuration
+public class MemoryAutoConfiguration {
+
+ @Bean
+ @ConditionalOnMissingBean
+ public ConversationMemoryStore conversationMemoryStore() {
+ return new InMemoryConversationMemoryStore();
+ }
+}
diff --git a/mf-common/mf-common-ai/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/mf-common/mf-common-ai/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
index f2170c30..83ecfe48 100644
--- a/mf-common/mf-common-ai/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
+++ b/mf-common/mf-common-ai/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -1 +1,3 @@
cn.com.mfish.common.ai.engine.ApiToolAutoConfiguration
+cn.com.mfish.common.ai.memory.MemoryAutoConfiguration
+cn.com.mfish.common.ai.capability.CapabilityAutoConfiguration