feat: 对接 Agent MCP 能力

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

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

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

View File

@@ -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<AgentToolSpec> toolSpecs = new ArrayList<>();
private List<McpSpec> mcpSpecs = new ArrayList<>();
private List<AgentOperateToolSpec> operateToolSpecs = new ArrayList<>();
private List<AgentKnowledgeSpec> 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<McpSpec> getMcpSpecs() {
return mcpSpecs;
}
/**
* 设置 MCP 声明。
*
* @param mcpSpecs MCP 声明
*/
public void setMcpSpecs(List<McpSpec> mcpSpecs) {
this.mcpSpecs = mcpSpecs == null ? new ArrayList<>() : new ArrayList<>(mcpSpecs);
}
/**
* 获取操作类工具定义。
*

View File

@@ -31,4 +31,10 @@ public interface AgentRuntime {
* @return 运行事件流
*/
Flux<AgentRuntimeEvent> resume(AgentResumeRequest request);
/**
* 关闭运行器并释放底层资源。
*/
default void close() {
}
}

View File

@@ -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<McpClientWrapper> mcpClients = new CopyOnWriteArrayList<>();
/**
* 使用默认适配器创建运行时。
@@ -112,6 +118,7 @@ public class AgentScopeReActRuntime implements AgentRuntime {
if (!initialized.compareAndSet(false, true)) {
throw new AgentRuntimeException("Agent runtime has already been initialized.");
}
try {
this.initRequest = request;
this.runtimeContext = createRuntimeContext(request);
this.skillContext = AgentSkillRuntimeContext.from(request.getAgentDefinition().getSkillBoxSpec());
@@ -121,6 +128,20 @@ public class AgentScopeReActRuntime implements AgentRuntime {
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<AgentToolSpec> runtimeToolSpecs = mergeToolSpecs(definition.getToolSpecs(), toolkitBuildResult.mcpToolSpecs(),
toolkitBuildResult.operateToolSpecs());
interceptors.add(new ToolHitlInterceptor(eventBridge, approvalCoordinator,
mergeToolSpecs(definition.getToolSpecs(), toolkitBuildResult.operateToolSpecs())));
runtimeToolSpecs));
// 注册旁路事件监听器与主线路干预器。观察器只发旁路事件,不修改 AgentScope HookEvent。
List<AgentRuntimeObserver> 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<String, List<AgentTool>> 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<AgentToolSpec> 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<AgentToolSpec> mergeToolSpecs(List<AgentToolSpec> toolSpecs, List<AgentToolSpec> operateToolSpecs) {
private List<AgentToolSpec> mergeToolSpecs(List<AgentToolSpec> toolSpecs,
List<AgentToolSpec> mcpToolSpecs,
List<AgentToolSpec> operateToolSpecs) {
List<AgentToolSpec> 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<String, List<AgentTool>> skillTools,
List<AgentToolSpec> mcpToolSpecs,
List<AgentToolSpec> operateToolSpecs) {
}
}

View File

@@ -142,6 +142,9 @@ public class ToolHitlInterceptor implements AgentRuntimeInterceptor {
Map<String, Object> 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<String, Object> payload, AgentToolSpec toolSpec) {
if (toolSpec == null || toolSpec.getMetadata() == null || toolSpec.getMetadata().isEmpty()) {
return;
}
Map<String, Object> 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<String, Object> payload, Map<String, Object> metadata, String key) {
if (metadata.containsKey(key)) {
payload.put(key, metadata.get(key));
}
}
private String approvalPrompt(AgentToolApprovalRequest approvalRequest) {
if (approvalRequest != null
&& approvalRequest.getApprovalPrompt() != null

View File

@@ -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<String, AgentToolSpec> 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<AgentToolSpec> toolSpecs) {
this.eventBridge = eventBridge;
this.skillContext = skillContext;
this.toolSpecs = (toolSpecs == null ? List.<AgentToolSpec>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<String, Object> 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<String, Object> payload, Map<String, Object> metadata, String key) {
if (metadata.containsKey(key)) {
payload.put(key, metadata.get(key));
}
}
private boolean success(ToolResultBlock result) {
if (result == null) {
return false;

View File

@@ -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<String, String> rawToAlias;
private final Map<String, String> aliasToRaw;
private final String toolNamePrefix;
/**
* 创建 MCP client 别名包装器。
*
* @param delegate 原始 MCP client
* @param rawToAlias 原始工具名到运行时工具名的映射
*/
AliasedMcpClientWrapper(McpClientWrapper delegate, Map<String, String> rawToAlias) {
this(delegate, rawToAlias, null);
}
/**
* 创建 MCP client 别名包装器。
*
* @param delegate 原始 MCP client
* @param rawToAlias 原始工具名到运行时工具名的映射
* @param toolNamePrefix 动态工具名前缀
*/
AliasedMcpClientWrapper(McpClientWrapper delegate, Map<String, String> 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<Void> initialize() {
return delegate.initialize().doOnSuccess(ignored -> initialized = delegate.isInitialized());
}
/**
* 返回已替换为运行时别名的工具列表。
*
* @return 工具列表
*/
@Override
public Mono<List<McpSchema.Tool>> listTools() {
return delegate.listTools().map(this::aliasTools);
}
/**
* 调用 MCP 工具,运行时别名会映射回原始工具名。
*
* @param toolName 运行时工具名
* @param arguments 工具参数
* @return 工具调用结果
*/
@Override
public Mono<McpSchema.CallToolResult> callTool(String toolName, Map<String, Object> arguments) {
return delegate.callTool(rawToolName(toolName), arguments);
}
/**
* 关闭底层 MCP client。
*/
@Override
public void close() {
delegate.close();
initialized = false;
}
private List<McpSchema.Tool> aliasTools(List<McpSchema.Tool> tools) {
if (tools == null || tools.isEmpty()) {
cachedTools.clear();
return List.of();
}
List<McpSchema.Tool> aliased = new ArrayList<>();
cachedTools.clear();
Map<String, String> 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<String, String> 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<String, Object> 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<String, String> 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);
}
}

