feat: 完善 Agent Skill 渐进披露运行时
- 支持 Skill 绑定 MCP 冻结清单和延迟注册 - 拒绝同步工具工作流进入不可恢复挂起状态
This commit is contained in:
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 注册和模型提示所需的必要信息。
|
||||
*
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取元数据。
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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。
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user