feat: 对接 Agent MCP 能力

- 新增 runtime MCP 声明、ClientFactory、Toolkit 适配与工具别名映射

- 增加 MCP 环境检测与 stdio 环境变量透传

- 补齐 MCP 工具事件、审批与生命周期释放测试
This commit is contained in:
2026-05-29 11:08:39 +08:00
parent 2bc525c16e
commit 43f45956ff
24 changed files with 2559 additions and 55 deletions

View File

@@ -39,9 +39,12 @@ import io.agentscope.core.model.ToolSchema;
import io.agentscope.core.skill.SkillBox;
import io.agentscope.core.tool.AgentTool;
import io.agentscope.core.tool.Toolkit;
import io.agentscope.core.tool.mcp.McpClientWrapper;
import io.modelcontextprotocol.spec.McpSchema;
import org.junit.Assert;
import org.junit.Test;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.publisher.Sinks;
import java.lang.reflect.Field;
@@ -255,6 +258,42 @@ public class AgentScopeStatefulRuntimeTest {
.anyMatch(PendingToolRecoveryHook.class::isInstance));
}
@Test
public void shouldCloseMcpClientsWhenRuntimeCloses() throws Exception {
AgentScopeReActRuntime runtime = fakeRuntime();
FakeMcpClientWrapper client = new FakeMcpClientWrapper("mcp-close");
Field mcpClientsField = AgentScopeReActRuntime.class.getDeclaredField("mcpClients");
mcpClientsField.setAccessible(true);
@SuppressWarnings("unchecked")
List<McpClientWrapper> clients = (List<McpClientWrapper>) mcpClientsField.get(runtime);
clients.add(client);
runtime.close();
Assert.assertTrue(client.closed.get());
Assert.assertTrue(clients.isEmpty());
}
@Test
public void shouldCloseMcpClientsEvenWhenRuntimeIsStreaming() throws Exception {
AgentScopeReActRuntime runtime = fakeRuntime();
FakeMcpClientWrapper client = new FakeMcpClientWrapper("mcp-close-streaming");
Field mcpClientsField = AgentScopeReActRuntime.class.getDeclaredField("mcpClients");
mcpClientsField.setAccessible(true);
@SuppressWarnings("unchecked")
List<McpClientWrapper> clients = (List<McpClientWrapper>) mcpClientsField.get(runtime);
clients.add(client);
Field runningField = AgentScopeReActRuntime.class.getDeclaredField("running");
runningField.setAccessible(true);
AtomicBoolean running = (AtomicBoolean) runningField.get(runtime);
running.set(true);
runtime.close();
Assert.assertTrue(client.closed.get());
Assert.assertTrue(clients.isEmpty());
}
@Test
public void shouldEmitAutoContextCompressionEventsFromInterceptor() throws Exception {
AgentInitRequest request = initRequest();
@@ -403,7 +442,11 @@ public class AgentScopeStatefulRuntimeTest {
AgentRuntimeExecutionContext context = executionContext();
Sinks.Many<AgentRuntimeEvent> sink = Sinks.many().replay().all();
AgentRuntimeEventBridge bridge = AgentRuntimeEventBridge.fixed(context, sink);
ToolExecutionObserver observer = new ToolExecutionObserver(bridge);
AgentToolSpec toolSpec = new AgentToolSpec();
toolSpec.setName("search");
toolSpec.getMetadata().put("toolDisplayName", "Search Tool");
toolSpec.getMetadata().put("rawMcpToolName", "search");
ToolExecutionObserver observer = new ToolExecutionObserver(bridge, null, List.of(toolSpec));
ReActAgent agent = initializedAgent();
Toolkit toolkit = agent.getToolkit();
ToolUseBlock toolUse = ToolUseBlock.builder()
@@ -421,9 +464,12 @@ public class AgentScopeStatefulRuntimeTest {
Assert.assertEquals(AgentRuntimeEventType.TOOL_CALL, events.get(0).getEventType());
Assert.assertEquals("RUNNING", events.get(0).getPayload().get("status"));
Assert.assertEquals("PRE_ACTING", events.get(0).getPayload().get("phase"));
Assert.assertEquals("Search Tool", events.get(0).getPayload().get("toolDisplayName"));
Assert.assertEquals("search", events.get(0).getPayload().get("rawMcpToolName"));
Assert.assertEquals(AgentRuntimeEventType.TOOL_RESULT, events.get(1).getEventType());
Assert.assertEquals("SUCCESS", events.get(1).getPayload().get("status"));
Assert.assertEquals("POST_ACTING", events.get(1).getPayload().get("phase"));
Assert.assertEquals("Search Tool", events.get(1).getPayload().get("toolDisplayName"));
}
@Test
@@ -712,6 +758,8 @@ public class AgentScopeStatefulRuntimeTest {
toolSpec.setDescription("search");
toolSpec.setApprovalRequired(true);
toolSpec.getApprovalRequest().setApprovalPrompt("Approve search?");
toolSpec.getMetadata().put("toolDisplayName", "MCP Search - search");
toolSpec.getMetadata().put("rawMcpToolName", "search");
request.getAgentDefinition().setToolSpecs(List.of(toolSpec));
AtomicBoolean invoked = new AtomicBoolean(false);
request.setToolInvokers(Map.of("search", (arguments, context) -> {
@@ -736,6 +784,12 @@ public class AgentScopeStatefulRuntimeTest {
Assert.assertFalse(invoked.get());
Assert.assertTrue(events.stream()
.anyMatch(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED));
AgentRuntimeEvent approval = events.stream()
.filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED)
.findFirst()
.orElseThrow();
Assert.assertEquals("MCP Search - search", approval.getPayload().get("toolDisplayName"));
Assert.assertEquals("search", approval.getPayload().get("rawMcpToolName"));
AgentRuntimeEvent suspended = events.stream()
.filter(event -> event.getEventType() == AgentRuntimeEventType.SUSPENDED)
.findFirst()
@@ -1117,6 +1171,37 @@ public class AgentScopeStatefulRuntimeTest {
}
}
private static class FakeMcpClientWrapper extends McpClientWrapper {
private final AtomicBoolean closed = new AtomicBoolean(false);
private FakeMcpClientWrapper(String name) {
super(name);
}
@Override
public Mono<Void> initialize() {
initialized = true;
return Mono.empty();
}
@Override
public Mono<List<McpSchema.Tool>> listTools() {
return Mono.just(List.of());
}
@Override
public Mono<McpSchema.CallToolResult> callTool(String toolName, Map<String, Object> arguments) {
return Mono.just(new McpSchema.CallToolResult(List.of(), false));
}
@Override
public void close() {
closed.set(true);
initialized = false;
}
}
private AgentRuntimeExecutionContext executionContext() {
AgentRuntimeExecutionContext context = new AgentRuntimeExecutionContext();
AgentDefinition definition = new AgentDefinition();

View File

@@ -0,0 +1,77 @@
package com.easyagents.agent.runtime.mcp;
import com.easyagents.agent.runtime.AgentRuntimeException;
import com.easyagents.agent.runtime.tool.AgentToolSpec;
import com.easyagents.agent.runtime.tool.operate.AgentOperateToolAdapter;
import com.easyagents.agent.runtime.tool.operate.AgentOperateToolSpec;
import com.easyagents.agent.runtime.tool.operate.AgentOperateToolType;
import org.junit.Test;
import java.util.List;
import java.util.Map;
/**
* 测试 MCP 声明校验器。
*/
public class McpSpecValidatorTest {
@Test(expected = AgentRuntimeException.class)
public void shouldRejectMissingStdioCommand() {
McpSpec spec = spec(McpTransportType.STDIO);
McpSpecValidator.validateConnection(spec);
}
@Test(expected = AgentRuntimeException.class)
public void shouldRejectMissingSseUrl() {
McpSpec spec = spec(McpTransportType.SSE);
McpSpecValidator.validateConnection(spec);
}
@Test(expected = AgentRuntimeException.class)
public void shouldRejectMissingHttpUrl() {
McpSpec spec = spec(McpTransportType.HTTP);
McpSpecValidator.validateConnection(spec);
}
@Test(expected = AgentRuntimeException.class)
public void shouldRejectMcpToolNameConflictWithBusinessTool() {
AgentToolSpec businessTool = toolSpec("search");
AgentToolSpec mcpTool = toolSpec("search");
McpSpecValidator.validateToolConflicts(List.of(businessTool), List.of(mcpTool), List.of());
}
@Test(expected = AgentRuntimeException.class)
public void shouldRejectMcpToolNameConflictWithOperateTool() {
AgentToolSpec mcpTool = toolSpec(AgentOperateToolAdapter.EXECUTE_SHELL_COMMAND_TOOL);
AgentOperateToolSpec operateTool = new AgentOperateToolSpec();
operateTool.setType(AgentOperateToolType.SHELL);
McpSpecValidator.validateToolConflicts(List.of(), List.of(mcpTool), List.of(operateTool));
}
@Test(expected = AgentRuntimeException.class)
public void shouldRejectDuplicatedRuntimeToolAliases() {
McpSpec spec = spec(McpTransportType.STDIO);
spec.setCommand("node");
spec.setToolAliases(Map.of("search", "mcp_1_tool", "search.v2", "mcp_1_tool"));
McpSpecValidator.validateConnection(spec);
}
private McpSpec spec(McpTransportType type) {
McpSpec spec = new McpSpec();
spec.setName("mcp");
spec.setTransportType(type);
return spec;
}
private AgentToolSpec toolSpec(String name) {
AgentToolSpec spec = new AgentToolSpec();
spec.setName(name);
return spec;
}
}

View File

@@ -0,0 +1,233 @@
package com.easyagents.agent.runtime.mcp;
import com.easyagents.agent.runtime.AgentRuntimeException;
import com.easyagents.agent.runtime.tool.AgentToolSpec;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.tool.Toolkit;
import io.agentscope.core.tool.mcp.McpClientWrapper;
import io.modelcontextprotocol.spec.McpSchema;
import org.junit.Assert;
import org.junit.Test;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
/**
* 测试 MCP Toolkit 适配器。
*/
public class McpToolkitAdapterTest {
@Test
public void shouldRegisterEnabledMcpToolsAndBuildRuntimeToolSpecs() {
FakeMcpClientWrapper client = new FakeMcpClientWrapper("demo",
List.of(tool("search"), tool("write_file")));
McpToolkitAdapter adapter = new McpToolkitAdapter(new FakeMcpClientFactory(client));
McpSpec spec = stdioSpec();
spec.setEnableTools(List.of("search"));
spec.setApprovalRequired(true);
spec.setMetadata(Map.of("owner", "runtime"));
Toolkit toolkit = new Toolkit();
McpRegistration registration = adapter.register(List.of(spec), toolkit);
Assert.assertEquals(1, registration.getClients().size());
Assert.assertEquals(1, registration.getToolSpecs().size());
Assert.assertNotNull(toolkit.getTool("search"));
Assert.assertNull(toolkit.getTool("write_file"));
AgentToolSpec toolSpec = registration.getToolSpecs().get(0);
Assert.assertEquals("search", toolSpec.getName());
Assert.assertTrue(toolSpec.isApprovalRequired());
Assert.assertEquals("MCP", toolSpec.getMetadata().get("source"));
Assert.assertEquals("demo", toolSpec.getMetadata().get("mcpName"));
Assert.assertEquals("stdio", toolSpec.getMetadata().get("transportType"));
Assert.assertEquals("runtime", toolSpec.getMetadata().get("owner"));
}
@Test
public void shouldFilterSensitiveMetadataKeysFromRuntimeToolSpec() {
FakeMcpClientWrapper client = new FakeMcpClientWrapper("demo", List.of(tool("search")));
McpToolkitAdapter adapter = new McpToolkitAdapter(new FakeMcpClientFactory(client));
McpSpec spec = stdioSpec();
spec.setMetadata(Map.of(
"owner", "runtime",
"apiKey", "secret-key",
"Authorization", "Bearer secret-token",
"password", "secret-password"));
McpRegistration registration = adapter.register(List.of(spec), new Toolkit());
Map<String, Object> metadata = registration.getToolSpecs().get(0).getMetadata();
Assert.assertEquals("runtime", metadata.get("owner"));
Assert.assertFalse(metadata.containsKey("apiKey"));
Assert.assertFalse(metadata.containsKey("Authorization"));
Assert.assertFalse(metadata.containsKey("password"));
}
@Test
public void shouldRegisterAliasedMcpToolAndCallRawToolName() {
FakeMcpClientWrapper client = new FakeMcpClientWrapper("demo", List.of(tool("search")));
McpToolkitAdapter adapter = new McpToolkitAdapter(new FakeMcpClientFactory(client));
McpSpec spec = stdioSpec();
spec.setEnableTools(List.of("mcp_1_search"));
spec.setToolAliases(Map.of("search", "mcp_1_search"));
Toolkit toolkit = new Toolkit();
McpRegistration registration = adapter.register(List.of(spec), toolkit);
Assert.assertNotNull(toolkit.getTool("mcp_1_search"));
Assert.assertNull(toolkit.getTool("search"));
AgentToolSpec toolSpec = registration.getToolSpecs().get(0);
Assert.assertEquals("mcp_1_search", toolSpec.getName());
Assert.assertEquals("search", toolSpec.getMetadata().get("rawMcpToolName"));
registration.getClients().get(0).callTool("mcp_1_search", Map.of("q", "hello")).block();
Assert.assertEquals("search", client.lastCalledToolName.get());
}
/**
* 验证未配置工具白名单时会为 MCP 下全部工具动态生成运行时别名。
*/
@Test
public void shouldRegisterAllToolsWithDynamicPrefixAndCallRawToolName() {
FakeMcpClientWrapper client = new FakeMcpClientWrapper("demo",
List.of(tool("search.tool"), tool("write-file")));
McpToolkitAdapter adapter = new McpToolkitAdapter(new FakeMcpClientFactory(client));
McpSpec spec = stdioSpec();
spec.setDescription("Demo MCP");
spec.setToolNamePrefix("mcp_20_");
Toolkit toolkit = new Toolkit();
McpRegistration registration = adapter.register(List.of(spec), toolkit);
Assert.assertNotNull(toolkit.getTool("mcp_20_search_tool"));
Assert.assertNotNull(toolkit.getTool("mcp_20_write-file"));
Assert.assertNull(toolkit.getTool("search.tool"));
Assert.assertEquals(2, registration.getToolSpecs().size());
AgentToolSpec toolSpec = registration.getToolSpecs().get(0);
Assert.assertEquals("mcp_20_search_tool", toolSpec.getName());
Assert.assertEquals("search.tool", toolSpec.getMetadata().get("rawMcpToolName"));
Assert.assertEquals("Demo MCP - search.tool", toolSpec.getMetadata().get("toolDisplayName"));
registration.getClients().get(0).callTool("mcp_20_search_tool", Map.of("q", "hello")).block();
Assert.assertEquals("search.tool", client.lastCalledToolName.get());
}
/**
* 验证动态别名安全化后发生重名时会自动追加序号。
*/
@Test
public void shouldDeduplicateDynamicAliasesAfterSanitizingToolNames() {
FakeMcpClientWrapper client = new FakeMcpClientWrapper("demo",
List.of(tool("search.tool"), tool("search_tool")));
McpToolkitAdapter adapter = new McpToolkitAdapter(new FakeMcpClientFactory(client));
McpSpec spec = stdioSpec();
spec.setToolNamePrefix("mcp_20_");
Toolkit toolkit = new Toolkit();
McpRegistration registration = adapter.register(List.of(spec), toolkit);
Assert.assertNotNull(toolkit.getTool("mcp_20_search_tool"));
Assert.assertNotNull(toolkit.getTool("mcp_20_search_tool_2"));
Assert.assertEquals("search.tool", registration.getToolSpecs().get(0).getMetadata().get("rawMcpToolName"));
Assert.assertEquals("search_tool", registration.getToolSpecs().get(1).getMetadata().get("rawMcpToolName"));
registration.getClients().get(0).callTool("mcp_20_search_tool_2", Map.of("q", "hello")).block();
Assert.assertEquals("search_tool", client.lastCalledToolName.get());
}
@Test
public void shouldReturnEmptyToolSpecsWhenMcpServerHasNoTools() {
FakeMcpClientWrapper client = new FakeMcpClientWrapper("demo", List.of());
McpToolkitAdapter adapter = new McpToolkitAdapter(new FakeMcpClientFactory(client));
McpRegistration registration = adapter.register(List.of(stdioSpec()), new Toolkit());
Assert.assertEquals(1, registration.getClients().size());
Assert.assertTrue(registration.getToolSpecs().isEmpty());
}
@Test
public void shouldCloseCreatedClientWhenRegistrationFails() {
FakeMcpClientWrapper client = new FakeMcpClientWrapper("demo", List.of(tool("search")));
client.failOnListTools = true;
McpToolkitAdapter adapter = new McpToolkitAdapter(new FakeMcpClientFactory(client));
try {
adapter.register(List.of(stdioSpec()), new Toolkit());
Assert.fail("Expected MCP registration failure.");
} catch (AgentRuntimeException | IllegalStateException ignored) {
Assert.assertTrue(client.closed.get());
}
}
private McpSpec stdioSpec() {
McpSpec spec = new McpSpec();
spec.setName("demo");
spec.setTransportType(McpTransportType.STDIO);
spec.setCommand("node");
spec.setTimeout(Duration.ofSeconds(10));
spec.setInitializationTimeout(Duration.ofSeconds(3));
return spec;
}
private McpSchema.Tool tool(String name) {
McpSchema.JsonSchema schema = new McpSchema.JsonSchema("object",
Map.of("q", Map.of("type", "string", "description", "query")),
List.of("q"), null, null, null);
return new McpSchema.Tool(name, name, name + " description", schema, null, null, null);
}
private static class FakeMcpClientFactory extends McpClientFactory {
private final McpClientWrapper client;
private FakeMcpClientFactory(McpClientWrapper client) {
this.client = client;
}
@Override
public McpClientWrapper create(McpSpec spec) {
return client;
}
}
private static class FakeMcpClientWrapper extends McpClientWrapper {
private final List<McpSchema.Tool> tools;
private final AtomicBoolean closed = new AtomicBoolean(false);
private final AtomicReference<String> lastCalledToolName = new AtomicReference<>();
private boolean failOnListTools;
private FakeMcpClientWrapper(String name, List<McpSchema.Tool> tools) {
super(name);
this.tools = tools;
}
@Override
public Mono<Void> initialize() {
initialized = true;
return Mono.empty();
}
@Override
public Mono<List<McpSchema.Tool>> listTools() {
if (failOnListTools) {
return Mono.error(new IllegalStateException("list tools failed"));
}
return Mono.just(tools);
}
@Override
public Mono<McpSchema.CallToolResult> callTool(String toolName, Map<String, Object> arguments) {
lastCalledToolName.set(toolName);
return Mono.just(new McpSchema.CallToolResult(List.of(), false));
}
@Override
public void close() {
closed.set(true);
initialized = false;
}
}
}

View File

@@ -0,0 +1,27 @@
package com.easyagents.agent.runtime.mcp;
import com.easyagents.agent.runtime.AgentRuntimeException;
import org.junit.Assert;
import org.junit.Test;
/**
* 测试 MCP 连接方式解析。
*/
public class McpTransportTypeTest {
@Test
public void shouldParseCompatibleTransportValues() {
Assert.assertEquals(McpTransportType.STDIO, McpTransportType.from(null));
Assert.assertEquals(McpTransportType.STDIO, McpTransportType.from("stdio"));
Assert.assertEquals(McpTransportType.SSE, McpTransportType.from("http-sse"));
Assert.assertEquals(McpTransportType.SSE, McpTransportType.from("SSE"));
Assert.assertEquals(McpTransportType.HTTP, McpTransportType.from("http-stream"));
Assert.assertEquals(McpTransportType.HTTP, McpTransportType.from("HTTP"));
Assert.assertEquals(McpTransportType.HTTP, McpTransportType.from("streamable-http"));
}
@Test(expected = AgentRuntimeException.class)
public void shouldRejectUnsupportedTransportValue() {
McpTransportType.from("websocket");
}
}