View File

@@ -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;
}
}

View File

@@ -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<McpClientWrapper> clients;
private final List<AgentToolSpec> toolSpecs;
/**
* 创建 MCP 注册结果。
*
* @param clients 已创建 MCP client
* @param toolSpecs 已注册工具声明
*/
public McpRegistration(List<McpClientWrapper> clients, List<AgentToolSpec> 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<McpClientWrapper> getClients() {
return clients;
}
/**
* 获取已注册工具声明。
*
* @return 已注册工具声明
*/
public List<AgentToolSpec> getToolSpecs() {
return toolSpecs;
}
}

View File

@@ -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<String> args = new ArrayList<>();
private Map<String, String> env = new LinkedHashMap<>();
private String url;
private Map<String, String> headers = new LinkedHashMap<>();
private Map<String, String> queryParams = new LinkedHashMap<>();
private Duration timeout = Duration.ofSeconds(120);
private Duration initializationTimeout = Duration.ofSeconds(30);
private List<String> enableTools = new ArrayList<>();
private List<String> disableTools = new ArrayList<>();
private String groupName;
private Map<String, Map<String, Object>> presetParameters = new LinkedHashMap<>();
private Map<String, String> toolAliases = new LinkedHashMap<>();
private String toolNamePrefix;
private boolean approvalRequired;
private AgentToolApprovalRequest approvalRequest = new AgentToolApprovalRequest();
private Map<String, AgentToolApprovalRequest> toolApprovalRequests = new LinkedHashMap<>();
private Map<String, Object> 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<String> getArgs() {
return args;
}
/**
* 设置 stdio 参数。
*
* @param args stdio 参数
*/
public void setArgs(List<String> args) {
this.args = args == null ? new ArrayList<>() : new ArrayList<>(args);
}
/**
* 获取 stdio 环境变量。
*
* @return stdio 环境变量
*/
public Map<String, String> getEnv() {
return env;
}
/**
* 设置 stdio 环境变量。
*
* @param env stdio 环境变量
*/
public void setEnv(Map<String, String> 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<String, String> getHeaders() {
return headers;
}
/**
* 设置 HTTP 请求头。
*
* @param headers HTTP 请求头
*/
public void setHeaders(Map<String, String> headers) {
this.headers = headers == null ? new LinkedHashMap<>() : new LinkedHashMap<>(headers);
}
/**
* 获取 HTTP 查询参数。
*
* @return HTTP 查询参数
*/
public Map<String, String> getQueryParams() {
return queryParams;
}
/**
* 设置 HTTP 查询参数。
*
* @param queryParams HTTP 查询参数
*/
public void setQueryParams(Map<String, String> 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<String> getEnableTools() {
return enableTools;
}
/**
* 设置启用工具白名单。
*
* @param enableTools 启用工具白名单
*/
public void setEnableTools(List<String> enableTools) {
this.enableTools = enableTools == null ? new ArrayList<>() : new ArrayList<>(enableTools);
}
/**
* 获取禁用工具黑名单。
*
* @return 禁用工具黑名单
*/
public List<String> getDisableTools() {
return disableTools;
}
/**
* 设置禁用工具黑名单。
*
* @param disableTools 禁用工具黑名单
*/
public void setDisableTools(List<String> 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<String, Map<String, Object>> getPresetParameters() {
return presetParameters;
}
/**
* 设置预置参数。
*
* @param presetParameters 预置参数
*/
public void setPresetParameters(Map<String, Map<String, Object>> presetParameters) {
this.presetParameters = presetParameters == null ? new LinkedHashMap<>() : new LinkedHashMap<>(presetParameters);
}
/**
* 获取 MCP 原始工具名到运行时工具名的别名映射。
*
* @return 工具别名映射
*/
public Map<String, String> getToolAliases() {
return toolAliases;
}
/**
* 设置 MCP 原始工具名到运行时工具名的别名映射。
*
* @param toolAliases 工具别名映射
*/
public void setToolAliases(Map<String, String> 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<String, AgentToolApprovalRequest> getToolApprovalRequests() {
return toolApprovalRequests;
}
/**
* 设置运行时工具名到审批请求的映射。
*
* @param toolApprovalRequests 工具审批请求映射
*/
public void setToolApprovalRequests(Map<String, AgentToolApprovalRequest> toolApprovalRequests) {
this.toolApprovalRequests = toolApprovalRequests == null
? new LinkedHashMap<>()
: new LinkedHashMap<>(toolApprovalRequests);
}
/**
* 获取元数据。
*
* @return 元数据
*/
public Map<String, Object> getMetadata() {
return metadata;
}
/**
* 设置元数据。
*
* @param metadata 元数据
*/
public void setMetadata(Map<String, Object> metadata) {
this.metadata = metadata == null ? new LinkedHashMap<>() : new LinkedHashMap<>(metadata);
}
}

View File

@@ -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<String, String> aliases = spec.getToolAliases();
if (aliases == null || aliases.isEmpty()) {
return;
}
Set<String> runtimeNames = new HashSet<>();
for (Map.Entry<String, String> 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<AgentToolSpec> businessToolSpecs,
List<AgentToolSpec> mcpToolSpecs,
List<AgentOperateToolSpec> operateToolSpecs) {
Set<String> names = new HashSet<>();
addToolNames(names, businessToolSpecs, "Agent tool conflicts with existing tool: ");
addToolNames(names, mcpToolSpecs, "MCP tool conflicts with existing tool: ");
Set<String> 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<String> names, List<AgentToolSpec> 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());
}
}
}
}

