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

@@ -0,0 +1,151 @@
package tech.easyflow.agent.runtime;
import com.easyagents.agent.runtime.AgentDefinition;
import com.easyagents.agent.runtime.mcp.McpSpec;
import com.easyagents.agent.runtime.mcp.McpTransportType;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentToolBinding;
import tech.easyflow.agent.enums.AgentToolType;
import tech.easyflow.ai.entity.Mcp;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.entity.ModelProvider;
import tech.easyflow.ai.service.McpService;
import tech.easyflow.ai.service.ModelService;
import java.lang.reflect.Field;
import java.lang.reflect.Proxy;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
/**
* Agent MCP 运行时定义编译测试。
*/
public class AgentDefinitionCompilerMcpTest {
/**
* 验证 Agent 绑定 MCP 后会编译为 runtime 原生 MCP 声明,并按整个 MCP 暴露工具。
*
* @throws Exception 反射注入依赖失败时抛出
*/
@Test
public void compileShouldBuildWholeMcpSpecWithDynamicPrefixAndApproval() throws Exception {
BigInteger modelId = BigInteger.valueOf(10L);
BigInteger mcpId = BigInteger.valueOf(20L);
Model model = model(modelId);
Mcp mcp = mcp(mcpId);
AgentDefinitionCompiler compiler = new AgentDefinitionCompiler();
setField(compiler, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper());
setField(compiler, "modelService", modelService(model));
setField(compiler, "mcpService", mcpService(mcp));
Agent agent = agent(modelId, mcpId);
AgentRuntimeBundle bundle = compiler.compile(agent);
AgentDefinition definition = bundle.getDefinition();
Assert.assertTrue(definition.getToolSpecs().isEmpty());
Assert.assertTrue(bundle.getToolInvokers().isEmpty());
Assert.assertEquals(1, definition.getMcpSpecs().size());
McpSpec spec = definition.getMcpSpecs().get(0);
Assert.assertEquals("mcp_20", spec.getName());
Assert.assertEquals(McpTransportType.STDIO, spec.getTransportType());
Assert.assertEquals("npx", spec.getCommand());
Assert.assertEquals(List.of("-y", "@modelcontextprotocol/server-everything"), spec.getArgs());
Assert.assertTrue(spec.isApprovalRequired());
Assert.assertEquals("mcp_20_", spec.getToolNamePrefix());
Assert.assertTrue(spec.getToolAliases().isEmpty());
Assert.assertTrue(spec.getEnableTools().isEmpty());
Assert.assertEquals(AgentToolType.MCP.name(), spec.getMetadata().get("toolType"));
Assert.assertEquals(String.valueOf(mcpId), spec.getMetadata().get("mcpId"));
Assert.assertEquals("everything", spec.getMetadata().get("serverName"));
Assert.assertTrue(spec.getToolApprovalRequests().isEmpty());
Assert.assertEquals("确认调用 MCP 工具?", spec.getApprovalRequest().getApprovalPrompt());
}
private Agent agent(BigInteger modelId, BigInteger mcpId) {
AgentToolBinding binding = new AgentToolBinding();
binding.setToolType(AgentToolType.MCP.name());
binding.setTargetId(mcpId);
binding.setEnabled(true);
binding.setHitlEnabled(true);
binding.setHitlConfigJson(Map.of("prompt", "确认调用 MCP 工具?"));
Agent agent = new Agent();
agent.setId(BigInteger.valueOf(1L));
agent.setName("MCP Agent");
agent.setModelId(modelId);
agent.setToolBindings(List.of(binding));
return agent;
}
private Model model(BigInteger modelId) {
ModelProvider provider = new ModelProvider();
provider.setProviderType("openai");
provider.setProviderName("OpenAI");
Model model = new Model();
model.setId(modelId);
model.setModelProvider(provider);
model.setModelName("gpt-test");
model.setEndpoint("https://example.com");
model.setRequestPath("/v1/chat/completions");
model.setApiKey("test-key");
return model;
}
private Mcp mcp(BigInteger mcpId) {
Mcp mcp = new Mcp();
mcp.setId(mcpId);
mcp.setTitle("Everything");
mcp.setDescription("MCP Everything");
mcp.setApprovalRequired(true);
mcp.setStatus(true);
mcp.setConfigJson("""
{
"mcpServers": {
"everything": {
"transport": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-everything"]
}
}
}
""");
return mcp;
}
private ModelService modelService(Model model) {
return (ModelService) Proxy.newProxyInstance(
ModelService.class.getClassLoader(),
new Class<?>[]{ModelService.class},
(proxy, method, args) -> "getModelInstance".equals(method.getName()) ? model : defaultValue(method.getReturnType()));
}
private McpService mcpService(Mcp mcp) {
return (McpService) Proxy.newProxyInstance(
McpService.class.getClassLoader(),
new Class<?>[]{McpService.class},
(proxy, method, args) -> "getById".equals(method.getName()) ? mcp : defaultValue(method.getReturnType()));
}
private Object defaultValue(Class<?> type) {
if (type == boolean.class) {
return false;
}
if (type == int.class || type == long.class || type == short.class || type == byte.class) {
return 0;
}
if (type == double.class || type == float.class) {
return 0D;
}
return null;
}
private void setField(Object target, String fieldName, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
}

View File

@@ -1,15 +1,22 @@
package tech.easyflow.agent.runtime;
import com.easyagents.agent.runtime.AgentInitRequest;
import com.easyagents.agent.runtime.AgentRuntime;
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
import com.easyagents.agent.runtime.message.AgentKnowledgeReference;
import com.easyagents.agent.runtime.message.AgentMessage;
import com.easyagents.agent.runtime.message.AgentMessageRole;
import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
import com.easyagents.agent.runtime.persistence.session.memory.InMemoryAgentSessionStore;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.agent.entity.AgentHitlPending;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import tech.easyflow.agent.entity.AgentToolBinding;
import tech.easyflow.agent.runtime.event.AgentRunEventRecorder;
import tech.easyflow.agent.runtime.hitl.AgentHitlPendingService;
import tech.easyflow.agent.runtime.lock.AgentRunLock;
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
import tech.easyflow.common.entity.LoginAccount;
@@ -402,14 +409,150 @@ public class AgentRunServiceDraftAndHitlTest {
Exception thrown = Assert.assertThrows(Exception.class, () -> invoke(service, "run",
new Class<?>[]{Agent.class, String.class, String.class, String.class, String.class,
String.class, ChatRuntimeContext.class, boolean.class},
agent, "你好", "request-lock", "trace-lock", "session-lock", "AGENT", context, true));
String.class, ChatRuntimeContext.class, boolean.class, AgentSessionStore.class},
agent, "你好", "request-lock", "trace-lock", "session-lock", "AGENT", context, true,
new InMemoryAgentSessionStore()));
Assert.assertTrue(rootCause(thrown) instanceof BusinessException);
Assert.assertEquals(0, chatRuntimeManager.prepareSessionCount);
Assert.assertEquals(0, chatRuntimeManager.recordUserMessageCount);
}
/**
* 验证草稿运行会使用独立 session store且不会绑定 MySQL session 元信息。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void startRuntimeShouldUseDraftSessionStoreWithoutBindingMysqlSession() throws Exception {
AgentRunService service = new AgentRunService();
RecordingAgentDefinitionCompiler compiler = new RecordingAgentDefinitionCompiler();
RecordingAgentRuntime runtime = new RecordingAgentRuntime();
RecordingAgentRuntimeFactory runtimeFactory = new RecordingAgentRuntimeFactory(runtime);
AgentSessionStore draftStore = new InMemoryAgentSessionStore();
setField(service, "agentDefinitionCompiler", compiler);
setField(service, "agentRuntimeFactory", runtimeFactory);
setField(service, "agentRunRegistry", new AgentRunRegistry());
Agent agent = new Agent();
agent.setId(BigInteger.valueOf(100));
invoke(service, "startRuntime",
new Class<?>[]{Agent.class, String.class, String.class, String.class, String.class, String.class,
ChatRuntimeContext.class, ChatSseEmitter.class, boolean.class, AgentSessionStore.class,
AgentRunLock.Handle.class},
agent, "你好", "request-draft", "trace-draft", "agent-draft-100", "AGENT_DRAFT",
chatContext(), new RecordingChatSseEmitter(), false, draftStore, null);
Assert.assertSame(draftStore, runtime.initRequest.getSessionStore());
}
/**
* 验证草稿事件不会写运行事件表,正式事件仍会记录。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void handleRuntimeEventShouldOnlyPersistEventsForFormalChat() throws Exception {
AgentRunService service = new AgentRunService();
setField(service, "agentRunRegistry", new AgentRunRegistry());
RecordingAgentRunEventRecorder recorder = new RecordingAgentRunEventRecorder();
setField(service, "agentRunEventRecorder", recorder);
AgentRuntimeEvent draftEvent = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_CALL);
draftEvent.getPayload().put("toolName", "search");
invoke(service, "handleRuntimeEvent",
runtimeEventParameterTypes(),
draftEvent, "request-draft", new RecordingChatSseEmitter(), new StringBuilder(),
new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), false);
Assert.assertEquals(0, recorder.recordCount);
AgentRuntimeEvent formalEvent = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_CALL);
formalEvent.getPayload().put("toolName", "search");
invoke(service, "handleRuntimeEvent",
runtimeEventParameterTypes(),
formalEvent, "request-formal", new RecordingChatSseEmitter(), new StringBuilder(),
new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), true);
Assert.assertEquals(1, recorder.recordCount);
}
/**
* 验证草稿工具审批只注册内存恢复令牌,不写 HITL pending 表。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void draftToolApprovalShouldNotPersistPending() throws Exception {
AgentRunService service = new AgentRunService();
AgentRunRegistry registry = new AgentRunRegistry();
RecordingAgentHitlPendingService pendingService = new RecordingAgentHitlPendingService();
setField(service, "agentRunRegistry", registry);
setField(service, "agentHitlPendingService", pendingService);
registry.register(runContext("request-draft", "agent-draft-tool", false));
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED);
event.getPayload().put("resumeToken", "token-draft");
invoke(service, "handleRuntimeEvent",
runtimeEventParameterTypes(),
event, "request-draft", new RecordingChatSseEmitter(), new StringBuilder(),
new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), false);
Assert.assertTrue(registry.containsResumeTarget("request-draft", "token-draft"));
Assert.assertEquals(0, pendingService.recordApprovalRequiredCount);
}
/**
* 验证草稿审批恢复不执行 pending 表消费,正式审批仍执行。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void approveShouldSkipPendingConsumeOnlyForDraftRun() throws Exception {
AgentRunService service = new AgentRunService();
AgentRunRegistry registry = new AgentRunRegistry();
RecordingAgentHitlPendingService pendingService = new RecordingAgentHitlPendingService();
setField(service, "agentRunRegistry", registry);
setField(service, "agentHitlPendingService", pendingService);
registry.register(runContext("request-draft-approve", "agent-draft-approve", false));
registry.registerResumeToken("request-draft-approve", "token-draft-approve");
invoke(service, "approveRuntime",
new Class<?>[]{String.class, String.class, BigInteger.class, String.class},
"request-draft-approve", "token-draft-approve", BigInteger.ONE, "1");
Assert.assertEquals(0, pendingService.approveCount);
registry.register(runContext("request-formal-approve", "session-formal-approve", true));
registry.registerResumeToken("request-formal-approve", "token-formal-approve");
invoke(service, "approveRuntime",
new Class<?>[]{String.class, String.class, BigInteger.class, String.class},
"request-formal-approve", "token-formal-approve", BigInteger.ONE, "1");
Assert.assertEquals(1, pendingService.approveCount);
}
/**
* 验证清理草稿会话只清草稿 store不触碰 MySQL pending 清理。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void clearDraftSessionShouldOnlyDeleteDraftStore() throws Exception {
AgentRunService service = new AgentRunService();
RecordingAgentHitlPendingService pendingService = new RecordingAgentHitlPendingService();
RecordingAgentSessionStore draftStore = new RecordingAgentSessionStore();
setField(service, "agentRunRegistry", new AgentRunRegistry());
setField(service, "agentHitlPendingService", pendingService);
setField(service, "draftAgentSessionStore", draftStore);
invoke(service, "clearDraftSessionInternal",
new Class<?>[]{String.class, String.class}, "agent-draft-clear", "1");
Assert.assertEquals("agent-draft-clear", draftStore.deletedSessionKey);
Assert.assertEquals(0, pendingService.deleteByRuntimeSessionIdCount);
}
/**
* 验证正式聊天会在会话准备完成后向前端返回真实会话 ID。
*
@@ -530,6 +673,28 @@ public class AgentRunServiceDraftAndHitlTest {
ChatRuntimeContext.class, AtomicBoolean.class, boolean.class};
}
private AgentRunRegistry.AgentRunContext runContext(String requestId, String sessionId, boolean persistChatlog) {
return new AgentRunRegistry.AgentRunContext(
requestId,
sessionId,
new RecordingAgentRuntime(),
new RecordingChatSseEmitter(),
chatContext(),
new StringBuilder(),
new ChatAssistantAccumulator(),
new AtomicBoolean(false),
persistChatlog,
new AgentRunRegistry.RunOwner("agent-1", sessionId, "1"),
null,
event -> {
},
error -> {
},
() -> {
}
);
}
private ChatRuntimeContext chatContext() {
ChatRuntimeContext context = new ChatRuntimeContext();
context.setAssistantId(BigInteger.valueOf(100));
@@ -598,6 +763,148 @@ public class AgentRunServiceDraftAndHitlTest {
}
}
private static class RecordingAgentRuntime implements AgentRuntime {
private AgentInitRequest initRequest;
private int resumeCount;
@Override
public void init(AgentInitRequest request) {
initRequest = request;
}
@Override
public reactor.core.publisher.Flux<AgentRuntimeEvent> stream(AgentMessage userMessage) {
return reactor.core.publisher.Flux.empty();
}
@Override
public reactor.core.publisher.Flux<AgentRuntimeEvent> resume(com.easyagents.agent.runtime.AgentResumeRequest request) {
resumeCount++;
return reactor.core.publisher.Flux.empty();
}
}
private static class RecordingAgentRuntimeFactory implements AgentRuntimeFactory {
private final AgentRuntime runtime;
private RecordingAgentRuntimeFactory(AgentRuntime runtime) {
this.runtime = runtime;
}
@Override
public AgentRuntime create() {
return runtime;
}
}
private static class RecordingAgentDefinitionCompiler extends AgentDefinitionCompiler {
@Override
public AgentRuntimeBundle compile(Agent agent) {
AgentRuntimeBundle bundle = new AgentRuntimeBundle();
bundle.setDefinition(new com.easyagents.agent.runtime.AgentDefinition());
return bundle;
}
}
private static class RecordingAgentRunEventRecorder implements AgentRunEventRecorder {
private int recordCount;
@Override
public void record(String requestId, ChatRuntimeContext chatContext, AgentRuntimeEvent event) {
recordCount++;
}
}
private static class RecordingAgentHitlPendingService implements AgentHitlPendingService {
private int recordApprovalRequiredCount;
private int approveCount;
private int rejectCount;
private int cancelByRequestIdCount;
private int deleteByRuntimeSessionIdCount;
@Override
public void recordApprovalRequired(String requestId, ChatRuntimeContext chatContext, AgentRuntimeEvent event) {
recordApprovalRequiredCount++;
}
@Override
public AgentHitlPending approve(String resumeToken, BigInteger operatorId) {
approveCount++;
return new AgentHitlPending();
}
@Override
public AgentHitlPending reject(String resumeToken, BigInteger operatorId, String reason) {
rejectCount++;
return new AgentHitlPending();
}
@Override
public void cancelByRequestId(String requestId, String reason) {
cancelByRequestIdCount++;
}
@Override
public void deleteByChatSessionId(BigInteger chatSessionId) {
// 测试桩无需处理。
}
@Override
public void deleteByRuntimeSessionId(String runtimeSessionId) {
deleteByRuntimeSessionIdCount++;
}
@Override
public List<AgentHitlPending> expirePending(int limit) {
return List.of();
}
}
private static class RecordingAgentSessionStore implements AgentSessionStore {
private String deletedSessionKey;
@Override
public void save(String sessionKey, String name, io.agentscope.core.state.State state) {
// 测试桩无需处理。
}
@Override
public void saveList(String sessionKey, String name, List<? extends io.agentscope.core.state.State> states) {
// 测试桩无需处理。
}
@Override
public <T extends io.agentscope.core.state.State> java.util.Optional<T> get(String sessionKey, String name, Class<T> type) {
return java.util.Optional.empty();
}
@Override
public <T extends io.agentscope.core.state.State> List<T> getList(String sessionKey, String name, Class<T> itemType) {
return List.of();
}
@Override
public boolean exists(String sessionKey) {
return false;
}
@Override
public void delete(String sessionKey) {
deletedSessionKey = sessionKey;
}
@Override
public java.util.Set<String> listSessionKeys() {
return java.util.Set.of();
}
}
/**
* 记录 chatlog 写入动作的测试桩。
*/