feat: 完成 Agent MCP 对接

- 增加 MCP 连接类型、环境检测接口和容器运行环境支持

- 将 Agent 编排改为绑定整体 MCP 并编译为 runtime McpSpec

- 优化 MCP 工具展示、审批、草稿试运行和画布回显稳定性
This commit is contained in:
2026-05-29 11:09:21 +08:00
parent e39f7521e2
commit cc3bb9cff0
33 changed files with 2405 additions and 127 deletions

View File

@@ -37,6 +37,18 @@ public class McpBase extends DateEntity implements Serializable {
@Column(comment = "完整MCP配置JSON")
private String configJson;
/**
* MCP连接方式
*/
@Column(comment = "MCP连接方式")
private String transportType;
/**
* 是否启用工具调用审批
*/
@Column(comment = "是否启用工具调用审批")
private Boolean approvalRequired;
/**
* 部门ID
*/
@@ -111,6 +123,22 @@ public class McpBase extends DateEntity implements Serializable {
this.configJson = configJson;
}
public String getTransportType() {
return transportType;
}
public void setTransportType(String transportType) {
this.transportType = transportType;
}
public Boolean getApprovalRequired() {
return approvalRequired;
}
public void setApprovalRequired(Boolean approvalRequired) {
this.approvalRequired = approvalRequired;
}
public BigInteger getDeptId() {
return deptId;
}

View File

@@ -0,0 +1,66 @@
package tech.easyflow.ai.mcp;
import tech.easyflow.common.util.StringUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.util.Locale;
/**
* MCP 连接方式。
*/
public enum McpTransportType {
/**
* 标准输入输出进程通信。
*/
STDIO("stdio"),
/**
* HTTP SSE 通信。
*/
SSE("http-sse"),
/**
* Streamable HTTP 通信。
*/
HTTP("http-stream");
private final String value;
/**
* 创建 MCP 连接方式。
*
* @param value 配置值
*/
McpTransportType(String value) {
this.value = value;
}
/**
* 获取配置值。
*
* @return 配置值
*/
public String getValue() {
return value;
}
/**
* 解析连接方式。
*
* @param value 连接方式文本
* @return MCP 连接方式
*/
public static McpTransportType from(String value) {
if (StringUtil.noText(value)) {
return STDIO;
}
String normalized = value.trim().toLowerCase(Locale.ROOT);
return switch (normalized) {
case "stdio" -> STDIO;
case "sse", "http-sse" -> SSE;
case "http", "http-stream", "streamable-http" -> HTTP;
default -> throw new BusinessException("不支持的 MCP 连接方式: " + value);
};
}
}

View File

@@ -1,6 +1,7 @@
package tech.easyflow.ai.service;
import com.easyagents.core.model.chat.tool.Tool;
import com.easyagents.mcp.client.McpEnvironmentCheckResult;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.service.IService;
import tech.easyflow.ai.entity.BotMcp;
@@ -30,4 +31,6 @@ public interface McpService extends IService<Mcp> {
Mcp getMcpTools(String id);
Page<Mcp> pageTools(Page<Mcp> mcpPage);
McpEnvironmentCheckResult checkMcp(String configJson);
}

View File

@@ -3,6 +3,8 @@ package tech.easyflow.ai.service.impl;
import com.easyagents.core.model.chat.tool.Parameter;
import com.easyagents.core.model.chat.tool.Tool;
import com.easyagents.mcp.client.McpClientManager;
import com.easyagents.mcp.client.McpEnvironmentCheckResult;
import com.easyagents.mcp.client.McpEnvironmentChecker;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.mybatisflex.core.paginate.Page;
@@ -16,6 +18,7 @@ import tech.easyflow.ai.easyagents.tool.McpTool;
import tech.easyflow.ai.entity.BotMcp;
import tech.easyflow.ai.entity.Mcp;
import tech.easyflow.ai.mapper.McpMapper;
import tech.easyflow.ai.mcp.McpTransportType;
import tech.easyflow.ai.service.McpService;
import tech.easyflow.ai.utils.CommonFiledUtil;
import tech.easyflow.common.constant.enums.EnumRes;
@@ -37,7 +40,8 @@ import java.util.*;
@Service
public class McpServiceImpl extends ServiceImpl<McpMapper, Mcp> implements McpService {
private final McpClientManager mcpClientManager = McpClientManager.getInstance();
protected Logger Log = LoggerFactory.getLogger(DocumentServiceImpl.class);
private final McpEnvironmentChecker mcpEnvironmentChecker = new McpEnvironmentChecker();
protected Logger Log = LoggerFactory.getLogger(McpServiceImpl.class);
@Override
public Result<?> saveMcp(Mcp entity) {
@@ -49,6 +53,8 @@ public class McpServiceImpl extends ServiceImpl<McpMapper, Mcp> implements McpS
if (!StringUtil.hasText(serverName)) {
return Result.fail("未找到mcp服务名称", serverName);
}
entity.setTransportType(getFirstMcpTransportType(entity.getConfigJson()));
entity.setApprovalRequired(Boolean.TRUE.equals(entity.getApprovalRequired()));
try {
mcpClientManager.registerFromJson(entity.getConfigJson());
} catch (Exception e) {
@@ -79,6 +85,8 @@ public class McpServiceImpl extends ServiceImpl<McpMapper, Mcp> implements McpS
if (!StringUtil.hasText(serverName)) {
return Result.fail("未找到mcp服务名称", serverName);
}
entity.setTransportType(getFirstMcpTransportType(entity.getConfigJson()));
entity.setApprovalRequired(Boolean.TRUE.equals(entity.getApprovalRequired()));
if (entity.getStatus()) {
try {
mcpClientManager.registerFromJson(entity.getConfigJson());
@@ -121,6 +129,7 @@ public class McpServiceImpl extends ServiceImpl<McpMapper, Mcp> implements McpS
records.forEach(mcp -> {
boolean clientOnline = mcpClientManager.isClientOnline(getFirstMcpServerName(mcp.getConfigJson()));
mcp.setClientOnline(clientOnline);
mcp.setTransportType(resolveMcpTransportType(mcp));
}
);
page.getData().setRecords(records);
@@ -130,6 +139,9 @@ public class McpServiceImpl extends ServiceImpl<McpMapper, Mcp> implements McpS
@Override
public Mcp getMcpTools(String id) {
Mcp mcp = this.getById(id);
if (mcp != null) {
mcp.setTransportType(resolveMcpTransportType(mcp));
}
if (mcp != null && mcp.getStatus()) {
McpSyncClient mcpClient = getMcpClient(mcp, mcpClientManager);
List<McpSchema.Tool> tools = null;
@@ -209,9 +221,27 @@ public class McpServiceImpl extends ServiceImpl<McpMapper, Mcp> implements McpS
return firstServerName.orElse(null);
}
public static String getFirstMcpTransportType(String mcpJson) {
JSONObject rootJson = JSON.parseObject(mcpJson);
JSONObject mcpServersJson = rootJson.getJSONObject("mcpServers");
if (mcpServersJson == null || mcpServersJson.isEmpty()) {
return McpTransportType.STDIO.getValue();
}
Optional<String> firstServerName = mcpServersJson.keySet().stream().findFirst();
if (firstServerName.isEmpty()) {
return McpTransportType.STDIO.getValue();
}
JSONObject serverJson = mcpServersJson.getJSONObject(firstServerName.get());
if (serverJson == null) {
return McpTransportType.STDIO.getValue();
}
return McpTransportType.from(serverJson.getString("transport")).getValue();
}
@Override
public Page<Mcp> pageTools(Page<Mcp> page) {
page.getRecords().forEach(mcp -> {
mcp.setTransportType(resolveMcpTransportType(mcp));
// mcp 未启用,不查询工具
if (!mcp.getStatus()) {
return;
@@ -235,6 +265,11 @@ public class McpServiceImpl extends ServiceImpl<McpMapper, Mcp> implements McpS
return page;
}
@Override
public McpEnvironmentCheckResult checkMcp(String configJson) {
return mcpEnvironmentChecker.check(configJson);
}
private Result<?> validateMcpConfig(Mcp entity) {
if (entity == null || !StringUtil.hasText(entity.getConfigJson())) {
Log.error("MCP 配置不能为空");
@@ -242,4 +277,14 @@ public class McpServiceImpl extends ServiceImpl<McpMapper, Mcp> implements McpS
}
return Result.ok();
}
private String resolveMcpTransportType(Mcp mcp) {
if (mcp == null) {
return McpTransportType.STDIO.getValue();
}
if (StringUtil.hasText(mcp.getTransportType())) {
return McpTransportType.from(mcp.getTransportType()).getValue();
}
return getFirstMcpTransportType(mcp.getConfigJson());
}
}

View File

@@ -0,0 +1,58 @@
package tech.easyflow.ai.mcp;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.ai.service.impl.McpServiceImpl;
import tech.easyflow.common.web.exceptions.BusinessException;
/**
* {@link McpTransportType} 单元测试。
*/
public class McpTransportTypeTest {
/**
* 应兼容解析 MCP 配置中常见的连接方式文本。
*/
@Test
public void fromShouldParseSupportedTransportTypes() {
Assert.assertEquals(McpTransportType.STDIO, McpTransportType.from("stdio"));
Assert.assertEquals(McpTransportType.SSE, McpTransportType.from("sse"));
Assert.assertEquals(McpTransportType.SSE, McpTransportType.from("http-sse"));
Assert.assertEquals(McpTransportType.HTTP, McpTransportType.from("http"));
Assert.assertEquals(McpTransportType.HTTP, McpTransportType.from("http-stream"));
Assert.assertEquals(McpTransportType.HTTP, McpTransportType.from("streamable-http"));
Assert.assertEquals(McpTransportType.STDIO, McpTransportType.from(null));
Assert.assertEquals(McpTransportType.STDIO, McpTransportType.from(" "));
}
/**
* 应从 MCP 配置 JSON 中推断首个 server 的连接方式。
*/
@Test
public void getFirstMcpTransportTypeShouldInferFromConfigJson() {
Assert.assertEquals("stdio", McpServiceImpl.getFirstMcpTransportType("""
{"mcpServers":{"everything":{"command":"npx","args":["-y","@modelcontextprotocol/server-everything"]}}}
"""));
Assert.assertEquals("http-sse", McpServiceImpl.getFirstMcpTransportType("""
{"mcpServers":{"remote":{"transport":"http-sse","url":"http://127.0.0.1:3000/sse"}}}
"""));
Assert.assertEquals("http-stream", McpServiceImpl.getFirstMcpTransportType("""
{"mcpServers":{"remote":{"transport":"http-stream","url":"http://127.0.0.1:3000/mcp"}}}
"""));
}
/**
* 不支持的连接方式应直接失败,避免保存无法启动的 MCP 配置。
*/
@Test
public void getFirstMcpTransportTypeShouldRejectUnsupportedTransportType() {
try {
McpServiceImpl.getFirstMcpTransportType("""
{"mcpServers":{"remote":{"transport":"websocket","url":"ws://127.0.0.1:3000/mcp"}}}
""");
Assert.fail("expected BusinessException");
} catch (BusinessException exception) {
Assert.assertTrue(exception.getMessage().contains("不支持的 MCP 连接方式"));
}
}
}

View File

@@ -70,4 +70,25 @@ public class ChatAssistantAccumulatorTest {
Assert.assertEquals(1, secondToolCalls.size());
Assert.assertEquals("call-2", secondToolCalls.get(0).get("id"));
}
/**
* 工具展示名应进入展示链和 assistant toolCalls但不覆盖真实工具名。
*/
@Test
@SuppressWarnings("unchecked")
public void shouldKeepToolDisplayNameWithoutOverridingToolName() {
ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator();
accumulator.appendToolCall("call-1", "mcp_123_search", "知识库 MCP - search", "{\"q\":\"java\"}");
accumulator.appendToolResult("call-1", "mcp_123_search", "知识库 MCP - search", "{\"ok\":true}");
Map<String, Object> payload = accumulator.buildPayload(null);
List<Map<String, Object>> chains = (List<Map<String, Object>>) payload.get("chains");
List<Map<String, Object>> messageChain = (List<Map<String, Object>>) payload.get("messageChain");
List<Map<String, Object>> toolCalls = (List<Map<String, Object>>) messageChain.get(0).get("toolCalls");
Assert.assertEquals("mcp_123_search", chains.get(0).get("name"));
Assert.assertEquals("知识库 MCP - search", chains.get(0).get("toolDisplayName"));
Assert.assertEquals("mcp_123_search", toolCalls.get(0).get("name"));
Assert.assertEquals("知识库 MCP - search", toolCalls.get(0).get("toolDisplayName"));
}
}