View File

@@ -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<McpSpec> 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<McpClientWrapper> clients = new ArrayList<>();
List<AgentToolSpec> 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<McpSchema.Tool> registeredTools(McpSpec spec, McpClientWrapper client) {
List<McpSchema.Tool> tools = client.listTools().block();
if (tools == null || tools.isEmpty()) {
return List.of();
}
List<McpSchema.Tool> 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<String> enableTools, List<String> disableTools) {
if (enableTools != null && !enableTools.isEmpty()) {
return enableTools.contains(toolName);
}
return disableTools == null || disableTools.isEmpty() || !disableTools.contains(toolName);
}
private List<AgentToolSpec> toToolSpecs(McpSpec spec, List<McpSchema.Tool> tools) {
if (tools == null || tools.isEmpty()) {
return List.of();
}
List<AgentToolSpec> toolSpecs = new ArrayList<>();
for (McpSchema.Tool tool : tools) {
AgentToolSpec toolSpec = new AgentToolSpec();
Set<String> excludedPresetNames = Set.of();
Map<String, Object> 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<String, Object> metadata(McpSpec spec, McpSchema.Tool tool) {
Map<String, Object> 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<String, String> 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<McpClientWrapper> clients) {
for (McpClientWrapper client : clients) {
if (client == null) {
continue;
}
try {
client.close();
} catch (Exception ignored) {
}
}
}
private <T> List<T> emptyToNull(List<T> values) {
return values == null || values.isEmpty() ? null : values;
}
private <K, V> Map<K, V> emptyToNull(Map<K, V> values) {
return values == null || values.isEmpty() ? null : values;
}
private String blankToNull(String value) {
return value == null || value.isBlank() ? null : value;
}
}

View File

@@ -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";
};
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,107 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* 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;
}
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
*/
package com.easyagents.mcp.client;
/**
* MCP 检测状态。
*/
public enum McpCheckStatus {
/**
* 检测通过。
*/
SUCCESS,
/**
* 检测存在警告但不一定阻断使用。
*/
WARNING,
/**
* 检测失败。
*/
FAILED
}

