From 43f45956ffc628783062e073d543e3f77a5e5b64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Fri, 29 May 2026 11:08:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AF=B9=E6=8E=A5=20Agent=20MCP=20?= =?UTF-8?q?=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 runtime MCP 声明、ClientFactory、Toolkit 适配与工具别名映射 - 增加 MCP 环境检测与 stdio 环境变量透传 - 补齐 MCP 工具事件、审批与生命周期释放测试 --- .../agent/runtime/AgentDefinition.java | 20 + .../agent/runtime/AgentRuntime.java | 6 + .../agentscope/AgentScopeReActRuntime.java | 75 ++- .../interceptor/ToolHitlInterceptor.java | 22 + .../event/observer/ToolExecutionObserver.java | 47 +- .../runtime/mcp/AliasedMcpClientWrapper.java | 172 +++++++ .../agent/runtime/mcp/McpClientFactory.java | 41 ++ .../agent/runtime/mcp/McpRegistration.java | 54 +++ .../easyagents/agent/runtime/mcp/McpSpec.java | 426 ++++++++++++++++++ .../agent/runtime/mcp/McpSpecValidator.java | 107 +++++ .../agent/runtime/mcp/McpToolkitAdapter.java | 241 ++++++++++ .../agent/runtime/mcp/McpTransportType.java | 58 +++ .../AgentScopeStatefulRuntimeTest.java | 87 +++- .../runtime/mcp/McpSpecValidatorTest.java | 77 ++++ .../runtime/mcp/McpToolkitAdapterTest.java | 233 ++++++++++ .../runtime/mcp/McpTransportTypeTest.java | 27 ++ .../easyagents/mcp/client/McpCheckItem.java | 107 +++++ .../easyagents/mcp/client/McpCheckStatus.java | 27 ++ .../mcp/client/McpEnvironmentCheckResult.java | 84 ++++ .../mcp/client/McpEnvironmentChecker.java | 318 +++++++++++++ .../mcp/client/McpServerCheckResult.java | 124 +++++ .../mcp/client/StdioTransportFactory.java | 45 +- .../mcp/client/McpEnvironmentCheckerTest.java | 158 +++++++ .../mcp/client/StdioTransportFactoryTest.java | 58 +++ 24 files changed, 2559 insertions(+), 55 deletions(-) create mode 100644 easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/AliasedMcpClientWrapper.java create mode 100644 easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpClientFactory.java create mode 100644 easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpRegistration.java create mode 100644 easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpSpec.java create mode 100644 easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpSpecValidator.java create mode 100644 easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpToolkitAdapter.java create mode 100644 easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpTransportType.java create mode 100644 easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/mcp/McpSpecValidatorTest.java create mode 100644 easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/mcp/McpToolkitAdapterTest.java create mode 100644 easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/mcp/McpTransportTypeTest.java create mode 100644 easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpCheckItem.java create mode 100644 easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpCheckStatus.java create mode 100644 easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpEnvironmentCheckResult.java create mode 100644 easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpEnvironmentChecker.java create mode 100644 easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpServerCheckResult.java create mode 100644 easy-agents-mcp/src/test/java/com/easyagents/mcp/client/McpEnvironmentCheckerTest.java create mode 100644 easy-agents-mcp/src/test/java/com/easyagents/mcp/client/StdioTransportFactoryTest.java diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/AgentDefinition.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/AgentDefinition.java index f087024..00484db 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/AgentDefinition.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/AgentDefinition.java @@ -2,6 +2,7 @@ package com.easyagents.agent.runtime; import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec; import com.easyagents.agent.runtime.memory.AgentMemoryPolicy; +import com.easyagents.agent.runtime.mcp.McpSpec; import com.easyagents.agent.runtime.model.AgentGenerationOptions; import com.easyagents.agent.runtime.model.AgentModelSpec; import com.easyagents.agent.runtime.persistence.AgentPersistencePolicy; @@ -28,6 +29,7 @@ public class AgentDefinition { private AgentGenerationOptions generationOptions = new AgentGenerationOptions(); private AgentExecutionOptions executionOptions = new AgentExecutionOptions(); private List toolSpecs = new ArrayList<>(); + private List mcpSpecs = new ArrayList<>(); private List operateToolSpecs = new ArrayList<>(); private List knowledgeSpecs = new ArrayList<>(); private AgentMemoryPolicy memoryPolicy = AgentMemoryPolicy.autoContext(); @@ -179,6 +181,24 @@ public class AgentDefinition { this.toolSpecs = toolSpecs == null ? new ArrayList<>() : new ArrayList<>(toolSpecs); } + /** + * 获取 MCP 声明。 + * + * @return MCP 声明 + */ + public List getMcpSpecs() { + return mcpSpecs; + } + + /** + * 设置 MCP 声明。 + * + * @param mcpSpecs MCP 声明 + */ + public void setMcpSpecs(List mcpSpecs) { + this.mcpSpecs = mcpSpecs == null ? new ArrayList<>() : new ArrayList<>(mcpSpecs); + } + /** * 获取操作类工具定义。 * diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/AgentRuntime.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/AgentRuntime.java index 22c4d7b..2eb4f41 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/AgentRuntime.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/AgentRuntime.java @@ -31,4 +31,10 @@ public interface AgentRuntime { * @return 运行事件流 */ Flux resume(AgentResumeRequest request); + + /** + * 关闭运行器并释放底层资源。 + */ + default void close() { + } } diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeReActRuntime.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeReActRuntime.java index 0133792..4d4497c 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeReActRuntime.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeReActRuntime.java @@ -14,6 +14,9 @@ import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec; import com.easyagents.agent.runtime.knowledge.citation.AgentKnowledgeCitationMatcher; import com.easyagents.agent.runtime.knowledge.citation.HeuristicKnowledgeCitationMatcher; import com.easyagents.agent.runtime.message.*; +import com.easyagents.agent.runtime.mcp.McpRegistration; +import com.easyagents.agent.runtime.mcp.McpSpecValidator; +import com.easyagents.agent.runtime.mcp.McpToolkitAdapter; import com.easyagents.agent.runtime.persistence.session.noop.NoopAgentSessionStore; import com.easyagents.agent.runtime.skill.AgentSkillBinding; import com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext; @@ -36,6 +39,7 @@ import io.agentscope.core.skill.SkillBox; import io.agentscope.core.state.SessionKey; import io.agentscope.core.tool.AgentTool; import io.agentscope.core.tool.Toolkit; +import io.agentscope.core.tool.mcp.McpClientWrapper; import reactor.core.publisher.Flux; import reactor.core.publisher.Sinks; @@ -57,6 +61,7 @@ public class AgentScopeReActRuntime implements AgentRuntime { private final AgentScopeSkillAdapter skillAdapter; private final AgentScopeMessageAdapter messageAdapter; private final AgentOperateToolAdapter operateToolAdapter = new AgentOperateToolAdapter(); + private final McpToolkitAdapter mcpToolkitAdapter = new McpToolkitAdapter(); private final AgentKnowledgeCitationMatcher citationMatcher = new HeuristicKnowledgeCitationMatcher(); private final AtomicBoolean initialized = new AtomicBoolean(false); private final AtomicBoolean running = new AtomicBoolean(false); @@ -68,6 +73,7 @@ public class AgentScopeReActRuntime implements AgentRuntime { private Session session; private SessionKey sessionKey; private ReActAgent agent; + private final List mcpClients = new CopyOnWriteArrayList<>(); /** * 使用默认适配器创建运行时。 @@ -112,15 +118,30 @@ public class AgentScopeReActRuntime implements AgentRuntime { if (!initialized.compareAndSet(false, true)) { throw new AgentRuntimeException("Agent runtime has already been initialized."); } - this.initRequest = request; - this.runtimeContext = createRuntimeContext(request); - this.skillContext = AgentSkillRuntimeContext.from(request.getAgentDefinition().getSkillBoxSpec()); - this.approvalCoordinator = AgentToolApprovalCoordinator.enabled(); - this.turnContextHolder = new AgentRuntimeTurnContextHolder(); - this.session = new AgentScopeSessionAdapter(request.getSessionStore()); - this.sessionKey = AgentScopeSessionAdapter.sessionKey(request.getSessionId()); - this.agent = buildAgent(runtimeContext); - this.agent.loadIfExists(session, sessionKey); + try { + this.initRequest = request; + this.runtimeContext = createRuntimeContext(request); + this.skillContext = AgentSkillRuntimeContext.from(request.getAgentDefinition().getSkillBoxSpec()); + this.approvalCoordinator = AgentToolApprovalCoordinator.enabled(); + this.turnContextHolder = new AgentRuntimeTurnContextHolder(); + this.session = new AgentScopeSessionAdapter(request.getSessionStore()); + this.sessionKey = AgentScopeSessionAdapter.sessionKey(request.getSessionId()); + this.agent = buildAgent(runtimeContext); + this.agent.loadIfExists(session, sessionKey); + } catch (RuntimeException error) { + closeMcpClients(); + initialized.set(false); + throw error; + } + } + + /** + * 关闭运行时并释放 MCP client。 + */ + @Override + public void close() { + closeMcpClients(); + initialized.set(false); } /** @@ -1054,12 +1075,14 @@ public class AgentScopeReActRuntime implements AgentRuntime { if (memory instanceof AutoContextMemory) { interceptors.add(new AutoContextInterceptor(eventBridge, memoryResult.getAutoContextConfig())); } + List runtimeToolSpecs = mergeToolSpecs(definition.getToolSpecs(), toolkitBuildResult.mcpToolSpecs(), + toolkitBuildResult.operateToolSpecs()); interceptors.add(new ToolHitlInterceptor(eventBridge, approvalCoordinator, - mergeToolSpecs(definition.getToolSpecs(), toolkitBuildResult.operateToolSpecs()))); + runtimeToolSpecs)); // 注册旁路事件监听器与主线路干预器。观察器只发旁路事件,不修改 AgentScope HookEvent。 List observers = new ArrayList<>(); observers.add(new SkillExecutionObserver(eventBridge, skillContext, skillBox)); - observers.add(new ToolExecutionObserver(eventBridge, skillContext)); + observers.add(new ToolExecutionObserver(eventBridge, skillContext, runtimeToolSpecs)); observers.add(new ReasoningLifecycleObserver(eventBridge)); observers.add(new AgentRuntimeErrorObserver(eventBridge)); AgentRuntimeObservationManager observationManager = @@ -1098,7 +1121,7 @@ public class AgentScopeReActRuntime implements AgentRuntime { Toolkit toolkit) { Map> skillTools = new LinkedHashMap<>(); if (!context.getAgentDefinition().getExecutionOptions().isToolCallingEnabled()) { - return new AgentScopeToolkitBuildResult(skillTools, List.of()); + return new AgentScopeToolkitBuildResult(skillTools, List.of(), List.of()); } for (AgentToolSpec toolSpec : context.getAgentDefinition().getToolSpecs()) { AgentToolInvoker invoker = context.getToolInvokers().get(toolSpec.getName()); @@ -1111,22 +1134,45 @@ public class AgentScopeReActRuntime implements AgentRuntime { skillTools.computeIfAbsent(skillBinding.getSkillId(), key -> new ArrayList<>()).add(agentTool); } } + McpRegistration mcpRegistration = mcpToolkitAdapter.register( + context.getAgentDefinition().getMcpSpecs(), toolkit); + mcpClients.addAll(mcpRegistration.getClients()); List operateToolSpecs = operateToolAdapter.register( context.getAgentDefinition().getOperateToolSpecs(), toolkit); - return new AgentScopeToolkitBuildResult(skillTools, operateToolSpecs); + McpSpecValidator.validateToolConflicts(context.getAgentDefinition().getToolSpecs(), + mcpRegistration.getToolSpecs(), context.getAgentDefinition().getOperateToolSpecs()); + return new AgentScopeToolkitBuildResult(skillTools, mcpRegistration.getToolSpecs(), operateToolSpecs); } - private List mergeToolSpecs(List toolSpecs, List operateToolSpecs) { + private List mergeToolSpecs(List toolSpecs, + List mcpToolSpecs, + List operateToolSpecs) { List merged = new ArrayList<>(); if (toolSpecs != null) { merged.addAll(toolSpecs); } + if (mcpToolSpecs != null) { + merged.addAll(mcpToolSpecs); + } if (operateToolSpecs != null) { merged.addAll(operateToolSpecs); } return merged; } + private void closeMcpClients() { + for (McpClientWrapper client : mcpClients) { + if (client == null) { + continue; + } + try { + client.close(); + } catch (Exception ignored) { + } + } + mcpClients.clear(); + } + /** * 构建聚合知识库的默认检索配置。 * @@ -1163,6 +1209,7 @@ public class AgentScopeReActRuntime implements AgentRuntime { } private record AgentScopeToolkitBuildResult(Map> skillTools, + List mcpToolSpecs, List operateToolSpecs) { } } diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/interceptor/ToolHitlInterceptor.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/interceptor/ToolHitlInterceptor.java index f13c747..4012f8e 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/interceptor/ToolHitlInterceptor.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/interceptor/ToolHitlInterceptor.java @@ -142,6 +142,9 @@ public class ToolHitlInterceptor implements AgentRuntimeInterceptor { Map metadata = approvalRequest == null ? new LinkedHashMap<>() : new LinkedHashMap<>(approvalRequest.getMetadata()); + if (toolSpec.getMetadata() != null && !toolSpec.getMetadata().isEmpty()) { + metadata.putAll(toolSpec.getMetadata()); + } metadata.put("phase", "POST_REASONING"); metadata.put("source", "TOOL_HITL_INTERCEPTOR"); metadata.putAll(toolUse.getMetadata() == null ? Map.of() : toolUse.getMetadata()); @@ -170,6 +173,7 @@ public class ToolHitlInterceptor implements AgentRuntimeInterceptor { event.getPayload().put("approvalPrompt", approvalPrompt(toolSpec.getApprovalRequest())); event.getPayload().put("approvalMetadata", pendingState.getMetadata()); event.getPayload().put("toolDescription", toolSpec.getDescription()); + enrichToolPayload(event.getPayload(), toolSpec); event.getMetadata().put("source", "TOOL_HITL_INTERCEPTOR"); event.getMetadata().put("phase", "POST_REASONING"); return event; @@ -187,6 +191,24 @@ public class ToolHitlInterceptor implements AgentRuntimeInterceptor { return payload; } + private void enrichToolPayload(Map payload, AgentToolSpec toolSpec) { + if (toolSpec == null || toolSpec.getMetadata() == null || toolSpec.getMetadata().isEmpty()) { + return; + } + Map metadata = toolSpec.getMetadata(); + putIfPresent(payload, metadata, "toolDisplayName"); + putIfPresent(payload, metadata, "rawMcpToolName"); + putIfPresent(payload, metadata, "mcpToolName"); + putIfPresent(payload, metadata, "mcpName"); + putIfPresent(payload, metadata, "mcpTitle"); + } + + private void putIfPresent(Map payload, Map metadata, String key) { + if (metadata.containsKey(key)) { + payload.put(key, metadata.get(key)); + } + } + private String approvalPrompt(AgentToolApprovalRequest approvalRequest) { if (approvalRequest != null && approvalRequest.getApprovalPrompt() != null diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/observer/ToolExecutionObserver.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/observer/ToolExecutionObserver.java index 1d6b466..14b39c7 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/observer/ToolExecutionObserver.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/observer/ToolExecutionObserver.java @@ -5,6 +5,7 @@ import com.easyagents.agent.runtime.event.AgentRuntimeEventBridge; import com.easyagents.agent.runtime.event.AgentRuntimeEventType; import com.easyagents.agent.runtime.event.AgentRuntimeObserver; import com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext; +import com.easyagents.agent.runtime.tool.AgentToolSpec; import io.agentscope.core.hook.HookEvent; import io.agentscope.core.hook.PostActingEvent; import io.agentscope.core.hook.PreActingEvent; @@ -15,7 +16,10 @@ import io.agentscope.core.message.ToolUseBlock; import reactor.core.publisher.Mono; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; /** * 监听 AgentScope 原生工具执行生命周期,并发射工具状态旁路事件。 @@ -28,6 +32,7 @@ public class ToolExecutionObserver implements AgentRuntimeObserver { private final AgentRuntimeEventBridge eventBridge; private final AgentSkillRuntimeContext skillContext; + private final Map toolSpecs; /** * 创建工具执行观察器。 @@ -35,7 +40,7 @@ public class ToolExecutionObserver implements AgentRuntimeObserver { * @param eventBridge 旁路事件桥 */ public ToolExecutionObserver(AgentRuntimeEventBridge eventBridge) { - this(eventBridge, null); + this(eventBridge, null, List.of()); } /** @@ -46,8 +51,25 @@ public class ToolExecutionObserver implements AgentRuntimeObserver { */ public ToolExecutionObserver(AgentRuntimeEventBridge eventBridge, AgentSkillRuntimeContext skillContext) { + this(eventBridge, skillContext, List.of()); + } + + /** + * 创建工具执行观察器。 + * + * @param eventBridge 旁路事件桥 + * @param skillContext Skill 上下文,用于跳过由 SkillExecutionObserver 处理的工具 + * @param toolSpecs 工具声明列表,用于补齐展示名称和治理元数据 + */ + public ToolExecutionObserver(AgentRuntimeEventBridge eventBridge, + AgentSkillRuntimeContext skillContext, + List toolSpecs) { this.eventBridge = eventBridge; this.skillContext = skillContext; + this.toolSpecs = (toolSpecs == null ? List.of() : toolSpecs).stream() + .filter(spec -> spec != null && spec.getName() != null && !spec.getName().isBlank()) + .collect(Collectors.toMap(AgentToolSpec::getName, Function.identity(), (left, right) -> left, + LinkedHashMap::new)); } /** @@ -87,6 +109,7 @@ public class ToolExecutionObserver implements AgentRuntimeObserver { runtimeEvent.getPayload().put("source", "HOOK"); runtimeEvent.getPayload().put("phase", "PRE_ACTING"); runtimeEvent.getMetadata().putAll(nullToEmpty(toolUse.getMetadata())); + enrichToolPayload(runtimeEvent, toolUse.getName()); eventBridge.emit(runtimeEvent); } @@ -115,9 +138,31 @@ public class ToolExecutionObserver implements AgentRuntimeObserver { if (result != null) { runtimeEvent.getMetadata().putAll(nullToEmpty(result.getMetadata())); } + enrichToolPayload(runtimeEvent, toolName); eventBridge.emit(runtimeEvent); } + private void enrichToolPayload(AgentRuntimeEvent runtimeEvent, String toolName) { + AgentToolSpec toolSpec = toolSpecs.get(toolName); + if (toolSpec == null || toolSpec.getMetadata() == null || toolSpec.getMetadata().isEmpty()) { + return; + } + Map metadata = toolSpec.getMetadata(); + putIfPresent(runtimeEvent.getPayload(), metadata, "toolDisplayName"); + putIfPresent(runtimeEvent.getPayload(), metadata, "rawMcpToolName"); + putIfPresent(runtimeEvent.getPayload(), metadata, "mcpToolName"); + putIfPresent(runtimeEvent.getPayload(), metadata, "mcpName"); + putIfPresent(runtimeEvent.getPayload(), metadata, "mcpTitle"); + putIfPresent(runtimeEvent.getPayload(), metadata, "source"); + runtimeEvent.getMetadata().putAll(metadata); + } + + private void putIfPresent(Map payload, Map metadata, String key) { + if (metadata.containsKey(key)) { + payload.put(key, metadata.get(key)); + } + } + private boolean success(ToolResultBlock result) { if (result == null) { return false; diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/AliasedMcpClientWrapper.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/AliasedMcpClientWrapper.java new file mode 100644 index 0000000..703fa1d --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/AliasedMcpClientWrapper.java @@ -0,0 +1,172 @@ +package com.easyagents.agent.runtime.mcp; + +import io.agentscope.core.tool.mcp.McpClientWrapper; +import io.modelcontextprotocol.spec.McpSchema; +import reactor.core.publisher.Mono; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 为 MCP client 增加运行时工具别名。 + */ +class AliasedMcpClientWrapper extends McpClientWrapper { + + static final String RAW_TOOL_NAME_META_KEY = "easyagentsRawMcpToolName"; + + private final McpClientWrapper delegate; + private final Map rawToAlias; + private final Map aliasToRaw; + private final String toolNamePrefix; + + /** + * 创建 MCP client 别名包装器。 + * + * @param delegate 原始 MCP client + * @param rawToAlias 原始工具名到运行时工具名的映射 + */ + AliasedMcpClientWrapper(McpClientWrapper delegate, Map rawToAlias) { + this(delegate, rawToAlias, null); + } + + /** + * 创建 MCP client 别名包装器。 + * + * @param delegate 原始 MCP client + * @param rawToAlias 原始工具名到运行时工具名的映射 + * @param toolNamePrefix 动态工具名前缀 + */ + AliasedMcpClientWrapper(McpClientWrapper delegate, Map rawToAlias, String toolNamePrefix) { + super(delegate == null ? "mcp" : delegate.getName()); + this.delegate = delegate; + this.rawToAlias = rawToAlias == null ? Map.of() : new LinkedHashMap<>(rawToAlias); + this.aliasToRaw = new LinkedHashMap<>(); + this.toolNamePrefix = toolNamePrefix == null || toolNamePrefix.isBlank() ? null : toolNamePrefix.trim(); + this.rawToAlias.forEach((rawName, aliasName) -> { + if (rawName != null && aliasName != null && !aliasName.isBlank()) { + this.aliasToRaw.put(aliasName, rawName); + } + }); + } + + /** + * 初始化底层 MCP client。 + * + * @return 初始化完成信号 + */ + @Override + public Mono initialize() { + return delegate.initialize().doOnSuccess(ignored -> initialized = delegate.isInitialized()); + } + + /** + * 返回已替换为运行时别名的工具列表。 + * + * @return 工具列表 + */ + @Override + public Mono> listTools() { + return delegate.listTools().map(this::aliasTools); + } + + /** + * 调用 MCP 工具,运行时别名会映射回原始工具名。 + * + * @param toolName 运行时工具名 + * @param arguments 工具参数 + * @return 工具调用结果 + */ + @Override + public Mono callTool(String toolName, Map arguments) { + return delegate.callTool(rawToolName(toolName), arguments); + } + + /** + * 关闭底层 MCP client。 + */ + @Override + public void close() { + delegate.close(); + initialized = false; + } + + private List aliasTools(List tools) { + if (tools == null || tools.isEmpty()) { + cachedTools.clear(); + return List.of(); + } + List aliased = new ArrayList<>(); + cachedTools.clear(); + Map usedAliases = new LinkedHashMap<>(); + for (McpSchema.Tool tool : tools) { + if (tool == null) { + continue; + } + McpSchema.Tool aliasTool = aliasTool(tool, usedAliases); + cachedTools.put(aliasTool.name(), aliasTool); + aliased.add(aliasTool); + } + return aliased; + } + + private McpSchema.Tool aliasTool(McpSchema.Tool tool, Map usedAliases) { + String aliasName = uniqueAliasName(aliasName(tool.name()), tool.name(), usedAliases); + if (aliasName == null || aliasName.isBlank() || aliasName.equals(tool.name())) { + return tool; + } + aliasToRaw.put(aliasName, tool.name()); + Map meta = new LinkedHashMap<>(); + if (tool.meta() != null) { + meta.putAll(tool.meta()); + } + meta.put(RAW_TOOL_NAME_META_KEY, tool.name()); + return new McpSchema.Tool(aliasName, tool.title(), tool.description(), tool.inputSchema(), + tool.outputSchema(), tool.annotations(), meta); + } + + private String uniqueAliasName(String aliasName, String rawName, Map usedAliases) { + if (aliasName == null || aliasName.isBlank()) { + return aliasName; + } + String existingRawName = usedAliases.get(aliasName); + if (existingRawName == null || existingRawName.equals(rawName)) { + usedAliases.put(aliasName, rawName); + return aliasName; + } + int suffix = 2; + String candidate = aliasName + "_" + suffix; + while (usedAliases.containsKey(candidate)) { + suffix++; + candidate = aliasName + "_" + suffix; + } + usedAliases.put(candidate, rawName); + return candidate; + } + + private String aliasName(String rawName) { + String explicitAlias = rawToAlias.get(rawName); + if (explicitAlias != null && !explicitAlias.isBlank()) { + return explicitAlias; + } + if (toolNamePrefix == null) { + return rawName; + } + return toolNamePrefix + safeToolNameSegment(rawName); + } + + private String safeToolNameSegment(String value) { + String normalized = String.valueOf(value == null ? "" : value).trim() + .replaceAll("[^A-Za-z0-9_-]", "_") + .replaceAll("_+", "_"); + if (normalized.isBlank()) { + return "tool"; + } + return normalized; + } + + private String rawToolName(String toolName) { + return aliasToRaw.getOrDefault(toolName, toolName); + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpClientFactory.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpClientFactory.java new file mode 100644 index 0000000..8c8005c --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpClientFactory.java @@ -0,0 +1,41 @@ +package com.easyagents.agent.runtime.mcp; + +import com.easyagents.agent.runtime.AgentRuntimeException; +import io.agentscope.core.tool.mcp.McpClientBuilder; +import io.agentscope.core.tool.mcp.McpClientWrapper; + +import java.time.Duration; + +/** + * 创建 AgentScope MCP client。 + */ +public class McpClientFactory { + + /** + * 根据 MCP 运行时声明创建 AgentScope MCP client。 + * + * @param spec MCP 运行时声明 + * @return AgentScope MCP client + */ + public McpClientWrapper create(McpSpec spec) { + McpSpecValidator.validateConnection(spec); + McpClientBuilder builder = McpClientBuilder.create(spec.getName()) + .timeout(timeout(spec.getTimeout(), Duration.ofSeconds(120))) + .initializationTimeout(timeout(spec.getInitializationTimeout(), Duration.ofSeconds(30))); + switch (spec.getTransportType()) { + case STDIO -> builder.stdioTransport(spec.getCommand(), spec.getArgs(), spec.getEnv()); + case SSE -> builder.sseTransport(spec.getUrl()) + .headers(spec.getHeaders()) + .queryParams(spec.getQueryParams()); + case HTTP -> builder.streamableHttpTransport(spec.getUrl()) + .headers(spec.getHeaders()) + .queryParams(spec.getQueryParams()); + default -> throw new AgentRuntimeException("Unsupported MCP transport type: " + spec.getTransportType()); + } + return builder.buildAsync().block(); + } + + private Duration timeout(Duration value, Duration defaultValue) { + return value == null || value.isZero() || value.isNegative() ? defaultValue : value; + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpRegistration.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpRegistration.java new file mode 100644 index 0000000..2550406 --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpRegistration.java @@ -0,0 +1,54 @@ +package com.easyagents.agent.runtime.mcp; + +import com.easyagents.agent.runtime.tool.AgentToolSpec; +import io.agentscope.core.tool.mcp.McpClientWrapper; + +import java.util.ArrayList; +import java.util.List; + +/** + * MCP 注册结果。 + */ +public class McpRegistration { + + private final List clients; + private final List toolSpecs; + + /** + * 创建 MCP 注册结果。 + * + * @param clients 已创建 MCP client + * @param toolSpecs 已注册工具声明 + */ + public McpRegistration(List clients, List toolSpecs) { + this.clients = clients == null ? List.of() : new ArrayList<>(clients); + this.toolSpecs = toolSpecs == null ? List.of() : new ArrayList<>(toolSpecs); + } + + /** + * 创建空注册结果。 + * + * @return 空注册结果 + */ + public static McpRegistration empty() { + return new McpRegistration(List.of(), List.of()); + } + + /** + * 获取已创建 MCP client。 + * + * @return 已创建 MCP client + */ + public List getClients() { + return clients; + } + + /** + * 获取已注册工具声明。 + * + * @return 已注册工具声明 + */ + public List getToolSpecs() { + return toolSpecs; + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpSpec.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpSpec.java new file mode 100644 index 0000000..8ebb80b --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpSpec.java @@ -0,0 +1,426 @@ +package com.easyagents.agent.runtime.mcp; + +import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * MCP 运行时声明。 + */ +public class McpSpec { + + private String name; + private String description; + private McpTransportType transportType = McpTransportType.STDIO; + private String command; + private List args = new ArrayList<>(); + private Map env = new LinkedHashMap<>(); + private String url; + private Map headers = new LinkedHashMap<>(); + private Map queryParams = new LinkedHashMap<>(); + private Duration timeout = Duration.ofSeconds(120); + private Duration initializationTimeout = Duration.ofSeconds(30); + private List enableTools = new ArrayList<>(); + private List disableTools = new ArrayList<>(); + private String groupName; + private Map> presetParameters = new LinkedHashMap<>(); + private Map toolAliases = new LinkedHashMap<>(); + private String toolNamePrefix; + private boolean approvalRequired; + private AgentToolApprovalRequest approvalRequest = new AgentToolApprovalRequest(); + private Map toolApprovalRequests = new LinkedHashMap<>(); + private Map metadata = new LinkedHashMap<>(); + + /** + * 获取 MCP client 名称。 + * + * @return MCP client 名称 + */ + public String getName() { + return name; + } + + /** + * 设置 MCP client 名称。 + * + * @param name MCP client 名称 + */ + public void setName(String name) { + this.name = name; + } + + /** + * 获取 MCP 描述。 + * + * @return MCP 描述 + */ + public String getDescription() { + return description; + } + + /** + * 设置 MCP 描述。 + * + * @param description MCP 描述 + */ + public void setDescription(String description) { + this.description = description; + } + + /** + * 获取连接方式。 + * + * @return 连接方式 + */ + public McpTransportType getTransportType() { + return transportType; + } + + /** + * 设置连接方式。 + * + * @param transportType 连接方式 + */ + public void setTransportType(McpTransportType transportType) { + this.transportType = transportType == null ? McpTransportType.STDIO : transportType; + } + + /** + * 通过字符串设置连接方式。 + * + * @param transportType 连接方式文本 + */ + public void setTransportType(String transportType) { + this.transportType = McpTransportType.from(transportType); + } + + /** + * 获取 stdio 命令。 + * + * @return stdio 命令 + */ + public String getCommand() { + return command; + } + + /** + * 设置 stdio 命令。 + * + * @param command stdio 命令 + */ + public void setCommand(String command) { + this.command = command; + } + + /** + * 获取 stdio 参数。 + * + * @return stdio 参数 + */ + public List getArgs() { + return args; + } + + /** + * 设置 stdio 参数。 + * + * @param args stdio 参数 + */ + public void setArgs(List args) { + this.args = args == null ? new ArrayList<>() : new ArrayList<>(args); + } + + /** + * 获取 stdio 环境变量。 + * + * @return stdio 环境变量 + */ + public Map getEnv() { + return env; + } + + /** + * 设置 stdio 环境变量。 + * + * @param env stdio 环境变量 + */ + public void setEnv(Map env) { + this.env = env == null ? new LinkedHashMap<>() : new LinkedHashMap<>(env); + } + + /** + * 获取 HTTP 地址。 + * + * @return HTTP 地址 + */ + public String getUrl() { + return url; + } + + /** + * 设置 HTTP 地址。 + * + * @param url HTTP 地址 + */ + public void setUrl(String url) { + this.url = url; + } + + /** + * 获取 HTTP 请求头。 + * + * @return HTTP 请求头 + */ + public Map getHeaders() { + return headers; + } + + /** + * 设置 HTTP 请求头。 + * + * @param headers HTTP 请求头 + */ + public void setHeaders(Map headers) { + this.headers = headers == null ? new LinkedHashMap<>() : new LinkedHashMap<>(headers); + } + + /** + * 获取 HTTP 查询参数。 + * + * @return HTTP 查询参数 + */ + public Map getQueryParams() { + return queryParams; + } + + /** + * 设置 HTTP 查询参数。 + * + * @param queryParams HTTP 查询参数 + */ + public void setQueryParams(Map queryParams) { + this.queryParams = queryParams == null ? new LinkedHashMap<>() : new LinkedHashMap<>(queryParams); + } + + /** + * 获取请求超时时间。 + * + * @return 请求超时时间 + */ + public Duration getTimeout() { + return timeout; + } + + /** + * 设置请求超时时间。 + * + * @param timeout 请求超时时间 + */ + public void setTimeout(Duration timeout) { + this.timeout = timeout == null ? Duration.ofSeconds(120) : timeout; + } + + /** + * 获取初始化超时时间。 + * + * @return 初始化超时时间 + */ + public Duration getInitializationTimeout() { + return initializationTimeout; + } + + /** + * 设置初始化超时时间。 + * + * @param initializationTimeout 初始化超时时间 + */ + public void setInitializationTimeout(Duration initializationTimeout) { + this.initializationTimeout = initializationTimeout == null ? Duration.ofSeconds(30) : initializationTimeout; + } + + /** + * 获取启用工具白名单。 + * + * @return 启用工具白名单 + */ + public List getEnableTools() { + return enableTools; + } + + /** + * 设置启用工具白名单。 + * + * @param enableTools 启用工具白名单 + */ + public void setEnableTools(List enableTools) { + this.enableTools = enableTools == null ? new ArrayList<>() : new ArrayList<>(enableTools); + } + + /** + * 获取禁用工具黑名单。 + * + * @return 禁用工具黑名单 + */ + public List getDisableTools() { + return disableTools; + } + + /** + * 设置禁用工具黑名单。 + * + * @param disableTools 禁用工具黑名单 + */ + public void setDisableTools(List disableTools) { + this.disableTools = disableTools == null ? new ArrayList<>() : new ArrayList<>(disableTools); + } + + /** + * 获取工具分组名。 + * + * @return 工具分组名 + */ + public String getGroupName() { + return groupName; + } + + /** + * 设置工具分组名。 + * + * @param groupName 工具分组名 + */ + public void setGroupName(String groupName) { + this.groupName = groupName; + } + + /** + * 获取预置参数。 + * + * @return 预置参数 + */ + public Map> getPresetParameters() { + return presetParameters; + } + + /** + * 设置预置参数。 + * + * @param presetParameters 预置参数 + */ + public void setPresetParameters(Map> presetParameters) { + this.presetParameters = presetParameters == null ? new LinkedHashMap<>() : new LinkedHashMap<>(presetParameters); + } + + /** + * 获取 MCP 原始工具名到运行时工具名的别名映射。 + * + * @return 工具别名映射 + */ + public Map getToolAliases() { + return toolAliases; + } + + /** + * 设置 MCP 原始工具名到运行时工具名的别名映射。 + * + * @param toolAliases 工具别名映射 + */ + public void setToolAliases(Map toolAliases) { + this.toolAliases = toolAliases == null ? new LinkedHashMap<>() : new LinkedHashMap<>(toolAliases); + } + + /** + * 获取动态工具名前缀。 + * + * @return 动态工具名前缀 + */ + public String getToolNamePrefix() { + return toolNamePrefix; + } + + /** + * 设置动态工具名前缀。 + * + * @param toolNamePrefix 动态工具名前缀 + */ + public void setToolNamePrefix(String toolNamePrefix) { + this.toolNamePrefix = toolNamePrefix; + } + + /** + * 返回 MCP 工具是否默认需要人工审批。 + * + * @return 需要审批时为 true + */ + public boolean isApprovalRequired() { + return approvalRequired; + } + + /** + * 设置 MCP 工具是否默认需要人工审批。 + * + * @param approvalRequired 审批标记 + */ + public void setApprovalRequired(boolean approvalRequired) { + this.approvalRequired = approvalRequired; + } + + /** + * 获取审批请求。 + * + * @return 审批请求 + */ + public AgentToolApprovalRequest getApprovalRequest() { + return approvalRequest; + } + + /** + * 设置审批请求。 + * + * @param approvalRequest 审批请求 + */ + public void setApprovalRequest(AgentToolApprovalRequest approvalRequest) { + this.approvalRequest = approvalRequest == null ? new AgentToolApprovalRequest() : approvalRequest; + } + + /** + * 获取运行时工具名到审批请求的映射。 + * + * @return 工具审批请求映射 + */ + public Map getToolApprovalRequests() { + return toolApprovalRequests; + } + + /** + * 设置运行时工具名到审批请求的映射。 + * + * @param toolApprovalRequests 工具审批请求映射 + */ + public void setToolApprovalRequests(Map toolApprovalRequests) { + this.toolApprovalRequests = toolApprovalRequests == null + ? new LinkedHashMap<>() + : new LinkedHashMap<>(toolApprovalRequests); + } + + /** + * 获取元数据。 + * + * @return 元数据 + */ + public Map getMetadata() { + return metadata; + } + + /** + * 设置元数据。 + * + * @param metadata 元数据 + */ + public void setMetadata(Map metadata) { + this.metadata = metadata == null ? new LinkedHashMap<>() : new LinkedHashMap<>(metadata); + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpSpecValidator.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpSpecValidator.java new file mode 100644 index 0000000..daf3cac --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpSpecValidator.java @@ -0,0 +1,107 @@ +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 java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * MCP 声明校验器。 + */ +public final class McpSpecValidator { + + private McpSpecValidator() { + } + + /** + * 校验 MCP 连接配置。 + * + * @param spec MCP 运行时声明 + */ + public static void validateConnection(McpSpec spec) { + if (spec == null) { + throw new AgentRuntimeException("MCP spec is required."); + } + if (spec.getName() == null || spec.getName().isBlank()) { + throw new AgentRuntimeException("MCP name is required."); + } + if (spec.getTransportType() == null) { + throw new AgentRuntimeException("MCP transport type is required: " + spec.getName()); + } + validateToolAliases(spec); + switch (spec.getTransportType()) { + case STDIO -> { + if (spec.getCommand() == null || spec.getCommand().isBlank()) { + throw new AgentRuntimeException("MCP stdio command is required: " + spec.getName()); + } + } + case SSE, HTTP -> { + if (spec.getUrl() == null || spec.getUrl().isBlank()) { + throw new AgentRuntimeException("MCP url is required: " + spec.getName()); + } + } + default -> throw new AgentRuntimeException("Unsupported MCP transport type: " + spec.getTransportType()); + } + } + + private static void validateToolAliases(McpSpec spec) { + Map aliases = spec.getToolAliases(); + if (aliases == null || aliases.isEmpty()) { + return; + } + Set runtimeNames = new HashSet<>(); + for (Map.Entry entry : aliases.entrySet()) { + String rawName = entry.getKey(); + String runtimeName = entry.getValue(); + if (rawName == null || rawName.isBlank()) { + throw new AgentRuntimeException("MCP raw tool name is required: " + spec.getName()); + } + if (runtimeName == null || runtimeName.isBlank()) { + throw new AgentRuntimeException("MCP runtime tool name is required: " + spec.getName()); + } + if (!runtimeNames.add(runtimeName)) { + throw new AgentRuntimeException("MCP runtime tool alias conflicts: " + runtimeName); + } + } + } + + /** + * 校验 MCP 工具与既有工具名冲突。 + * + * @param businessToolSpecs 普通工具声明 + * @param mcpToolSpecs MCP 工具声明 + * @param operateToolSpecs 操作工具声明 + */ + public static void validateToolConflicts(List businessToolSpecs, + List mcpToolSpecs, + List operateToolSpecs) { + Set names = new HashSet<>(); + addToolNames(names, businessToolSpecs, "Agent tool conflicts with existing tool: "); + addToolNames(names, mcpToolSpecs, "MCP tool conflicts with existing tool: "); + Set operateToolNames = new AgentOperateToolAdapter().enabledToolNames(operateToolSpecs); + for (String operateToolName : operateToolNames) { + if (!names.add(operateToolName)) { + throw new AgentRuntimeException("Agent operate tool conflicts with existing tool: " + operateToolName); + } + } + } + + private static void addToolNames(Set names, List toolSpecs, String messagePrefix) { + if (toolSpecs == null || toolSpecs.isEmpty()) { + return; + } + for (AgentToolSpec toolSpec : toolSpecs) { + if (toolSpec == null || toolSpec.getName() == null || toolSpec.getName().isBlank()) { + continue; + } + if (!names.add(toolSpec.getName())) { + throw new AgentRuntimeException(messagePrefix + toolSpec.getName()); + } + } + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpToolkitAdapter.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpToolkitAdapter.java new file mode 100644 index 0000000..f82c0b9 --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpToolkitAdapter.java @@ -0,0 +1,241 @@ +package com.easyagents.agent.runtime.mcp; + +import com.easyagents.agent.runtime.AgentRuntimeException; +import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest; +import com.easyagents.agent.runtime.tool.AgentToolCategory; +import com.easyagents.agent.runtime.tool.AgentToolSpec; +import com.easyagents.agent.runtime.tool.AgentToolVisibility; +import io.agentscope.core.tool.Toolkit; +import io.agentscope.core.tool.mcp.McpClientWrapper; +import io.agentscope.core.tool.mcp.McpTool; +import io.modelcontextprotocol.spec.McpSchema; + +import java.util.*; +import java.util.Locale; + +/** + * 将 MCP 运行时声明注册到 AgentScope Toolkit。 + */ +public class McpToolkitAdapter { + + private final McpClientFactory clientFactory; + + /** + * 使用默认 MCP client factory 创建适配器。 + */ + public McpToolkitAdapter() { + this(new McpClientFactory()); + } + + /** + * 使用指定 MCP client factory 创建适配器。 + * + * @param clientFactory MCP client factory + */ + public McpToolkitAdapter(McpClientFactory clientFactory) { + this.clientFactory = clientFactory == null ? new McpClientFactory() : clientFactory; + } + + /** + * 将 MCP 声明注册到 Toolkit。 + * + * @param specs MCP 声明 + * @param toolkit AgentScope Toolkit + * @return MCP 注册结果 + */ + public McpRegistration register(List specs, Toolkit toolkit) { + if (specs == null || specs.isEmpty()) { + return McpRegistration.empty(); + } + if (toolkit == null) { + throw new AgentRuntimeException("AgentScope toolkit is required for MCP registration."); + } + List clients = new ArrayList<>(); + List toolSpecs = new ArrayList<>(); + try { + for (McpSpec spec : specs) { + if (spec == null) { + continue; + } + McpSpecValidator.validateConnection(spec); + McpClientWrapper client = clientFactory.create(spec); + client = applyAliases(spec, client); + clients.add(client); + registerClient(spec, client, toolkit); + toolSpecs.addAll(toToolSpecs(spec, registeredTools(spec, client))); + } + } catch (RuntimeException error) { + closeQuietly(clients); + throw error; + } + return new McpRegistration(clients, toolSpecs); + } + + private McpClientWrapper applyAliases(McpSpec spec, McpClientWrapper client) { + boolean hasExplicitAliases = spec.getToolAliases() != null && !spec.getToolAliases().isEmpty(); + boolean hasDynamicPrefix = spec.getToolNamePrefix() != null && !spec.getToolNamePrefix().isBlank(); + if (!hasExplicitAliases && !hasDynamicPrefix) { + return client; + } + return new AliasedMcpClientWrapper(client, spec.getToolAliases(), spec.getToolNamePrefix()); + } + + private void registerClient(McpSpec spec, McpClientWrapper client, Toolkit toolkit) { + String groupName = blankToNull(spec.getGroupName()); + if (groupName != null && toolkit.getToolGroup(groupName) == null) { + toolkit.createToolGroup(groupName, spec.getDescription(), true); + } + toolkit.registration() + .mcpClient(client) + .enableTools(emptyToNull(spec.getEnableTools())) + .disableTools(emptyToNull(spec.getDisableTools())) + .group(groupName) + .presetParameters(emptyToNull(spec.getPresetParameters())) + .apply(); + } + + private List registeredTools(McpSpec spec, McpClientWrapper client) { + List tools = client.listTools().block(); + if (tools == null || tools.isEmpty()) { + return List.of(); + } + List filtered = new ArrayList<>(); + for (McpSchema.Tool tool : tools) { + if (tool != null && shouldRegister(tool.name(), spec.getEnableTools(), spec.getDisableTools())) { + filtered.add(tool); + } + } + return filtered; + } + + private boolean shouldRegister(String toolName, List enableTools, List disableTools) { + if (enableTools != null && !enableTools.isEmpty()) { + return enableTools.contains(toolName); + } + return disableTools == null || disableTools.isEmpty() || !disableTools.contains(toolName); + } + + private List toToolSpecs(McpSpec spec, List tools) { + if (tools == null || tools.isEmpty()) { + return List.of(); + } + List toolSpecs = new ArrayList<>(); + for (McpSchema.Tool tool : tools) { + AgentToolSpec toolSpec = new AgentToolSpec(); + Set excludedPresetNames = Set.of(); + Map toolPresetParameters = spec.getPresetParameters() == null + ? null + : spec.getPresetParameters().get(tool.name()); + if (toolPresetParameters != null) { + excludedPresetNames = toolPresetParameters.keySet(); + } + toolSpec.setName(tool.name()); + toolSpec.setDescription(tool.description()); + toolSpec.setCategory(AgentToolCategory.MCP); + toolSpec.setVisibility(AgentToolVisibility.VISIBLE); + toolSpec.setParametersSchema(McpTool.convertMcpSchemaToParameters(tool.inputSchema(), excludedPresetNames)); + toolSpec.setOutputSchema(tool.outputSchema()); + AgentToolApprovalRequest toolApprovalRequest = toolApprovalRequest(spec, tool.name()); + toolSpec.setApprovalRequired(spec.isApprovalRequired() || toolApprovalRequest != null); + toolSpec.setApprovalRequest(toolApprovalRequest == null ? spec.getApprovalRequest() : toolApprovalRequest); + toolSpec.setMetadata(metadata(spec, tool)); + toolSpecs.add(toolSpec); + } + return toolSpecs; + } + + private AgentToolApprovalRequest toolApprovalRequest(McpSpec spec, String toolName) { + if (spec.getToolApprovalRequests() == null || spec.getToolApprovalRequests().isEmpty()) { + return null; + } + return spec.getToolApprovalRequests().get(toolName); + } + + private Map metadata(McpSpec spec, McpSchema.Tool tool) { + Map metadata = new LinkedHashMap<>(); + if (spec.getMetadata() != null) { + spec.getMetadata().forEach((key, value) -> { + if (!isSensitiveMetadataKey(key)) { + metadata.put(key, value); + } + }); + } + metadata.put("source", "MCP"); + metadata.put("mcpName", spec.getName()); + metadata.put("mcpToolName", tool.name()); + metadata.put("rawMcpToolName", rawToolName(spec, tool)); + metadata.put("toolDisplayName", toolDisplayName(spec, tool)); + metadata.put("transportType", spec.getTransportType().configValue()); + return metadata; + } + + private String rawToolName(McpSpec spec, McpSchema.Tool tool) { + if (tool != null && tool.meta() != null) { + Object rawName = tool.meta().get(AliasedMcpClientWrapper.RAW_TOOL_NAME_META_KEY); + if (rawName != null && !String.valueOf(rawName).isBlank()) { + return String.valueOf(rawName); + } + } + String toolName = tool == null ? null : tool.name(); + if (spec.getToolAliases() != null && !spec.getToolAliases().isEmpty()) { + for (Map.Entry entry : spec.getToolAliases().entrySet()) { + if (Objects.equals(entry.getValue(), toolName)) { + return entry.getKey(); + } + } + } + return toolName; + } + + private String toolDisplayName(McpSpec spec, McpSchema.Tool tool) { + String rawToolName = rawToolName(spec, tool); + String mcpName = spec == null ? null : spec.getDescription(); + if (mcpName == null || mcpName.isBlank()) { + mcpName = spec == null ? null : spec.getName(); + } + if (mcpName == null || mcpName.isBlank()) { + return rawToolName; + } + if (rawToolName == null || rawToolName.isBlank()) { + return mcpName; + } + return mcpName + " - " + rawToolName; + } + + private boolean isSensitiveMetadataKey(String key) { + if (key == null || key.isBlank()) { + return false; + } + String normalized = key.toLowerCase(Locale.ROOT).replace("-", "").replace("_", ""); + return normalized.contains("key") + || normalized.contains("token") + || normalized.contains("secret") + || normalized.contains("password") + || normalized.contains("authorization") + || normalized.contains("credential"); + } + + private void closeQuietly(List clients) { + for (McpClientWrapper client : clients) { + if (client == null) { + continue; + } + try { + client.close(); + } catch (Exception ignored) { + } + } + } + + private List emptyToNull(List values) { + return values == null || values.isEmpty() ? null : values; + } + + private Map emptyToNull(Map values) { + return values == null || values.isEmpty() ? null : values; + } + + private String blankToNull(String value) { + return value == null || value.isBlank() ? null : value; + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpTransportType.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpTransportType.java new file mode 100644 index 0000000..0f8c096 --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/mcp/McpTransportType.java @@ -0,0 +1,58 @@ +package com.easyagents.agent.runtime.mcp; + +import com.easyagents.agent.runtime.AgentRuntimeException; + +import java.util.Locale; + +/** + * MCP 连接方式。 + */ +public enum McpTransportType { + + /** + * 标准输入输出进程通信。 + */ + STDIO, + + /** + * HTTP Server-Sent Events 通信。 + */ + SSE, + + /** + * Streamable HTTP 通信。 + */ + HTTP; + + /** + * 解析 MCP 连接方式。 + * + * @param value 连接方式文本 + * @return MCP 连接方式 + */ + public static McpTransportType from(String value) { + if (value == null || value.isBlank()) { + return STDIO; + } + String normalized = value.trim().toLowerCase(Locale.ROOT); + return switch (normalized) { + case "stdio" -> STDIO; + case "sse", "http-sse" -> SSE; + case "http", "streamable-http", "http-stream" -> HTTP; + default -> throw new AgentRuntimeException("Unsupported MCP transport type: " + value); + }; + } + + /** + * 转换为 Easy MCP 配置兼容值。 + * + * @return transport 配置值 + */ + public String configValue() { + return switch (this) { + case STDIO -> "stdio"; + case SSE -> "http-sse"; + case HTTP -> "http-stream"; + }; + } +} diff --git a/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/agentscope/AgentScopeStatefulRuntimeTest.java b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/agentscope/AgentScopeStatefulRuntimeTest.java index 46912ce..80333b0 100644 --- a/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/agentscope/AgentScopeStatefulRuntimeTest.java +++ b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/agentscope/AgentScopeStatefulRuntimeTest.java @@ -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 clients = (List) 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 clients = (List) 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 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 initialize() { + initialized = true; + return Mono.empty(); + } + + @Override + public Mono> listTools() { + return Mono.just(List.of()); + } + + @Override + public Mono callTool(String toolName, Map 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(); diff --git a/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/mcp/McpSpecValidatorTest.java b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/mcp/McpSpecValidatorTest.java new file mode 100644 index 0000000..6058b72 --- /dev/null +++ b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/mcp/McpSpecValidatorTest.java @@ -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; + } +} diff --git a/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/mcp/McpToolkitAdapterTest.java b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/mcp/McpToolkitAdapterTest.java new file mode 100644 index 0000000..345fd50 --- /dev/null +++ b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/mcp/McpToolkitAdapterTest.java @@ -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 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 tools; + private final AtomicBoolean closed = new AtomicBoolean(false); + private final AtomicReference lastCalledToolName = new AtomicReference<>(); + private boolean failOnListTools; + + private FakeMcpClientWrapper(String name, List tools) { + super(name); + this.tools = tools; + } + + @Override + public Mono initialize() { + initialized = true; + return Mono.empty(); + } + + @Override + public Mono> listTools() { + if (failOnListTools) { + return Mono.error(new IllegalStateException("list tools failed")); + } + return Mono.just(tools); + } + + @Override + public Mono callTool(String toolName, Map arguments) { + lastCalledToolName.set(toolName); + return Mono.just(new McpSchema.CallToolResult(List.of(), false)); + } + + @Override + public void close() { + closed.set(true); + initialized = false; + } + } +} diff --git a/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/mcp/McpTransportTypeTest.java b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/mcp/McpTransportTypeTest.java new file mode 100644 index 0000000..ba60397 --- /dev/null +++ b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/mcp/McpTransportTypeTest.java @@ -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"); + } +} diff --git a/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpCheckItem.java b/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpCheckItem.java new file mode 100644 index 0000000..4e2a0ce --- /dev/null +++ b/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpCheckItem.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + */ +package com.easyagents.mcp.client; + +/** + * MCP 单项检测结果。 + */ +public class McpCheckItem { + + private String name; + private McpCheckStatus status = McpCheckStatus.SUCCESS; + private String message; + private String detail; + + /** + * 创建检测项。 + * + * @param name 检测项名称 + * @param status 检测状态 + * @param message 检测消息 + * @param detail 检测详情 + * @return 检测项 + */ + public static McpCheckItem of(String name, McpCheckStatus status, String message, String detail) { + McpCheckItem item = new McpCheckItem(); + item.setName(name); + item.setStatus(status); + item.setMessage(message); + item.setDetail(detail); + return item; + } + + /** + * 获取检测项名称。 + * + * @return 检测项名称 + */ + public String getName() { + return name; + } + + /** + * 设置检测项名称。 + * + * @param name 检测项名称 + */ + public void setName(String name) { + this.name = name; + } + + /** + * 获取检测状态。 + * + * @return 检测状态 + */ + public McpCheckStatus getStatus() { + return status; + } + + /** + * 设置检测状态。 + * + * @param status 检测状态 + */ + public void setStatus(McpCheckStatus status) { + this.status = status == null ? McpCheckStatus.SUCCESS : status; + } + + /** + * 获取检测消息。 + * + * @return 检测消息 + */ + public String getMessage() { + return message; + } + + /** + * 设置检测消息。 + * + * @param message 检测消息 + */ + public void setMessage(String message) { + this.message = message; + } + + /** + * 获取检测详情。 + * + * @return 检测详情 + */ + public String getDetail() { + return detail; + } + + /** + * 设置检测详情。 + * + * @param detail 检测详情 + */ + public void setDetail(String detail) { + this.detail = detail; + } +} diff --git a/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpCheckStatus.java b/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpCheckStatus.java new file mode 100644 index 0000000..603702c --- /dev/null +++ b/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpCheckStatus.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + */ +package com.easyagents.mcp.client; + +/** + * MCP 检测状态。 + */ +public enum McpCheckStatus { + + /** + * 检测通过。 + */ + SUCCESS, + + /** + * 检测存在警告但不一定阻断使用。 + */ + WARNING, + + /** + * 检测失败。 + */ + FAILED +} diff --git a/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpEnvironmentCheckResult.java b/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpEnvironmentCheckResult.java new file mode 100644 index 0000000..1005417 --- /dev/null +++ b/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpEnvironmentCheckResult.java @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + */ +package com.easyagents.mcp.client; + +import java.util.ArrayList; +import java.util.List; + +/** + * MCP 环境检测结果。 + */ +public class McpEnvironmentCheckResult { + + private McpCheckStatus overallStatus = McpCheckStatus.SUCCESS; + private List servers = new ArrayList<>(); + + /** + * 合并两个状态。 + * + * @param current 当前状态 + * @param incoming 新状态 + * @return 合并后的状态 + */ + public static McpCheckStatus mergeStatus(McpCheckStatus current, McpCheckStatus incoming) { + if (current == McpCheckStatus.FAILED || incoming == McpCheckStatus.FAILED) { + return McpCheckStatus.FAILED; + } + if (current == McpCheckStatus.WARNING || incoming == McpCheckStatus.WARNING) { + return McpCheckStatus.WARNING; + } + return McpCheckStatus.SUCCESS; + } + + /** + * 添加 Server 检测结果。 + * + * @param server Server 检测结果 + */ + public void addServer(McpServerCheckResult server) { + if (server == null) { + return; + } + this.servers.add(server); + this.overallStatus = mergeStatus(this.overallStatus, server.getStatus()); + } + + /** + * 获取整体状态。 + * + * @return 整体状态 + */ + public McpCheckStatus getOverallStatus() { + return overallStatus; + } + + /** + * 设置整体状态。 + * + * @param overallStatus 整体状态 + */ + public void setOverallStatus(McpCheckStatus overallStatus) { + this.overallStatus = overallStatus == null ? McpCheckStatus.SUCCESS : overallStatus; + } + + /** + * 获取 Server 检测结果。 + * + * @return Server 检测结果 + */ + public List getServers() { + return servers; + } + + /** + * 设置 Server 检测结果。 + * + * @param servers Server 检测结果 + */ + public void setServers(List servers) { + this.servers = servers == null ? new ArrayList<>() : new ArrayList<>(servers); + } +} diff --git a/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpEnvironmentChecker.java b/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpEnvironmentChecker.java new file mode 100644 index 0000000..2ac44ae --- /dev/null +++ b/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpEnvironmentChecker.java @@ -0,0 +1,318 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + */ +package com.easyagents.mcp.client; + +import com.alibaba.fastjson2.JSON; +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.spec.McpSchema; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +/** + * MCP 配置与运行环境检测器。 + */ +public class McpEnvironmentChecker { + + private static final Logger log = LoggerFactory.getLogger(McpEnvironmentChecker.class); + private static final Duration COMMAND_TIMEOUT = Duration.ofSeconds(3); + private static final Duration MCP_REQUEST_TIMEOUT = Duration.ofSeconds(10); + private static final Set SUPPORTED_TRANSPORTS = Set.of("stdio", "http-sse", "http-stream"); + private static final Set KNOWN_VERSION_COMMANDS = Set.of( + "node", "npm", "npx", "pnpm", "python", "python3", "pip", "pip3"); + private final boolean probeEnabled; + private final Function transportFactoryProvider; + + /** + * 创建启用连接探测的检测器。 + */ + public McpEnvironmentChecker() { + this(true); + } + + /** + * 创建可控制连接探测行为的检测器。 + * + * @param probeEnabled 是否启用 MCP 连接探测 + */ + McpEnvironmentChecker(boolean probeEnabled) { + this(probeEnabled, McpEnvironmentChecker::defaultTransportFactory); + } + + /** + * 创建可注入 transport 工厂的检测器。 + * + * @param probeEnabled 是否启用 MCP 连接探测 + * @param transportFactoryProvider transport 工厂提供器 + */ + McpEnvironmentChecker(boolean probeEnabled, + Function transportFactoryProvider) { + this.probeEnabled = probeEnabled; + this.transportFactoryProvider = transportFactoryProvider; + } + + /** + * 检测 MCP 配置。 + * + * @param configJson MCP 配置 JSON + * @return 检测结果 + */ + public McpEnvironmentCheckResult check(String configJson) { + McpEnvironmentCheckResult result = new McpEnvironmentCheckResult(); + if (configJson == null || configJson.isBlank()) { + result.setOverallStatus(McpCheckStatus.FAILED); + result.addServer(failedServer("config", null, "configJson", "MCP 配置 JSON 不能为空", null)); + return result; + } + + McpConfig config; + try { + config = JSON.parseObject(configJson, McpConfig.class); + } catch (Exception error) { + result.setOverallStatus(McpCheckStatus.FAILED); + result.addServer(failedServer("config", null, "json", "MCP 配置 JSON 格式错误", sanitize(error))); + return result; + } + + if (config == null || config.getMcpServers() == null || config.getMcpServers().isEmpty()) { + result.setOverallStatus(McpCheckStatus.FAILED); + result.addServer(failedServer("config", null, "mcpServers", "mcpServers 不能为空", null)); + return result; + } + + for (Map.Entry entry : config.getMcpServers().entrySet()) { + result.addServer(checkServer(entry.getKey(), entry.getValue())); + } + return result; + } + + private McpServerCheckResult checkServer(String serverName, McpConfig.ServerSpec spec) { + McpServerCheckResult result = new McpServerCheckResult(); + result.setServerName(serverName); + result.setTransport(transport(spec)); + + if (serverName == null || serverName.isBlank()) { + result.addCheck(McpCheckItem.of("serverName", McpCheckStatus.FAILED, + "MCP 服务名称不能为空", null)); + } + if (spec == null) { + result.addCheck(McpCheckItem.of("server", McpCheckStatus.FAILED, + "MCP 服务配置不能为空", null)); + return result; + } + + String transport = transport(spec); + result.setTransport(transport); + if (!SUPPORTED_TRANSPORTS.contains(transport)) { + result.addCheck(McpCheckItem.of("transport", McpCheckStatus.FAILED, + "不支持的 MCP 传输类型", transport)); + return result; + } + result.addCheck(McpCheckItem.of("transport", McpCheckStatus.SUCCESS, + "MCP 传输类型可用", transport)); + + Map resolvedEnv = resolveEnv(spec.getEnv(), result); + if ("stdio".equals(transport)) { + validateStdio(spec, result); + } else { + validateHttp(spec, result); + } + + if (probeEnabled && result.getStatus() != McpCheckStatus.FAILED) { + probe(serverName, spec, resolvedEnv, result); + } + return result; + } + + private void validateStdio(McpConfig.ServerSpec spec, McpServerCheckResult result) { + if (spec.getCommand() == null || spec.getCommand().isBlank()) { + result.addCheck(McpCheckItem.of("command", McpCheckStatus.FAILED, + "stdio MCP 必须配置 command", null)); + return; + } + result.addCheck(checkCommand(spec.getCommand())); + } + + private void validateHttp(McpConfig.ServerSpec spec, McpServerCheckResult result) { + if (spec.getUrl() == null || spec.getUrl().isBlank()) { + result.addCheck(McpCheckItem.of("url", McpCheckStatus.FAILED, + "HTTP MCP 必须配置 url", null)); + return; + } + result.addCheck(McpCheckItem.of("url", McpCheckStatus.SUCCESS, + "MCP 连接地址已配置", spec.getUrl())); + } + + private McpCheckItem checkCommand(String command) { + String executable = executableName(command); + boolean known = KNOWN_VERSION_COMMANDS.contains(executable); + ProcessBuilder builder = known + ? new ProcessBuilder(command, "--version") + : new ProcessBuilder(command); + try { + Process process = builder.redirectErrorStream(true).start(); + boolean finished = process.waitFor(COMMAND_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + if (!finished) { + process.destroyForcibly(); + return McpCheckItem.of("command", McpCheckStatus.SUCCESS, + command + " 可启动", "版本检测超时,已终止检测进程"); + } + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim(); + if (process.exitValue() == 0 || !known) { + return McpCheckItem.of("command", McpCheckStatus.SUCCESS, + command + " 可用", firstLine(output)); + } + return McpCheckItem.of("command", McpCheckStatus.WARNING, + command + " 可启动但返回非零状态", firstLine(output)); + } catch (Exception error) { + return McpCheckItem.of("command", McpCheckStatus.FAILED, + "容器内未找到命令:" + command, sanitize(error)); + } + } + + private void probe(String serverName, + McpConfig.ServerSpec spec, + Map resolvedEnv, + McpServerCheckResult result) { + CloseableTransport transport = null; + McpSyncClient client = null; + try { + transport = transportFactoryProvider.apply(spec.getTransport()).create(spec, resolvedEnv); + client = McpClient.sync(transport.getTransport()) + .requestTimeout(MCP_REQUEST_TIMEOUT) + .build(); + client.initialize(); + McpSchema.ListToolsResult toolsResult = client.listTools(); + int toolCount = toolsResult == null || toolsResult.tools() == null ? 0 : toolsResult.tools().size(); + result.setToolCount(toolCount); + if (toolCount == 0) { + result.addCheck(McpCheckItem.of("tools", McpCheckStatus.WARNING, + "MCP 已连接,但没有发现工具", null)); + } else { + result.addCheck(McpCheckItem.of("tools", McpCheckStatus.SUCCESS, + "MCP 工具列表获取成功", String.valueOf(toolCount))); + } + } catch (Exception error) { + log.debug("MCP check failed for server: {}", serverName, error); + result.addCheck(McpCheckItem.of("connection", McpCheckStatus.FAILED, + "MCP 初始化或工具发现失败", sanitize(error))); + } finally { + closeQuietly(client); + closeQuietly(transport); + } + } + + private Map resolveEnv(Map env, McpServerCheckResult result) { + Map resolved = new HashMap<>(); + if (env == null || env.isEmpty()) { + result.addCheck(McpCheckItem.of("env", McpCheckStatus.SUCCESS, + "未配置额外环境变量", null)); + return resolved; + } + for (Map.Entry entry : env.entrySet()) { + String key = entry.getKey(); + String value = entry.getValue(); + if (value != null && value.startsWith("${input:") && value.endsWith("}")) { + String inputId = value.substring("${input:".length(), value.length() - 1); + String resolvedValue = System.getProperty("mcp.input." + inputId); + if (resolvedValue == null || resolvedValue.isBlank()) { + result.addCheck(McpCheckItem.of("env", McpCheckStatus.FAILED, + "环境变量未解析:" + key, "input:" + inputId)); + continue; + } + resolved.put(key, resolvedValue); + result.addCheck(McpCheckItem.of("env", McpCheckStatus.SUCCESS, + "环境变量已解析:" + key, "input:" + inputId)); + continue; + } + resolved.put(key, value); + result.addCheck(McpCheckItem.of("env", McpCheckStatus.SUCCESS, + "环境变量已配置:" + key, null)); + } + return resolved; + } + + private McpServerCheckResult failedServer(String serverName, + String transport, + String name, + String message, + String detail) { + McpServerCheckResult server = new McpServerCheckResult(); + server.setServerName(serverName); + server.setTransport(transport); + server.addCheck(McpCheckItem.of(name, McpCheckStatus.FAILED, message, detail)); + return server; + } + + private static McpTransportFactory defaultTransportFactory(String transportType) { + return switch (transport(transportType)) { + case "stdio" -> new StdioTransportFactory(); + case "http-sse" -> new HttpSseTransportFactory(); + case "http-stream" -> new HttpStreamTransportFactory(); + default -> throw new IllegalArgumentException("Unsupported transport: " + transportType); + }; + } + + private String transport(McpConfig.ServerSpec spec) { + return spec == null ? "stdio" : transport(spec.getTransport()); + } + + private static String transport(String value) { + return value == null || value.isBlank() ? "stdio" : value.toLowerCase(Locale.ROOT); + } + + private String executableName(String command) { + int slash = Math.max(command.lastIndexOf('/'), command.lastIndexOf('\\')); + String name = slash >= 0 ? command.substring(slash + 1) : command; + return name.endsWith(".cmd") ? name.substring(0, name.length() - 4) : name; + } + + private String firstLine(String value) { + if (value == null || value.isBlank()) { + return null; + } + int lineEnd = value.indexOf('\n'); + String line = lineEnd >= 0 ? value.substring(0, lineEnd) : value; + return sanitize(line.trim()); + } + + private String sanitize(Throwable error) { + if (error == null) { + return null; + } + String message = error.getMessage(); + return message == null || message.isBlank() ? error.getClass().getSimpleName() : sanitize(message); + } + + private String sanitize(String value) { + if (value == null) { + return null; + } + String sanitized = value.replaceAll("(?i)(api[_-]?key|token|secret|password)=([^\\s,;]+)", "$1=******"); + return sanitized.length() > 500 ? sanitized.substring(0, 500) : sanitized; + } + + private void closeQuietly(AutoCloseable closeable) { + if (closeable == null) { + return; + } + try { + closeable.close(); + } catch (Exception error) { + log.debug("Failed to close MCP check resource.", error); + } + } +} diff --git a/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpServerCheckResult.java b/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpServerCheckResult.java new file mode 100644 index 0000000..ab31af3 --- /dev/null +++ b/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/McpServerCheckResult.java @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + */ +package com.easyagents.mcp.client; + +import java.util.ArrayList; +import java.util.List; + +/** + * 单个 MCP Server 的检测结果。 + */ +public class McpServerCheckResult { + + private String serverName; + private String transport; + private McpCheckStatus status = McpCheckStatus.SUCCESS; + private int toolCount; + private List checks = new ArrayList<>(); + + /** + * 添加检测项并刷新整体状态。 + * + * @param item 检测项 + */ + public void addCheck(McpCheckItem item) { + if (item == null) { + return; + } + this.checks.add(item); + this.status = McpEnvironmentCheckResult.mergeStatus(this.status, item.getStatus()); + } + + /** + * 获取 Server 名称。 + * + * @return Server 名称 + */ + public String getServerName() { + return serverName; + } + + /** + * 设置 Server 名称。 + * + * @param serverName Server 名称 + */ + public void setServerName(String serverName) { + this.serverName = serverName; + } + + /** + * 获取传输类型。 + * + * @return 传输类型 + */ + public String getTransport() { + return transport; + } + + /** + * 设置传输类型。 + * + * @param transport 传输类型 + */ + public void setTransport(String transport) { + this.transport = transport; + } + + /** + * 获取检测状态。 + * + * @return 检测状态 + */ + public McpCheckStatus getStatus() { + return status; + } + + /** + * 设置检测状态。 + * + * @param status 检测状态 + */ + public void setStatus(McpCheckStatus status) { + this.status = status == null ? McpCheckStatus.SUCCESS : status; + } + + /** + * 获取工具数量。 + * + * @return 工具数量 + */ + public int getToolCount() { + return toolCount; + } + + /** + * 设置工具数量。 + * + * @param toolCount 工具数量 + */ + public void setToolCount(int toolCount) { + this.toolCount = Math.max(toolCount, 0); + } + + /** + * 获取检测项。 + * + * @return 检测项 + */ + public List getChecks() { + return checks; + } + + /** + * 设置检测项。 + * + * @param checks 检测项 + */ + public void setChecks(List checks) { + this.checks = checks == null ? new ArrayList<>() : new ArrayList<>(checks); + } +} diff --git a/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/StdioTransportFactory.java b/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/StdioTransportFactory.java index cbefa08..585a558 100644 --- a/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/StdioTransportFactory.java +++ b/easy-agents-mcp/src/main/java/com/easyagents/mcp/client/StdioTransportFactory.java @@ -20,46 +20,24 @@ import io.modelcontextprotocol.client.transport.StdioClientTransport; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.spec.McpClientTransport; +import java.util.Collections; +import java.util.List; import java.util.Map; public class StdioTransportFactory implements McpTransportFactory { @Override public CloseableTransport create(McpConfig.ServerSpec spec, Map resolvedEnv) { -// ProcessBuilder pb = new ProcessBuilder(); -// List args = spec.getArgs(); -// if (args != null && !args.isEmpty()) { -// pb.command(spec.getCommand(), args.toArray(new String[0])); -// } else { -// pb.command(spec.getCommand()); -// } -// if (!resolvedEnv.isEmpty()) { -// pb.environment().putAll(resolvedEnv); -// } -// pb.redirectErrorStream(true); - try { -// Process process = pb.start(); -// OutputStream stdin = process.getOutputStream(); -// InputStream stdout = process.getInputStream(); - -// StdioClientTransport transport = new StdioClientTransport( -// stdin, stdout, McpJsonMapper.getDefault(), () -> {} -// ); - - -// ServerParameters params = ServerParameters.builder("npx") -// .args("-y", "@modelcontextprotocol/server-everything") -// .build(); - + List args = spec.getArgs() == null ? Collections.emptyList() : spec.getArgs(); + Map env = resolvedEnv == null ? Collections.emptyMap() : resolvedEnv; ServerParameters parameters = ServerParameters.builder(spec.getCommand()) - .args(spec.getArgs()) + .args(args) + .env(env) .build(); - StdioClientTransport transport = new StdioClientTransport(parameters, McpJsonMapper.getDefault()); - return new CloseableTransport() { @Override public McpClientTransport getTransport() { @@ -73,17 +51,6 @@ public class StdioTransportFactory implements McpTransportFactory { } catch (Exception e) { // ignore } -// if (process.isAlive()) { -// process.destroy(); -// try { -// if (!process.waitFor(3, TimeUnit.SECONDS)) { -// process.destroyForcibly(); -// } -// } catch (InterruptedException ex) { -// Thread.currentThread().interrupt(); -// process.destroyForcibly(); -// } -// } } }; } catch (Exception e) { diff --git a/easy-agents-mcp/src/test/java/com/easyagents/mcp/client/McpEnvironmentCheckerTest.java b/easy-agents-mcp/src/test/java/com/easyagents/mcp/client/McpEnvironmentCheckerTest.java new file mode 100644 index 0000000..eaea008 --- /dev/null +++ b/easy-agents-mcp/src/test/java/com/easyagents/mcp/client/McpEnvironmentCheckerTest.java @@ -0,0 +1,158 @@ +package com.easyagents.mcp.client; + +import io.modelcontextprotocol.json.TypeRef; +import io.modelcontextprotocol.spec.McpClientTransport; +import io.modelcontextprotocol.spec.McpSchema; +import org.junit.Test; +import reactor.core.publisher.Mono; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * MCP 环境检测测试。 + */ +public class McpEnvironmentCheckerTest { + + @Test + public void checkValidStdioConfigWithoutProbe() { + String json = """ + { + "mcpServers": { + "test": { + "transport": "stdio", + "command": "java", + "args": ["-version"] + } + } + } + """; + + McpEnvironmentCheckResult result = new McpEnvironmentChecker(false).check(json); + + assertEquals(McpCheckStatus.SUCCESS, result.getOverallStatus()); + assertEquals("test", result.getServers().get(0).getServerName()); + } + + @Test + public void checkMissingCommand() { + String json = """ + { + "mcpServers": { + "test": { + "transport": "stdio" + } + } + } + """; + + McpEnvironmentCheckResult result = new McpEnvironmentChecker(false).check(json); + + assertEquals(McpCheckStatus.FAILED, result.getOverallStatus()); + } + + @Test + public void checkMissingHttpUrl() { + String json = """ + { + "mcpServers": { + "test": { + "transport": "http-sse" + } + } + } + """; + + McpEnvironmentCheckResult result = new McpEnvironmentChecker(false).check(json); + + assertEquals(McpCheckStatus.FAILED, result.getOverallStatus()); + } + + @Test + public void checkUnresolvedInputEnv() { + String json = """ + { + "mcpServers": { + "test": { + "transport": "stdio", + "command": "java", + "env": { + "API_KEY": "${input:api_key}" + } + } + } + } + """; + + McpEnvironmentCheckResult result = new McpEnvironmentChecker(false).check(json); + + assertEquals(McpCheckStatus.FAILED, result.getOverallStatus()); + assertFalse(result.getServers().get(0).getChecks().toString().contains("secret")); + } + + @Test + public void checkInvalidJson() { + McpEnvironmentCheckResult result = new McpEnvironmentChecker(false).check("{ invalid json }"); + + assertEquals(McpCheckStatus.FAILED, result.getOverallStatus()); + } + + @Test + public void closeTransportWhenProbeFailed() { + TrackingCloseableTransport closeableTransport = new TrackingCloseableTransport(); + McpEnvironmentChecker checker = new McpEnvironmentChecker(true, transport -> (spec, resolvedEnv) -> closeableTransport); + String json = """ + { + "mcpServers": { + "test": { + "transport": "stdio", + "command": "java" + } + } + } + """; + + McpEnvironmentCheckResult result = checker.check(json); + + assertEquals(McpCheckStatus.FAILED, result.getOverallStatus()); + assertTrue(closeableTransport.closed); + } + + private static class TrackingCloseableTransport implements CloseableTransport { + private boolean closed; + + @Override + public McpClientTransport getTransport() { + return new FailingClientTransport(); + } + + @Override + public void close() { + closed = true; + } + } + + private static class FailingClientTransport implements McpClientTransport { + @Override + public Mono connect(java.util.function.Function, + Mono> handler) { + return Mono.error(new IllegalStateException("probe failed")); + } + + @Override + public Mono closeGracefully() { + return Mono.empty(); + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + return Mono.empty(); + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return null; + } + } +} diff --git a/easy-agents-mcp/src/test/java/com/easyagents/mcp/client/StdioTransportFactoryTest.java b/easy-agents-mcp/src/test/java/com/easyagents/mcp/client/StdioTransportFactoryTest.java new file mode 100644 index 0000000..b23757a --- /dev/null +++ b/easy-agents-mcp/src/test/java/com/easyagents/mcp/client/StdioTransportFactoryTest.java @@ -0,0 +1,58 @@ +package com.easyagents.mcp.client; + +import io.modelcontextprotocol.client.transport.ServerParameters; +import io.modelcontextprotocol.client.transport.StdioClientTransport; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Stdio MCP transport factory tests. + */ +public class StdioTransportFactoryTest { + + @Test + public void createWithResolvedEnv() throws Exception { + McpConfig.ServerSpec spec = new McpConfig.ServerSpec(); + spec.setCommand("npx"); + spec.setArgs(List.of("-y", "test-mcp-server")); + + CloseableTransport closeableTransport = new StdioTransportFactory() + .create(spec, Map.of("API_KEY", "resolved-secret")); + + assertTrue(closeableTransport.getTransport() instanceof StdioClientTransport); + StdioClientTransport transport = (StdioClientTransport) closeableTransport.getTransport(); + ServerParameters parameters = extractParameters(transport); + + assertEquals("npx", parameters.getCommand()); + assertEquals(List.of("-y", "test-mcp-server"), parameters.getArgs()); + assertEquals("resolved-secret", parameters.getEnv().get("API_KEY")); + } + + @Test + public void createWithNullArgsAndEnv() throws Exception { + McpConfig.ServerSpec spec = new McpConfig.ServerSpec(); + spec.setCommand("python"); + spec.setArgs(null); + + CloseableTransport closeableTransport = new StdioTransportFactory().create(spec, null); + + assertTrue(closeableTransport.getTransport() instanceof StdioClientTransport); + StdioClientTransport transport = (StdioClientTransport) closeableTransport.getTransport(); + ServerParameters parameters = extractParameters(transport); + + assertEquals("python", parameters.getCommand()); + assertEquals(List.of(), parameters.getArgs()); + } + + private ServerParameters extractParameters(StdioClientTransport transport) throws Exception { + Field paramsField = StdioClientTransport.class.getDeclaredField("params"); + paramsField.setAccessible(true); + return (ServerParameters) paramsField.get(transport); + } +}