feat: 完善 Agent Skill 渐进披露运行时

- 支持 Skill 绑定 MCP 冻结清单和延迟注册

- 拒绝同步工具工作流进入不可恢复挂起状态
This commit is contained in:
2026-08-19 21:51:16 +08:00
parent 49a7de34bb
commit c7d410d755
19 changed files with 1417 additions and 59 deletions

View File

@@ -18,6 +18,7 @@ import com.easyagents.agent.runtime.knowledge.citation.AgentKnowledgeCitationMat
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.McpSkillRegistration;
import com.easyagents.agent.runtime.mcp.McpSpecValidator;
import com.easyagents.agent.runtime.mcp.McpToolkitAdapter;
import com.easyagents.agent.runtime.persistence.session.noop.NoopAgentSessionStore;
@@ -218,7 +219,7 @@ public class AgentScopeReActRuntime implements AgentRuntime {
saveSession();
return Flux.just(started(executionContext), cancelled(executionContext));
}).doOnNext(event -> executionContext.getConversationRecorder().record(executionContext, event))
.doFinally(signalType -> cleanupTurn());
.doFinally(signalType -> cleanupStreamSegment(false));
}
return runAgentStreamAfterLock(executionContext, List::of);
});
@@ -238,6 +239,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
if (!running.compareAndSet(false, true)) {
return Flux.error(new AgentRuntimeException("Agent runtime is already streaming."));
}
// 新用户消息建立新的 Turn上一 Turn 的 MCP 级批准不能跨轮复用。
approvalCoordinator.clearReusableApprovalScopes();
return runAgentStreamAfterLock(executionContext, inputSupplier);
});
}
@@ -296,8 +299,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
.doOnNext(event -> executionContext.getConversationRecorder().record(executionContext, event))
// 处理中断请求
.doOnCancel(() -> cancelInternal(executionContext, sideEvents, finalText, finalMessage, cancelled))
// 释放运行锁并清掉 turn context
.doFinally(signalType -> cleanupTurn());
// HITL 挂起时保留当前 Turn 的 MCP 批准,其余终态完整清理
.doFinally(signalType -> cleanupStreamSegment(suspendedEvent.get() != null));
}
/**
@@ -582,7 +585,7 @@ public class AgentScopeReActRuntime implements AgentRuntime {
suspended.getMetadata().put("approvalStatus", AgentToolApprovalResolution.Status.WAITING.name());
return Flux.just(started(context), suspended)
.doOnNext(event -> context.getConversationRecorder().record(context, event))
.doFinally(signalType -> cleanupTurn());
.doFinally(signalType -> cleanupStreamSegment(true));
}
/**
@@ -791,10 +794,15 @@ public class AgentScopeReActRuntime implements AgentRuntime {
}
/**
* 清理本轮状态。
* 清理一次 stream/resume 片段状态。
*
* @param preserveReusableApprovalScopes 是否因 HITL 挂起而保留当前 Turn 的 MCP 批准
*/
private void cleanupTurn() {
private void cleanupStreamSegment(boolean preserveReusableApprovalScopes) {
approvalCoordinator.clearExecutionAuthorizations();
if (!preserveReusableApprovalScopes) {
approvalCoordinator.clearReusableApprovalScopes();
}
turnContextHolder.clear();
running.set(false);
}
@@ -1123,7 +1131,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
AgentScopeMemoryBuildResult memoryResult = memoryAdapter.createMemoryResult(null, definition.getMemoryPolicy(), model);
Memory memory = memoryResult.getMemory();
Knowledge knowledge = knowledgeAdapter.createAggregateKnowledge(context, turnContextHolder);
SkillBox skillBox = skillAdapter.createSkillBox(definition.getSkillBoxSpec(), toolkit, skillTools);
SkillBox skillBox = skillAdapter.createSkillBox(definition.getSkillBoxSpec(), toolkit, skillTools,
toolkitBuildResult.skillMcpRegistrations());
// AutoContextInterceptor 是官方 AutoContextHook 的替代实现。这里仍只注册统一 runtime hook
// 避免官方 hook 与 Easy-Agents interceptor 同时触发压缩和 inputMessages 改写。
AgentRuntimeEventBridge eventBridge = new AgentRuntimeEventBridge(context, turnContextHolder);
@@ -1202,7 +1211,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(), List.of());
return new AgentScopeToolkitBuildResult(skillTools, List.of(), List.of(), List.of());
}
for (AgentToolSpec toolSpec : context.getAgentDefinition().getToolSpecs()) {
AgentToolInvoker invoker = context.getToolInvokers().get(toolSpec.getName());
@@ -1222,7 +1231,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
context.getAgentDefinition().getOperateToolSpecs(), toolkit);
McpSpecValidator.validateToolConflicts(context.getAgentDefinition().getToolSpecs(),
mcpRegistration.getToolSpecs(), context.getAgentDefinition().getOperateToolSpecs());
return new AgentScopeToolkitBuildResult(skillTools, mcpRegistration.getToolSpecs(), operateToolSpecs);
return new AgentScopeToolkitBuildResult(skillTools, mcpRegistration.getToolSpecs(), operateToolSpecs,
mcpRegistration.getSkillRegistrations());
}
private List<AgentToolSpec> mergeToolSpecs(List<AgentToolSpec> toolSpecs,
@@ -1290,7 +1300,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
}
private record AgentScopeToolkitBuildResult(Map<String, List<AgentTool>> skillTools,
List<AgentToolSpec> mcpToolSpecs,
List<AgentToolSpec> operateToolSpecs) {
List<AgentToolSpec> mcpToolSpecs,
List<AgentToolSpec> operateToolSpecs,
List<McpSkillRegistration> skillMcpRegistrations) {
}
}

View File