View File

@@ -0,0 +1,84 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* 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<McpServerCheckResult> 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<McpServerCheckResult> getServers() {
return servers;
}
/**
* 设置 Server 检测结果。
*
* @param servers Server 检测结果
*/
public void setServers(List<McpServerCheckResult> servers) {
this.servers = servers == null ? new ArrayList<>() : new ArrayList<>(servers);
}
}

View File

@@ -0,0 +1,318 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* 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<String> SUPPORTED_TRANSPORTS = Set.of("stdio", "http-sse", "http-stream");
private static final Set<String> KNOWN_VERSION_COMMANDS = Set.of(
"node", "npm", "npx", "pnpm", "python", "python3", "pip", "pip3");
private final boolean probeEnabled;
private final Function<String, McpTransportFactory> 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<String, McpTransportFactory> 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<String, McpConfig.ServerSpec> 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<String, String> 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<String, String> 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<String, String> resolveEnv(Map<String, String> env, McpServerCheckResult result) {
Map<String, String> resolved = new HashMap<>();
if (env == null || env.isEmpty()) {
result.addCheck(McpCheckItem.of("env", McpCheckStatus.SUCCESS,
"未配置额外环境变量", null));
return resolved;
}
for (Map.Entry<String, String> 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);
}
}
}

View File

@@ -0,0 +1,124 @@
/*
* Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com).
* <p>
* 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<McpCheckItem> 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<McpCheckItem> getChecks() {
return checks;
}
/**
* 设置检测项。
*
* @param checks 检测项
*/
public void setChecks(List<McpCheckItem> checks) {
this.checks = checks == null ? new ArrayList<>() : new ArrayList<>(checks);
}
}

View File

@@ -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<String, String> resolvedEnv) {
// ProcessBuilder pb = new ProcessBuilder();
// List<String> 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<String> args = spec.getArgs() == null ? Collections.emptyList() : spec.getArgs();
Map<String, String> 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) {

View File

@@ -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<Void> connect(java.util.function.Function<Mono<McpSchema.JSONRPCMessage>,
Mono<McpSchema.JSONRPCMessage>> handler) {
return Mono.error(new IllegalStateException("probe failed"));
}
@Override
public Mono<Void> closeGracefully() {
return Mono.empty();
}
@Override
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message) {
return Mono.empty();
}
@Override
public <T> T unmarshalFrom(Object data, TypeRef<T> typeRef) {
return null;
}
}
}

View File

@@ -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);
}
}