@@ -4,12 +4,14 @@ import com.easyagents.agent.runtime.AgentRuntimeException;
import com.easyagents.agent.runtime.skill.AgentSkillBoxSpec;
import com.easyagents.agent.runtime.skill.AgentSkillCompiler;
import com.easyagents.agent.runtime.skill.AgentSkillSpec;
import com.easyagents.agent.runtime.mcp.McpSkillRegistration;
import io.agentscope.core.skill.AgentSkill;
import io.agentscope.core.skill.SkillBox;
import io.agentscope.core.tool.AgentTool;
import io.agentscope.core.tool.Toolkit;
import java.util.List;
import java.util.LinkedHashMap;
import java.util.Map;
/**
@@ -67,6 +69,22 @@ public class AgentScopeSkillAdapter implements AgentSkillCompiler<AgentSkill> {
* @return SkillBox未配置 Skill 时返回 null
*/
public SkillBox createSkillBox(AgentSkillBoxSpec spec, Toolkit toolkit, Map<String, List<AgentTool>> skillTools) {
return createSkillBox(spec, toolkit, skillTools, List.of());
}
/**
* 创建并绑定静态工具及 MCP 工具的 AgentScope SkillBox。
*
* @param spec SkillBox 声明
* @param toolkit Toolkit 实例
* @param skillTools 按 Skill ID 分组的静态工具
* @param skillMcpRegistrations 按 Skill 延迟激活的 MCP client
* @return SkillBox未配置 Skill 时返回 null
*/
public SkillBox createSkillBox(AgentSkillBoxSpec spec,
Toolkit toolkit,
Map<String, List<AgentTool>> skillTools,
List<McpSkillRegistration> skillMcpRegistrations) {
if (spec == null || spec.getSkills().isEmpty()) {
return null;
}
@@ -74,10 +92,15 @@ public class AgentScopeSkillAdapter implements AgentSkillCompiler<AgentSkill> {
? new SkillBox(toolkit)
: new SkillBox(toolkit, spec.getSkillBoxId());
skillBox.setExposeAllSkillMetadata(spec.isExposeAllSkillMetadata());
Map<String, List<McpSkillRegistration>> mcpBySkill = groupMcpRegistrations(skillMcpRegistrations);
for (AgentSkillSpec skillSpec : spec.getSkills()) {
AgentSkill skill = compile(skillSpec);
List<AgentTool> tools = skillTools == null ? List.of() : skillTools.getOrDefault(skillSpec.getSkillId(), List.of());
if (tools.isEmpty()) {
List<McpSkillRegistration> mcpRegistrations = mcpBySkill.remove(skillSpec.getSkillId());
if (mcpRegistrations == null) {
mcpRegistrations = List.of();
}
if (tools.isEmpty() && mcpRegistrations.isEmpty()) {
skillBox.registration()
.skill(skill)
.toolkit(toolkit)
@@ -97,11 +120,42 @@ public class AgentScopeSkillAdapter implements AgentSkillCompiler<AgentSkill> {
.agentTool(tool)
.apply();
}
for (McpSkillRegistration mcpRegistration : mcpRegistrations) {
skillBox.registration()
.skill(skill)
.toolkit(toolkit)
.enableTools(mcpRegistration.getEnableTools())
.disableTools(mcpRegistration.getDisableTools())
.presetParameters(mcpRegistration.getPresetParameters())
.mcpClient(mcpRegistration.getClient())
.apply();
}
}
if (!mcpBySkill.isEmpty()) {
throw new AgentRuntimeException("Skill-bound MCP references unknown skill: "
+ mcpBySkill.keySet().iterator().next());
}
skillBox.syncToolGroupStates();
return skillBox;
}
private Map<String, List<McpSkillRegistration>> groupMcpRegistrations(
List<McpSkillRegistration> registrations) {
Map<String, List<McpSkillRegistration>> grouped = new LinkedHashMap<>();
if (registrations == null) {
return grouped;
}
for (McpSkillRegistration registration : registrations) {
if (registration == null || registration.getSkillId() == null
|| registration.getSkillId().isBlank()) {
throw new AgentRuntimeException("Skill-bound MCP skill id is required.");
}
grouped.computeIfAbsent(registration.getSkillId(), key -> new java.util.ArrayList<>())
.add(registration);
}
return grouped;
}
/**
* 校验 Skill 声明是否具备 AgentScope 注册和模型提示所需的必要信息。
*

View File

@@ -561,6 +561,7 @@ public class AgentScopeToolAdapter {
}
target.put("skillId", binding.getSkillId());
target.put("skillName", binding.getSkillName());
target.put("skillDisplayName", binding.getSkillDisplayName());
target.put("skillBoxId", binding.getSkillBoxId());
}

View File

@@ -270,10 +270,12 @@ public class SkillExecutionObserver implements AgentRuntimeObserver {
}
event.getPayload().put("skillId", call.getSkillId());
event.getPayload().put("skillName", call.getSkillName());
event.getPayload().put("skillDisplayName", call.getSkillDisplayName());
event.getPayload().put("skillBoxId", call.getSkillBoxId());
event.getPayload().put("path", call.getPath());
event.getMetadata().put("skillId", call.getSkillId());
event.getMetadata().put("skillName", call.getSkillName());
event.getMetadata().put("skillDisplayName", call.getSkillDisplayName());
event.getMetadata().put("skillBoxId", call.getSkillBoxId());
}
@@ -283,6 +285,7 @@ public class SkillExecutionObserver implements AgentRuntimeObserver {
}
target.put("skillId", binding.getSkillId());
target.put("skillName", binding.getSkillName());
target.put("skillDisplayName", binding.getSkillDisplayName());
target.put("skillBoxId", binding.getSkillBoxId());
}

View File

@@ -9,8 +9,6 @@ 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;
import io.agentscope.core.message.ContentBlock;
import io.agentscope.core.message.TextBlock;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.message.ToolUseBlock;
import reactor.core.publisher.Mono;
@@ -103,12 +101,7 @@ public class ToolExecutionObserver implements AgentRuntimeObserver {
runtimeEvent.getPayload().put("toolCallId", toolUse.getId());
runtimeEvent.getPayload().put("name", toolUse.getName());
runtimeEvent.getPayload().put("toolName", toolUse.getName());
runtimeEvent.getPayload().put("input", toolUse.getInput());
runtimeEvent.getPayload().put("content", toolUse.getContent());
runtimeEvent.getPayload().put("status", "RUNNING");
runtimeEvent.getPayload().put("source", "HOOK");
runtimeEvent.getPayload().put("phase", "PRE_ACTING");
runtimeEvent.getMetadata().putAll(nullToEmpty(toolUse.getMetadata()));
enrichToolPayload(runtimeEvent, toolUse.getName());
eventBridge.emit(runtimeEvent);
}
@@ -129,15 +122,8 @@ public class ToolExecutionObserver implements AgentRuntimeObserver {
runtimeEvent.getPayload().put("toolCallId", toolCallId);
runtimeEvent.getPayload().put("name", toolName);
runtimeEvent.getPayload().put("toolName", toolName);
runtimeEvent.getPayload().put("text", resultText(result));
runtimeEvent.getPayload().put("suspended", result != null && result.isSuspended());
runtimeEvent.getPayload().put("status", success(result) ? "SUCCESS" : "FAILED");
runtimeEvent.getPayload().put("success", success(result));
runtimeEvent.getPayload().put("source", "HOOK");
runtimeEvent.getPayload().put("phase", "POST_ACTING");
if (result != null) {
runtimeEvent.getMetadata().putAll(nullToEmpty(result.getMetadata()));
}
enrichToolPayload(runtimeEvent, toolName);
eventBridge.emit(runtimeEvent);
}
@@ -149,12 +135,7 @@ public class ToolExecutionObserver implements AgentRuntimeObserver {
}
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);
putIfPresent(runtimeEvent.getPayload(), metadata, "skillId");
}
private void putIfPresent(Map<String, Object> payload, Map<String, Object> metadata, String key) {
@@ -171,25 +152,6 @@ public class ToolExecutionObserver implements AgentRuntimeObserver {
return !(success instanceof Boolean) || Boolean.TRUE.equals(success);
}
private String resultText(ToolResultBlock result) {
if (result == null || result.getOutput() == null || result.getOutput().isEmpty()) {
return "";
}
StringBuilder builder = new StringBuilder();
for (ContentBlock block : result.getOutput()) {
if (block instanceof TextBlock textBlock) {
builder.append(textBlock.getText());
} else {
builder.append(block);
}
}
return builder.toString();
}
private Map<String, Object> nullToEmpty(Map<String, Object> map) {
return map == null ? new LinkedHashMap<>() : map;
}
private boolean isSkillTool(String toolName) {
if (skillContext == null) {
return false;

View File

@@ -0,0 +1,155 @@
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 Tool 清单冻结为只读白名单视图。
*/
final class FrozenMcpClientWrapper extends McpClientWrapper {
private final McpClientWrapper delegate;
private final List<McpSchema.Tool> frozenTools;
private final Map<String, String> runtimeToRaw = new LinkedHashMap<>();
/**
* 创建冻结 MCP client 视图。
*
* @param delegate 原始 client
* @param actualTools 已一次性读取并验证的远端 Tool
* @param manifest 冻结清单
* @param aliases 显式运行别名
* @param prefix 运行名前缀
*/
FrozenMcpClientWrapper(McpClientWrapper delegate,
List<McpSchema.Tool> actualTools,
List<McpToolManifestEntry> manifest,
Map<String, String> aliases,
String prefix) {
super(delegate == null ? "mcp" : delegate.getName());
this.delegate = delegate;
this.frozenTools = freeze(actualTools, manifest, aliases, prefix);
this.frozenTools.forEach(tool -> cachedTools.put(tool.name(), tool));
}
/** {@inheritDoc} */
@Override
public Mono<Void> initialize() {
return delegate.initialize().doOnSuccess(ignored -> initialized = delegate.isInitialized());
}
/** {@inheritDoc} */
@Override
public Mono<List<McpSchema.Tool>> listTools() {
return Mono.just(frozenTools);
}
/** {@inheritDoc} */
@Override
public Mono<McpSchema.CallToolResult> callTool(String toolName, Map<String, Object> arguments) {
return delegate.callTool(runtimeToRaw.getOrDefault(toolName, toolName), arguments);
}
/** {@inheritDoc} */
@Override
public void close() {
delegate.close();
initialized = false;
}
/**
* 按冻结 manifest 顺序裁剪并应用稳定运行别名。
*
* @param actualTools 远端当前 Tool
* @param manifest 冻结清单
* @param aliases 显式别名
* @param prefix 动态前缀
* @return 不可变 Tool 白名单
*/
private List<McpSchema.Tool> freeze(List<McpSchema.Tool> actualTools,
List<McpToolManifestEntry> manifest,
Map<String, String> aliases,
String prefix) {
Map<String, McpSchema.Tool> actualByName = new LinkedHashMap<>();
if (actualTools != null) {
actualTools.stream().filter(tool -> tool != null && tool.name() != null)
.forEach(tool -> actualByName.put(tool.name(), tool));
}
Map<String, String> usedRuntimeNames = new LinkedHashMap<>();
List<McpSchema.Tool> result = new ArrayList<>();
for (McpToolManifestEntry entry : manifest) {
McpSchema.Tool actual = actualByName.get(entry.getName());
if (actual == null) {
throw new IllegalStateException("Frozen MCP tool is missing after validation: " + entry.getName());
}
String runtimeName = uniqueRuntimeName(
runtimeName(actual.name(), aliases, prefix), actual.name(), usedRuntimeNames);
runtimeToRaw.put(runtimeName, actual.name());
Map<String, Object> meta = new LinkedHashMap<>();
if (actual.meta() != null) {
meta.putAll(actual.meta());
}
if (!runtimeName.equals(actual.name())) {
meta.put(AliasedMcpClientWrapper.RAW_TOOL_NAME_META_KEY, actual.name());
}
// 模型可见描述也必须来自发布时冻结清单,避免远端描述在运行中漂移。
result.add(new McpSchema.Tool(runtimeName, actual.title(), entry.getDescription(),
actual.inputSchema(), actual.outputSchema(), actual.annotations(), meta));
}
return List.copyOf(result);
}
/**
* 计算单个 Tool 的运行名。
*
* @param rawName 原始名称
* @param aliases 显式别名
* @param prefix 动态前缀
* @return 运行名
*/
private String runtimeName(String rawName, Map<String, String> aliases, String prefix) {
String alias = aliases == null ? null : aliases.get(rawName);
if (alias != null && !alias.isBlank()) {
return alias;
}
if (prefix == null || prefix.isBlank()) {
return rawName;
}
String segment = String.valueOf(rawName == null ? "" : rawName).trim()
.replaceAll("[^A-Za-z0-9_-]", "_")
.replaceAll("_+", "_");
return prefix.trim() + (segment.isBlank() ? "tool" : segment);
}
/**
* 避免别名碰撞。
*
* @param candidate 候选运行名
* @param rawName 原始名称
* @param used 已使用运行名
* @return 唯一运行名
*/
private String uniqueRuntimeName(String candidate,
String rawName,
Map<String, String> used) {
String existing = used.get(candidate);
if (existing == null || existing.equals(rawName)) {
used.put(candidate, rawName);
return candidate;
}
int suffix = 2;
String value = candidate + "_" + suffix;
while (used.containsKey(value)) {
suffix++;
value = candidate + "_" + suffix;
}
used.put(value, rawName);
return value;
}
}

View File

@@ -13,6 +13,7 @@ public class McpRegistration {
private final List<McpClientWrapper> clients;
private final List<AgentToolSpec> toolSpecs;
private final List<McpSkillRegistration> skillRegistrations;
/**
* 创建 MCP 注册结果。
@@ -21,8 +22,24 @@ public class McpRegistration {
* @param toolSpecs 已注册工具声明
*/
public McpRegistration(List<McpClientWrapper> clients, List<AgentToolSpec> toolSpecs) {
this(clients, toolSpecs, List.of());
}
/**
* 创建 MCP 注册结果。
*
* @param clients 已创建 MCP client
* @param toolSpecs 已发现工具声明
* @param skillRegistrations 等待注册到 Skill 的 MCP client
*/
public McpRegistration(List<McpClientWrapper> clients,
List<AgentToolSpec> toolSpecs,
List<McpSkillRegistration> skillRegistrations) {
this.clients = clients == null ? List.of() : new ArrayList<>(clients);
this.toolSpecs = toolSpecs == null ? List.of() : new ArrayList<>(toolSpecs);
this.skillRegistrations = skillRegistrations == null
? List.of()
: new ArrayList<>(skillRegistrations);
}
/**
@@ -51,4 +68,13 @@ public class McpRegistration {
public List<AgentToolSpec> getToolSpecs() {
return toolSpecs;
}
/**
* 获取等待注册到 Skill 的 MCP client。
*
* @return Skill MCP 注册声明
*/
public List<McpSkillRegistration> getSkillRegistrations() {
return skillRegistrations;
}
}

View File

@@ -0,0 +1,88 @@
package com.easyagents.agent.runtime.mcp;
import io.agentscope.core.tool.mcp.McpClientWrapper;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 等待注册到指定 Skill 的 MCP client。
*/
public class McpSkillRegistration {
private final String skillId;
private final McpClientWrapper client;
private final List<String> enableTools;
private final List<String> disableTools;
private final Map<String, Map<String, Object>> presetParameters;
/**
* 创建 Skill MCP 注册声明。
*
* @param skillId Skill ID
* @param client MCP client
* @param enableTools 运行时工具白名单
* @param disableTools 运行时工具黑名单
* @param presetParameters 预设参数
*/
public McpSkillRegistration(String skillId,
McpClientWrapper client,
List<String> enableTools,
List<String> disableTools,
Map<String, Map<String, Object>> presetParameters) {
this.skillId = skillId;
this.client = client;
this.enableTools = enableTools == null ? List.of() : new ArrayList<>(enableTools);
this.disableTools = disableTools == null ? List.of() : new ArrayList<>(disableTools);
this.presetParameters = presetParameters == null
? Map.of()
: new LinkedHashMap<>(presetParameters);
}
/**
* 获取 Skill ID。
*
* @return Skill ID
*/
public String getSkillId() {
return skillId;
}
/**
* 获取 MCP client。
*
* @return MCP client
*/
public McpClientWrapper getClient() {
return client;
}
/**
* 获取运行时工具白名单。
*
* @return 工具白名单
*/
public List<String> getEnableTools() {
return enableTools;
}
/**
* 获取运行时工具黑名单。
*
* @return 工具黑名单
*/
public List<String> getDisableTools() {
return disableTools;
}
/**
* 获取预设参数。
*
* @return 预设参数
*/
public Map<String, Map<String, Object>> getPresetParameters() {
return presetParameters;
}
}

View File

@@ -33,6 +33,9 @@ public class McpSpec {
private boolean approvalRequired;
private AgentToolApprovalRequest approvalRequest = new AgentToolApprovalRequest();
private Map<String, AgentToolApprovalRequest> toolApprovalRequests = new LinkedHashMap<>();
private String skillId;
private List<McpToolManifestEntry> frozenToolManifest = new ArrayList<>();
private String frozenToolManifestHash;
private Map<String, Object> metadata = new LinkedHashMap<>();
/**
@@ -406,6 +409,62 @@ public class McpSpec {
: new LinkedHashMap<>(toolApprovalRequests);
}
/**
* 获取所属 Skill ID。
*
* @return Skill ID未绑定 Skill 时为空
*/
public String getSkillId() {
return skillId;
}
/**
* 设置所属 Skill ID。
*
* @param skillId Skill ID
*/
public void setSkillId(String skillId) {
this.skillId = skillId;
}
/**
* 获取冻结 Tool 清单。
*
* @return 冻结 Tool 清单
*/
public List<McpToolManifestEntry> getFrozenToolManifest() {
return frozenToolManifest;
}
/**
* 设置冻结 Tool 清单。
*
* @param frozenToolManifest 冻结 Tool 清单
*/
public void setFrozenToolManifest(List<McpToolManifestEntry> frozenToolManifest) {
this.frozenToolManifest = frozenToolManifest == null
? new ArrayList<>()
: new ArrayList<>(frozenToolManifest);
}
/**
* 获取冻结 Tool 清单 hash。
*
* @return 清单 hash
*/
public String getFrozenToolManifestHash() {
return frozenToolManifestHash;
}
/**
* 设置冻结 Tool 清单 hash。
*
* @param frozenToolManifestHash 清单 hash
*/
public void setFrozenToolManifestHash(String frozenToolManifestHash) {
this.frozenToolManifestHash = frozenToolManifestHash;
}
/**
* 获取元数据。
*

View File

@@ -0,0 +1,332 @@
package com.easyagents.agent.runtime.mcp;
import com.alibaba.fastjson2.JSON;
import com.easyagents.agent.runtime.AgentRuntimeException;
import io.modelcontextprotocol.spec.McpSchema;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
/**
* MCP Tool 冻结清单规范化与完整性校验器。
*/
public final class McpToolManifest {
/** MCP Tool 原始名称允许的最大 Unicode 字符数。 */
public static final int MAX_TOOL_NAME_LENGTH = 128;
/** MCP Tool 描述允许的最大 Unicode 字符数。 */
public static final int MAX_TOOL_DESCRIPTION_LENGTH = 4_096;
/** 单个输入或输出 Schema 允许的最大 UTF-8 字节数。 */
public static final int MAX_SCHEMA_UTF8_BYTES = 256 * 1_024;
/** 完整规范化 Manifest 允许的最大 UTF-8 字节数。 */
public static final int MAX_MANIFEST_UTF8_BYTES = 2 * 1_024 * 1_024;
private McpToolManifest() {
}
/**
* 将 MCP Tool 转换为稳定清单项。
*
* @param tools MCP Tool 列表
* @return 按名称稳定排序的清单
*/
public static List<McpToolManifestEntry> fromTools(List<McpSchema.Tool> tools) {
if (tools == null || tools.isEmpty()) {
return List.of();
}
List<McpToolManifestEntry> entries = new ArrayList<>();
Set<String> names = new HashSet<>();
int manifestBytes = 2;
for (McpSchema.Tool tool : tools) {
if (tool == null || tool.name() == null || tool.name().isBlank()) {
continue;
}
if (!names.add(tool.name())) {
throw new AgentRuntimeException("Duplicate MCP tool name: " + tool.name());
}
McpToolManifestEntry entry = new McpToolManifestEntry();
entry.setName(tool.name());
entry.setDescription(normalizeText(tool.description()));
entry.setInputSchema(normalizeSchema("MCP tool input schema", tool.inputSchema()));
entry.setOutputSchema(normalizeSchema("MCP tool output schema", tool.outputSchema()));
assertEntryBounds(entry);
manifestBytes += JSON.toJSONString(toCanonicalValue(entry))
.getBytes(StandardCharsets.UTF_8).length;
if (!entries.isEmpty()) {
manifestBytes++;
}
if (manifestBytes > MAX_MANIFEST_UTF8_BYTES) {
throw new AgentRuntimeException("MCP tool manifest exceeds "
+ MAX_MANIFEST_UTF8_BYTES + " UTF-8 bytes.");
}
entries.add(entry);
}
entries.sort(Comparator.comparing(McpToolManifestEntry::getName));
assertManifestSize(entries);
return List.copyOf(entries);
}
/**
* 计算冻结清单的 SHA-256。
*
* @param entries 冻结清单
* @return 十六进制 SHA-256
*/
public static String hash(List<McpToolManifestEntry> entries) {
String json = canonicalJson(entries);
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(json.getBytes(StandardCharsets.UTF_8));
return java.util.HexFormat.of().formatHex(digest);
} catch (NoSuchAlgorithmException error) {
throw new AgentRuntimeException("SHA-256 is unavailable for MCP manifest validation.", error);
}
}
/**
* 校验远端 Tool 与冻结白名单一致,同时忽略远端新增 Tool。
*
* @param spec MCP 运行时声明
* @param actualTools 远端当前 Tool
* @throws AgentRuntimeException 冻结清单缺失、被篡改、Tool 缺失或 Schema 漂移时抛出
*/
public static void assertFrozenManifest(McpSpec spec, List<McpSchema.Tool> actualTools) {
if (spec == null || spec.getSkillId() == null || spec.getSkillId().isBlank()) {
return;
}
List<McpToolManifestEntry> expected = spec.getFrozenToolManifest();
String expectedHash = spec.getFrozenToolManifestHash();
if (expected == null || expected.isEmpty() || expectedHash == null || expectedHash.isBlank()) {
throw new AgentRuntimeException("Skill-bound MCP requires a frozen tool manifest: " + spec.getName());
}
if (!expectedHash.equals(hash(expected))) {
throw new AgentRuntimeException("Skill-bound MCP frozen tool manifest is invalid: " + spec.getName());
}
Set<String> frozenNames = new HashSet<>();
for (McpToolManifestEntry entry : expected) {
if (entry != null && entry.getName() != null && !entry.getName().isBlank()) {
frozenNames.add(entry.getName());
}
}
Map<String, McpToolManifestEntry> actualByName = new LinkedHashMap<>();
if (actualTools != null) {
for (McpSchema.Tool tool : actualTools) {
if (tool == null || tool.name() == null || !frozenNames.contains(tool.name())) {
continue;
}
if (actualByName.containsKey(tool.name())) {
throw new AgentRuntimeException("Duplicate MCP tool name: " + tool.name());
}
List<McpToolManifestEntry> normalized = fromTools(List.of(tool));
if (!normalized.isEmpty()) {
actualByName.put(tool.name(), normalized.get(0));
}
}
}
for (McpToolManifestEntry expectedEntry : expected) {
if (expectedEntry == null || expectedEntry.getName() == null || expectedEntry.getName().isBlank()) {
throw new AgentRuntimeException("Skill-bound MCP frozen tool name is required: " + spec.getName());
}
McpToolManifestEntry actualEntry = actualByName.get(expectedEntry.getName());
if (actualEntry == null) {
throw new AgentRuntimeException("Skill-bound MCP tool is missing: " + expectedEntry.getName());
}
if (!sameRuntimeSchema(expectedEntry, actualEntry)) {
throw new AgentRuntimeException("Skill-bound MCP tool schema has changed: "
+ expectedEntry.getName());
}
}
}
/**
* 比较 Runtime 必须锁定的 Tool 名称及输入、输出 Schema。
*
* <p>描述用于保存与发布阶段的完整 manifest 变更识别,但远端仅调整描述时不会改变
* 已发布 Tool 的可调用边界,因此运行时不应中断既有 Agent。</p>
*
* @param expected 冻结清单项
* @param actual 远端当前清单项
* @return 名称及 Schema 相同时返回 {@code true}
*/
private static boolean sameRuntimeSchema(McpToolManifestEntry expected,
McpToolManifestEntry actual) {
return java.util.Objects.equals(expected.getName(), actual.getName())
&& java.util.Objects.equals(normalizeJson(expected.getInputSchema()),
normalizeJson(actual.getInputSchema()))
&& java.util.Objects.equals(normalizeJson(expected.getOutputSchema()),
normalizeJson(actual.getOutputSchema()));
}
/**
* 将冻结清单转换为稳定 JSON并对反序列化后的清单执行同等边界校验。
*
* @param entries 冻结清单
* @return 稳定 JSON
* @throws AgentRuntimeException 清单包含重复名称或超出预算时抛出
*/
private static String canonicalJson(List<McpToolManifestEntry> entries) {
List<Map<String, Object>> canonical = new ArrayList<>();
Set<String> names = new HashSet<>();
if (entries != null) {
entries.stream()
.filter(entry -> entry != null && entry.getName() != null && !entry.getName().isBlank())
.sorted(Comparator.comparing(McpToolManifestEntry::getName))
.forEach(entry -> {
if (!names.add(entry.getName())) {
throw new AgentRuntimeException("Duplicate MCP tool name: " + entry.getName());
}
McpToolManifestEntry normalized = new McpToolManifestEntry();
normalized.setName(entry.getName());
normalized.setDescription(normalizeText(entry.getDescription()));
normalized.setInputSchema(normalizeSchema(
"MCP tool input schema", entry.getInputSchema()));
normalized.setOutputSchema(normalizeSchema(
"MCP tool output schema", entry.getOutputSchema()));
assertEntryBounds(normalized);
canonical.add(toCanonicalValue(normalized));
});
}
String json = JSON.toJSONString(canonical);
assertUtf8Size("MCP tool manifest", json, MAX_MANIFEST_UTF8_BYTES);
return json;
}
/**
* 校验单个清单项的名称、描述及 Schema 预算。
*
* @param entry 已规范化的清单项
* @throws AgentRuntimeException 任一字段超出预算时抛出
*/
private static void assertEntryBounds(McpToolManifestEntry entry) {
assertTextLength("MCP tool name", entry.getName(), MAX_TOOL_NAME_LENGTH);
assertTextLength("MCP tool description", entry.getDescription(), MAX_TOOL_DESCRIPTION_LENGTH);
assertSchemaSize("MCP tool input schema", entry.getInputSchema());
assertSchemaSize("MCP tool output schema", entry.getOutputSchema());
}
/**
* 校验规范化清单的聚合字节预算。
*
* @param entries 已规范化且排序的清单
* @throws AgentRuntimeException 清单超出聚合预算时抛出
*/
private static void assertManifestSize(List<McpToolManifestEntry> entries) {
List<Map<String, Object>> canonical = entries.stream()
.map(McpToolManifest::toCanonicalValue)
.toList();
assertUtf8Size("MCP tool manifest", JSON.toJSONString(canonical), MAX_MANIFEST_UTF8_BYTES);
}
/**
* 构造用于哈希和预算计算的稳定清单值。
*
* @param entry 已规范化的清单项
* @return 保持字段顺序的清单值
*/
private static Map<String, Object> toCanonicalValue(McpToolManifestEntry entry) {
Map<String, Object> value = new LinkedHashMap<>();
value.put("name", entry.getName());
value.put("description", normalizeText(entry.getDescription()));
value.put("inputSchema", entry.getInputSchema());
value.put("outputSchema", entry.getOutputSchema());
return value;
}
/**
* 校验 Unicode 字符长度,避免 UTF-16 代理对被重复计数。
*
* @param field 字段名称
* @param value 字段值
* @param maxLength 最大 Unicode 字符数
* @throws AgentRuntimeException 字段超长时抛出
*/
private static void assertTextLength(String field, String value, int maxLength) {
if (value != null && value.codePointCount(0, value.length()) > maxLength) {
throw new AgentRuntimeException(field + " exceeds " + maxLength + " characters.");
}
}
/**
* 校验单个 Schema 的 UTF-8 字节预算。
*
* @param field Schema 字段名称
* @param schema 已规范化 Schema
* @throws AgentRuntimeException Schema 超出预算时抛出
*/
private static void assertSchemaSize(String field, Object schema) {
if (schema != null) {
assertUtf8Size(field, JSON.toJSONString(schema), MAX_SCHEMA_UTF8_BYTES);
}
}
/**
* 校验 JSON 或文本的 UTF-8 字节长度。
*
* @param field 字段名称
* @param value 待校验文本
* @param maxBytes 最大 UTF-8 字节数
* @throws AgentRuntimeException 文本超出预算时抛出
*/
private static void assertUtf8Size(String field, String value, int maxBytes) {
int bytes = value.getBytes(StandardCharsets.UTF_8).length;
if (bytes > maxBytes) {
throw new AgentRuntimeException(field + " exceeds " + maxBytes + " UTF-8 bytes.");
}
}
/**
* 在解析和排序前限制原始 Schema避免超大输入进入规范化流程。
*
* @param field Schema 字段名称
* @param value 原始 Schema
* @return 规范化 Schema
* @throws AgentRuntimeException 原始 Schema 超出预算时抛出
*/
private static Object normalizeSchema(String field, Object value) {
if (value == null) {
return null;
}
String json = JSON.toJSONString(value);
assertUtf8Size(field, json, MAX_SCHEMA_UTF8_BYTES);
return sortJson(JSON.parse(json));
}
private static Object normalizeJson(Object value) {
if (value == null) {
return null;
}
return sortJson(JSON.parse(JSON.toJSONString(value)));
}
private static Object sortJson(Object value) {
if (value instanceof Map<?, ?> source) {
Map<String, Object> sorted = new TreeMap<>();
source.forEach((key, child) -> sorted.put(String.valueOf(key), sortJson(child)));
return sorted;
}
if (value instanceof List<?> source) {
List<Object> sorted = new ArrayList<>(source.size());
for (Object child : source) {
sorted.add(sortJson(child));
}
return sorted;
}
return value;
}
private static String normalizeText(String value) {
return value == null ? "" : value;
}
}

View File

@@ -0,0 +1,105 @@
package com.easyagents.agent.runtime.mcp;
import java.util.Objects;
/**
* MCP Tool 冻结清单项。
*/
public class McpToolManifestEntry {
private String name;
private String description;
private Object inputSchema;
private Object outputSchema;
/**
* 获取 Tool 名称。
*
* @return Tool 名称
*/
public String getName() {
return name;
}
/**
* 设置 Tool 名称。
*
* @param name Tool 名称
*/
public void setName(String name) {
this.name = name;
}
/**
* 获取 Tool 描述。
*
* @return Tool 描述
*/
public String getDescription() {
return description;
}
/**
* 设置 Tool 描述。
*
* @param description Tool 描述
*/
public void setDescription(String description) {
this.description = description;
}
/**
* 获取输入 Schema。
*
* @return 输入 Schema
*/
public Object getInputSchema() {
return inputSchema;
}
/**
* 设置输入 Schema。
*
* @param inputSchema 输入 Schema
*/
public void setInputSchema(Object inputSchema) {
this.inputSchema = inputSchema;
}
/**
* 获取输出 Schema。
*
* @return 输出 Schema
*/
public Object getOutputSchema() {
return outputSchema;
}
/**
* 设置输出 Schema。
*
* @param outputSchema 输出 Schema
*/
public void setOutputSchema(Object outputSchema) {
this.outputSchema = outputSchema;
}
@Override
public boolean equals(Object other) {
if (this == other) {
return true;
}
if (!(other instanceof McpToolManifestEntry that)) {
return false;
}
return Objects.equals(name, that.name)
&& Objects.equals(description, that.description)
&& Objects.equals(inputSchema, that.inputSchema)
&& Objects.equals(outputSchema, that.outputSchema);
}
@Override
public int hashCode() {
return Objects.hash(name, description, inputSchema, outputSchema);
}
}

View File

@@ -52,6 +52,7 @@ public class McpToolkitAdapter {
}
List<McpClientWrapper> clients = new ArrayList<>();
List<AgentToolSpec> toolSpecs = new ArrayList<>();
List<McpSkillRegistration> skillRegistrations = new ArrayList<>();
try {
for (McpSpec spec : specs) {
if (spec == null) {
@@ -59,16 +60,59 @@ public class McpToolkitAdapter {
}
McpSpecValidator.validateConnection(spec);
McpClientWrapper client = clientFactory.create(spec);
client = applyAliases(spec, client);
clients.add(client);
registerClient(spec, client, toolkit);
if (isSkillBound(spec)) {
List<McpSchema.Tool> actualTools = initializeAndListTools(client);
McpToolManifest.assertFrozenManifest(spec, actualTools);
client = new FrozenMcpClientWrapper(client, actualTools,
spec.getFrozenToolManifest(), spec.getToolAliases(), spec.getToolNamePrefix());
} else {
client = applyAliases(spec, client);
}
clients.set(clients.size() - 1, client);
if (isSkillBound(spec)) {
// Skill MCP 必须以冻结 manifest 派生白名单,调用方不能通过空列表放宽到远端全部 Tool。
spec.setEnableTools(frozenRuntimeToolNames(spec, client));
skillRegistrations.add(new McpSkillRegistration(
spec.getSkillId(), client, spec.getEnableTools(), spec.getDisableTools(),
spec.getPresetParameters()));
} else {
registerClient(spec, client, toolkit);
}
toolSpecs.addAll(toToolSpecs(spec, registeredTools(spec, client)));
}
} catch (RuntimeException error) {
closeQuietly(clients);
throw error;
}
return new McpRegistration(clients, toolSpecs);
return new McpRegistration(clients, toolSpecs, skillRegistrations);
}
/**
* 根据冻结原始 Tool 名称和别名后的远端清单生成强制运行白名单。
*
* @param spec Skill MCP 声明
* @param client 已应用运行别名的 client
* @return 冻结 Tool 对应的运行名
*/
private List<String> frozenRuntimeToolNames(McpSpec spec, McpClientWrapper client) {
Set<String> frozenRawNames = new LinkedHashSet<>();
for (McpToolManifestEntry entry : spec.getFrozenToolManifest()) {
if (entry != null && entry.getName() != null && !entry.getName().isBlank()) {
frozenRawNames.add(entry.getName());
}
}
List<String> names = new ArrayList<>();
for (McpSchema.Tool tool : listTools(client)) {
if (tool != null && frozenRawNames.contains(rawToolName(spec, tool))) {
names.add(tool.name());
}
}
if (names.size() != frozenRawNames.size()) {
throw new AgentRuntimeException("Skill-bound MCP frozen tool aliases are incomplete: "
+ spec.getName());
}
return List.copyOf(names);
}
private McpClientWrapper applyAliases(McpSpec spec, McpClientWrapper client) {
@@ -95,7 +139,7 @@ public class McpToolkitAdapter {
}
private List<McpSchema.Tool> registeredTools(McpSpec spec, McpClientWrapper client) {
List<McpSchema.Tool> tools = client.listTools().block();
List<McpSchema.Tool> tools = listTools(client);
if (tools == null || tools.isEmpty()) {
return List.of();
}
@@ -108,6 +152,30 @@ public class McpToolkitAdapter {
return filtered;
}
private List<McpSchema.Tool> listTools(McpClientWrapper client) {
List<McpSchema.Tool> tools = client.listTools().block();
return tools == null ? List.of() : tools;
}
/**
* 初始化 client 后读取一次远端 Tool 清单。
*
* @param client MCP client
* @return Tool 清单
*/
private List<McpSchema.Tool> initializeAndListTools(McpClientWrapper client) {
// AgentScope validates the initialized flag when listTools() is invoked. Build the
// second publisher only after initialization has completed, otherwise eager publisher
// assembly can fail even though the server initializes successfully moments later.
client.initialize().block();
List<McpSchema.Tool> tools = client.listTools().block();
return tools == null ? List.of() : tools;
}
private boolean isSkillBound(McpSpec spec) {
return spec.getSkillId() != null && !spec.getSkillId().isBlank();
}
private boolean shouldRegister(String toolName, List<String> enableTools, List<String> disableTools) {
if (enableTools != null && !enableTools.isEmpty()) {
return enableTools.contains(toolName);
@@ -166,6 +234,9 @@ public class McpToolkitAdapter {
metadata.put("rawMcpToolName", rawToolName(spec, tool));
metadata.put("toolDisplayName", toolDisplayName(spec, tool));
metadata.put("transportType", spec.getTransportType().configValue());
if (isSkillBound(spec)) {
metadata.put("skillId", spec.getSkillId());
}
return metadata;
}

View File

@@ -7,6 +7,7 @@ public class AgentSkillBinding {
private final String skillId;
private final String skillName;
private final String skillDisplayName;
private final String skillBoxId;
/**
@@ -17,8 +18,26 @@ public class AgentSkillBinding {
* @param skillBoxId SkillBox ID
*/
public AgentSkillBinding(String skillId, String skillName, String skillBoxId) {
this(skillId, skillName, skillName, skillBoxId);
}
/**
* 创建带展示名称的 Skill 绑定关系。
*
* @param skillId Skill ID
* @param skillName Skill 规范名称
* @param skillDisplayName Skill 展示名称
* @param skillBoxId SkillBox ID
*/
public AgentSkillBinding(String skillId,
String skillName,
String skillDisplayName,
String skillBoxId) {
this.skillId = skillId;
this.skillName = skillName;
this.skillDisplayName = skillDisplayName == null || skillDisplayName.isBlank()
? skillName
: skillDisplayName;
this.skillBoxId = skillBoxId;
}
@@ -40,6 +59,15 @@ public class AgentSkillBinding {
return skillName;
}
/**
* 获取 Skill 展示名称。
*
* @return Skill 展示名称
*/
public String getSkillDisplayName() {
return skillDisplayName;
}
/**
* 获取 SkillBox ID。
*
@@ -49,4 +77,3 @@ public class AgentSkillBinding {
return skillBoxId;
}
}

View File

@@ -11,6 +11,7 @@ public class AgentSkillLoadCall {
private final String toolCallId;
private final String skillId;
private final String skillName;
private final String skillDisplayName;
private final String skillBoxId;
private final String path;
private final Map<String, Object> input;
@@ -31,9 +32,33 @@ public class AgentSkillLoadCall {
String skillBoxId,
String path,
Map<String, Object> input) {
this(toolCallId, skillId, skillName, skillName, skillBoxId, path, input);
}
/**
* 创建带展示名称的 Skill 加载工具调用记录。
*
* @param toolCallId 工具调用 ID
* @param skillId Skill ID
* @param skillName Skill 规范名称
* @param skillDisplayName Skill 展示名称
* @param skillBoxId SkillBox ID
* @param path 资源路径
* @param input 工具输入
*/
public AgentSkillLoadCall(String toolCallId,
String skillId,
String skillName,
String skillDisplayName,
String skillBoxId,
String path,
Map<String, Object> input) {
this.toolCallId = toolCallId;
this.skillId = skillId;
this.skillName = skillName;
this.skillDisplayName = skillDisplayName == null || skillDisplayName.isBlank()
? skillName
: skillDisplayName;
this.skillBoxId = skillBoxId;
this.path = path;
this.input = input == null ? new LinkedHashMap<>() : new LinkedHashMap<>(input);
@@ -66,6 +91,15 @@ public class AgentSkillLoadCall {
return skillName;
}
/**
* 获取 Skill 展示名称。
*
* @return Skill 展示名称
*/
public String getSkillDisplayName() {
return skillDisplayName;
}
/**
* 获取 SkillBox ID。
*

View File

@@ -43,7 +43,8 @@ public class AgentSkillRuntimeContext {
continue;
}
skillBindings.put(skillSpec.getSkillId(),
new AgentSkillBinding(skillSpec.getSkillId(), skillSpec.getName(), spec.getSkillBoxId()));
new AgentSkillBinding(skillSpec.getSkillId(), skillSpec.getName(),
displayName(skillSpec), spec.getSkillBoxId()));
}
Map<String, AgentSkillBinding> toolBindings = new LinkedHashMap<>();
for (Map.Entry<String, List<String>> entry : spec.getToolBindings().entrySet()) {
@@ -171,6 +172,7 @@ public class AgentSkillRuntimeContext {
AgentSkillBinding binding = getSkillBinding(skillId);
AgentSkillLoadCall call = new AgentSkillLoadCall(toolCallId, skillId,
binding == null ? null : binding.getSkillName(),
binding == null ? null : binding.getSkillDisplayName(),
binding == null ? null : binding.getSkillBoxId(), path, input);
pendingLoadCalls.put(toolCallId, call);
return call;
@@ -206,4 +208,11 @@ public class AgentSkillRuntimeContext {
private static String stringValue(Object value) {
return value == null ? null : String.valueOf(value);
}
private static String displayName(AgentSkillSpec skillSpec) {
Object value = skillSpec.getMetadata() == null ? null : skillSpec.getMetadata().get("displayName");
return value == null || String.valueOf(value).isBlank()
? skillSpec.getName()
: String.valueOf(value);
}
}

View File

@@ -0,0 +1,168 @@
package com.easyagents.agent.runtime.mcp;
import com.easyagents.agent.runtime.AgentRuntimeException;
import io.modelcontextprotocol.spec.McpSchema;
import org.junit.Assert;
import org.junit.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 测试 MCP Tool 冻结清单的稳定化与服务端预算。
*/
public class McpToolManifestTest {
/**
* 验证远端返回重复原始 Tool 名称时立即拒绝。
*/
@Test
public void shouldRejectDuplicateRawToolNames() {
expectManifestFailure(
() -> McpToolManifest.fromTools(List.of(tool("search", "first", smallSchema()),
tool("search", "second", smallSchema()))),
"Duplicate");
}
/**
* 验证 Tool 名称超出字符预算时拒绝。
*/
@Test
public void shouldRejectOverlongToolName() {
String name = "n".repeat(McpToolManifest.MAX_TOOL_NAME_LENGTH + 1);
expectManifestFailure(() -> McpToolManifest.fromTools(List.of(
tool(name, "description", smallSchema()))), "name");
}
/**
* 验证 Tool 描述超出字符预算时拒绝。
*/
@Test
public void shouldRejectOverlongToolDescription() {
String description = "d".repeat(McpToolManifest.MAX_TOOL_DESCRIPTION_LENGTH + 1);
expectManifestFailure(() -> McpToolManifest.fromTools(List.of(
tool("search", description, smallSchema()))), "description");
}
/**
* 验证单个输入或输出 Schema 超出 UTF-8 预算时拒绝。
*/
@Test
public void shouldRejectOversizedSingleSchema() {
McpSchema.JsonSchema oversized = schemaWithDescription(
"x".repeat(McpToolManifest.MAX_SCHEMA_UTF8_BYTES));
expectManifestFailure(() -> McpToolManifest.fromTools(List.of(
tool("search", "description", oversized))), "schema");
}
/**
* 验证各 Schema 合法但规范化 Manifest 聚合超过预算时拒绝。
*/
@Test
public void shouldRejectOversizedAggregateManifest() {
McpSchema.JsonSchema schema = schemaWithDescription("x".repeat(220_000));
List<McpSchema.Tool> tools = new ArrayList<>();
for (int index = 0; index < 10; index++) {
tools.add(new McpSchema.Tool("tool_" + index, "tool_" + index, "description",
schema, null, null, null));
}
expectManifestFailure(() -> McpToolManifest.fromTools(tools), "manifest");
}
/**
* 验证哈希入口同样拒绝反序列化后的重复名称,避免绕过发布阶段校验。
*/
@Test
public void shouldRejectDuplicateNamesWhenHashingFrozenManifest() {
McpToolManifestEntry first = manifestEntry("search");
McpToolManifestEntry second = manifestEntry("search");
expectManifestFailure(() -> McpToolManifest.hash(List.of(first, second)), "Duplicate");
}
/**
* 验证运行时完全忽略冻结白名单外新增 Tool即使新增 Tool 超出发布清单预算。
*/
@Test
public void shouldIgnoreOversizedRemoteToolOutsideFrozenWhitelist() {
McpSchema.Tool frozenTool = tool("search", "description", smallSchema());
McpSpec spec = new McpSpec();
spec.setName("demo");
spec.setSkillId("skill-1");
spec.setFrozenToolManifest(McpToolManifest.fromTools(List.of(frozenTool)));
spec.setFrozenToolManifestHash(McpToolManifest.hash(spec.getFrozenToolManifest()));
McpSchema.Tool extraTool = tool(
"new_remote_tool",
"x".repeat(McpToolManifest.MAX_TOOL_DESCRIPTION_LENGTH + 1),
smallSchema());
McpToolManifest.assertFrozenManifest(spec, List.of(frozenTool, extraTool));
}
/**
* 构造普通 MCP Tool。
*
* @param name Tool 名称
* @param description Tool 描述
* @param schema 输入 Schema
* @return MCP Tool
*/
private McpSchema.Tool tool(String name, String description, McpSchema.JsonSchema schema) {
return new McpSchema.Tool(name, name, description, schema, null, null, null);
}
/**
* 构造小型合法 Schema。
*
* @return 合法 Schema
*/
private McpSchema.JsonSchema smallSchema() {
return schemaWithDescription("query");
}
/**
* 构造带指定属性描述的 Schema。
*
* @param description 属性描述
* @return MCP JSON Schema
*/
private McpSchema.JsonSchema schemaWithDescription(String description) {
return new McpSchema.JsonSchema("object",
Map.of("value", Map.of("type", "string", "description", description)),
List.of("value"), null, null, null);
}
/**
* 构造最小冻结清单项。
*
* @param name Tool 名称
* @return 冻结清单项
*/
private McpToolManifestEntry manifestEntry(String name) {
McpToolManifestEntry entry = new McpToolManifestEntry();
entry.setName(name);
entry.setDescription("description");
entry.setInputSchema(Map.of("type", "object"));
return entry;
}
/**
* 断言清单转换抛出包含指定片段的运行时异常。
*
* @param action 待执行动作
* @param messageFragment 预期错误片段
*/
private void expectManifestFailure(Runnable action, String messageFragment) {
try {
action.run();
Assert.fail("Expected MCP manifest validation failure.");
} catch (AgentRuntimeException expected) {
Assert.assertTrue(expected.getMessage(), expected.getMessage().contains(messageFragment));
}
}
}

View File

@@ -1,6 +1,9 @@
package com.easyagents.agent.runtime.mcp;
import com.easyagents.agent.runtime.AgentRuntimeException;
import com.easyagents.agent.runtime.agentscope.AgentScopeSkillAdapter;
import com.easyagents.agent.runtime.skill.AgentSkillBoxSpec;
import com.easyagents.agent.runtime.skill.AgentSkillSpec;
import com.easyagents.agent.runtime.tool.AgentToolSpec;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.tool.Toolkit;
@@ -161,6 +164,130 @@ public class McpToolkitAdapterTest {
}
}
/**
* 验证 Skill MCP 只注册到禁用的 Skill Tool Group加载前不向模型暴露。
*/
@Test
public void shouldRegisterSkillMcpAsInactiveSkillToolGroup() {
List<McpSchema.Tool> frozenTools = List.of(tool("search"));
FakeMcpClientWrapper client = new FakeMcpClientWrapper("demo",
List.of(tool("search"), tool("new_remote_tool")));
McpToolkitAdapter adapter = new McpToolkitAdapter(new FakeMcpClientFactory(client));
McpSpec spec = stdioSpec();
spec.setSkillId("skill-1");
spec.setToolAliases(Map.of("search", "skill_1_mcp_search"));
spec.setFrozenToolManifest(McpToolManifest.fromTools(frozenTools));
spec.setFrozenToolManifestHash(McpToolManifest.hash(spec.getFrozenToolManifest()));
Toolkit toolkit = new Toolkit();
McpRegistration registration = adapter.register(List.of(spec), toolkit);
Assert.assertNull(toolkit.getTool("skill_1_mcp_search"));
Assert.assertEquals(1, registration.getSkillRegistrations().size());
Assert.assertEquals(List.of("skill_1_mcp_search"),
registration.getSkillRegistrations().get(0).getEnableTools());
AgentSkillSpec skill = new AgentSkillSpec();
skill.setSkillId("skill-1");
skill.setName("Search Skill");
skill.setDescription("Search through MCP.");
skill.setSkillContent("Load this skill before searching.");
AgentSkillBoxSpec skillBoxSpec = new AgentSkillBoxSpec();
skillBoxSpec.setSkills(List.of(skill));
new AgentScopeSkillAdapter().createSkillBox(skillBoxSpec, toolkit, Map.of(),
registration.getSkillRegistrations());
Assert.assertNotNull(toolkit.getTool("skill_1_mcp_search"));
Assert.assertFalse(toolkit.getActiveGroups().contains("skill-1_skill_tools"));
Assert.assertTrue(toolkit.getToolSchemas().stream()
.noneMatch(schema -> "skill_1_mcp_search".equals(schema.getName())));
Assert.assertNull(toolkit.getTool("skill_1_mcp_new_remote_tool"));
Assert.assertEquals(1, client.remoteListCalls.get());
}
/**
* 验证 Skill MCP 会在读取 Tool 清单前完成异步初始化。
*/
@Test
public void shouldInitializeSkillMcpBeforeListingTools() {
FakeMcpClientWrapper client = new FakeMcpClientWrapper("demo", List.of(tool("search")));
client.deferInitialization = true;
McpToolkitAdapter adapter = new McpToolkitAdapter(new FakeMcpClientFactory(client));
McpSpec spec = stdioSpec();
spec.setSkillId("skill-1");
spec.setFrozenToolManifest(McpToolManifest.fromTools(List.of(tool("search"))));
spec.setFrozenToolManifestHash(McpToolManifest.hash(spec.getFrozenToolManifest()));
McpRegistration registration = adapter.register(List.of(spec), new Toolkit());
Assert.assertTrue(client.isInitialized());
Assert.assertEquals(1, client.remoteListCalls.get());
Assert.assertEquals(1, registration.getSkillRegistrations().size());
}
/**
* 验证冻结 Tool 缺失时拒绝注册并关闭 client。
*/
@Test
public void shouldRejectMissingFrozenSkillMcpToolAndCloseClient() {
FakeMcpClientWrapper client = new FakeMcpClientWrapper("demo", List.of(tool("other")));
McpToolkitAdapter adapter = new McpToolkitAdapter(new FakeMcpClientFactory(client));
McpSpec spec = stdioSpec();
spec.setSkillId("skill-1");
spec.setFrozenToolManifest(McpToolManifest.fromTools(List.of(tool("search"))));
spec.setFrozenToolManifestHash(McpToolManifest.hash(spec.getFrozenToolManifest()));
try {
adapter.register(List.of(spec), new Toolkit());
Assert.fail("Expected frozen MCP tool validation failure.");
} catch (AgentRuntimeException expected) {
Assert.assertTrue(expected.getMessage().contains("missing"));
Assert.assertTrue(client.closed.get());
}
}
/**
* 验证冻结 Tool Schema 漂移时拒绝注册。
*/
@Test(expected = AgentRuntimeException.class)
public void shouldRejectChangedFrozenSkillMcpSchema() {
McpSchema.Tool expectedTool = tool("search");
McpSchema.JsonSchema changedSchema = new McpSchema.JsonSchema("object",
Map.of("keyword", Map.of("type", "string")), List.of("keyword"), null, null, null);
McpSchema.Tool actualTool = new McpSchema.Tool("search", "search", "search description",
changedSchema, null, null, null);
McpToolkitAdapter adapter = new McpToolkitAdapter(
new FakeMcpClientFactory(new FakeMcpClientWrapper("demo", List.of(actualTool))));
McpSpec spec = stdioSpec();
spec.setSkillId("skill-1");
spec.setFrozenToolManifest(McpToolManifest.fromTools(List.of(expectedTool)));
spec.setFrozenToolManifestHash(McpToolManifest.hash(spec.getFrozenToolManifest()));
adapter.register(List.of(spec), new Toolkit());
}
/**
* 验证远端仅调整 Tool 描述时不破坏已发布 Skill 的运行兼容性。
*/
@Test
public void shouldAllowChangedDescriptionWhenFrozenSchemaIsStable() {
McpSchema.Tool expectedTool = tool("search");
McpSchema.Tool actualTool = new McpSchema.Tool(
"search", "search", "updated description",
expectedTool.inputSchema(), expectedTool.outputSchema(), null, null);
McpToolkitAdapter adapter = new McpToolkitAdapter(
new FakeMcpClientFactory(new FakeMcpClientWrapper("demo", List.of(actualTool))));
McpSpec spec = stdioSpec();
spec.setSkillId("skill-1");
spec.setFrozenToolManifest(McpToolManifest.fromTools(List.of(expectedTool)));
spec.setFrozenToolManifestHash(McpToolManifest.hash(spec.getFrozenToolManifest()));
McpRegistration registration = adapter.register(List.of(spec), new Toolkit());
Assert.assertEquals(1, registration.getSkillRegistrations().size());
Assert.assertEquals("search description", registration.getToolSpecs().get(0).getDescription());
}
private McpSpec stdioSpec() {
McpSpec spec = new McpSpec();
spec.setName("demo");
@@ -197,7 +324,10 @@ public class McpToolkitAdapterTest {
private final List<McpSchema.Tool> tools;
private final AtomicBoolean closed = new AtomicBoolean(false);
private final AtomicReference<String> lastCalledToolName = new AtomicReference<>();
private final java.util.concurrent.atomic.AtomicInteger remoteListCalls =
new java.util.concurrent.atomic.AtomicInteger();
private boolean failOnListTools;
private boolean deferInitialization;
private FakeMcpClientWrapper(String name, List<McpSchema.Tool> tools) {
super(name);
@@ -206,12 +336,19 @@ public class McpToolkitAdapterTest {
@Override
public Mono<Void> initialize() {
if (deferInitialization) {
return Mono.fromRunnable(() -> initialized = true);
}
initialized = true;
return Mono.empty();
}
@Override
public Mono<List<McpSchema.Tool>> listTools() {
if (!initialized) {
return Mono.error(new IllegalStateException("client is not initialized"));
}
remoteListCalls.incrementAndGet();
if (failOnListTools) {
return Mono.error(new IllegalStateException("list tools failed"));
}